Trilha 3 · Do conceito à produção · ~30 min · pleno/sênior

Duas da manhã. Alerta de produção: o FakeFlix está recusando play para assinantes pagantes. O time acorda, abre os logs e encontra... isto: console.log('erro', e), milhares de vezes, sem timestamp confiável, sem saber qual usuário, qual request, qual módulo. Uma linha dizia só undefined is not a function. Levamos três horas para descobrir que era um token JWT com um campo renomeado entre dois deploys — uma informação que um log estruturado teria entregado em três minutos.

No mesmo sistema, a suíte de testes era um museu de horrores: cada teste montava um objeto Subscription na mão, com 20 campos, e quando adicionamos um campo obrigatório, 400 testes quebraram de uma vez — não porque a lógica mudou, mas porque cada teste repetia a construção do objeto. E metade dos "testes unitários" subia o banco.

Esta aula é sobre as três coisas sem glamour que separam um código que "funciona na minha máquina" de um código que sobrevive em produção: testes que não quebram por atrito, logs que respondem perguntas às duas da manhã, e autenticação que atravessa módulos sem virar acoplamento. Não é a parte sexy da arquitetura. É a parte que te deixa dormir.

Factories de teste: matando a duplicação e o atrito

O problema dos 400 testes quebrados é de coesão aplicada a testes (aula Coesão: o outro lado da moeda): a construção de um Subscription estava duplicada em 400 lugares, então uma mudança no modelo tinha 400 pontos de manutenção. A factory concentra essa construção num lugar só.

Antes (construção repetida, frágil):

// ❌ cada teste remonta o objeto inteiro — 400 cópias disso
it('nega play para assinatura vencida', async () => {
  const sub = {
    id: 'sub-1', userId: 'u-1', status: 'active',
    currentPeriodEnd: new Date('2020-01-01'), plan: 'premium',
    createdAt: new Date(), updatedAt: new Date(), pendingCharge: false,
    // ...+12 campos que este teste NÃO usa mas precisa preencher
  } as Subscription;
  expect(service.isActive(sub)).toBe(false);
});

Depois (factory com defaults sensatos e override do que importa):

// ✅ test/factories/subscription.factory.ts — a construção mora AQUI, uma vez
export function makeSubscription(overrides: Partial<Subscription> = {}): Subscription {
  return {
    id: `sub-${randomUUID()}`,
    userId: `u-${randomUUID()}`,
    status: 'active',
    currentPeriodEnd: addMonths(new Date(), 1), // default: válida
    plan: 'premium',
    pendingCharge: false,
    createdAt: new Date(),
    updatedAt: new Date(),
    ...overrides, // ⬅️ o teste sobrescreve SÓ o que é relevante para ele
  };
}
// ✅ o teste agora declara APENAS o que importa para o caso
it('nega play para assinatura vencida', () => {
  const sub = makeSubscription({ currentPeriodEnd: new Date('2020-01-01') });
  expect(service.isActive(sub)).toBe(false);
});

it('nega play para assinatura cancelada', () => {
  const sub = makeSubscription({ status: 'cancelled' });
  expect(service.isActive(sub)).toBe(false);
});

Três ganhos que compõem: (1) adicionar um campo obrigatório ao modelo agora quebra um lugar (a factory), não 400; (2) cada teste comunica sua intenção — "esta é uma assinatura vencida" — em vez de afogá-la em 20 campos irrelevantes; (3) os defaults representam "o caso normal", então o override destaca exatamente a variável sob teste. Isso é legibilidade e manutenibilidade de teste como cidadania de primeira classe.

Princípio da factory: o teste deve declarar só o que é relevante para o que ele verifica; todo o resto vem de defaults sensatos. Se um teste precisa preencher campos que não importam para ele, você tem duplicação esperando para virar atrito.

Factories compõem

Para agregados, factories chamam factories — mantendo a coesão:

export function makeUserWithActiveSubscription(overrides: Partial<User> = {}): User {
  const user = makeUser(overrides);
  const sub = makeSubscription({ userId: user.id, status: 'active' });
  return { ...user, subscription: sub };
}

Testar em isolamento sem vazar persistência

Recapitulando o Princípio 9 (aula Os 10 princípios da arquitetura modular): um teste unitário de regra não deveria subir banco. A porta hexagonal (aula DDD estratégico + Arquitetura Hexagonal no FakeFlix) torna isso trivial — você injeta um fake em memória que implementa a mesma interface do repositório real. Combine com a factory:

// ✅ fake repository em memória, implementa a PORTA (não o TypeORM)
class InMemorySubscriptionRepository implements SubscriptionRepository {
  private store = new Map<string, Subscription>();
  seed(sub: Subscription) { this.store.set(sub.userId, sub); }
  async findByUserId(userId: string) { return this.store.get(userId) ?? null; }
  async save(sub: Subscription) { this.store.set(sub.userId, sub); }
}

it('isActive consulta o repositório e aplica a regra', async () => {
  const repo = new InMemorySubscriptionRepository();
  repo.seed(makeSubscription({ userId: 'u-1', currentPeriodEnd: new Date('2020-01-01') }));
  const service = new SubscriptionService(repo);      // regra testada SEM banco, SEM HTTP
  expect(await service.isUserSubscriptionActive('u-1')).toBe(false);
});

Este teste roda em milissegundos, não vaza nada de TypeORM, e — o ponto de arquitetura — verde aqui significa que a fronteira está de pé (aula Os 10 princípios da arquitetura modular, Princípio 9 como termômetro). Reserve os testes que sobem banco para a camada de integração (testar o TypeOrmSubscriptionRepository de verdade), poucos e específicos. A pirâmide: muitos testes de regra isolados (rápidos), poucos de integração (lentos), pouquíssimos e2e.

Não vaze persistência no teste de regra: um cheiro comum é o teste unitário importar SubscriptionEntity (a entity do ORM) em vez do modelo de domínio Subscription. Isso acopla o teste ao detalhe de persistência (e reintroduz o que a aula Persistência e State Isolation separou). O teste de regra fala a língua do domínio; só o teste de integração conhece a entity.

Logging estruturado: o que salva as duas da manhã

console.log é texto para humano lerem um por vez. Log estruturado é dado — JSON que máquinas filtram, agregam e correlacionam. A diferença entre três horas e três minutos de investigação é esta.

// ✅ shared/infra/logging/logger.ts — Winston com saída JSON estruturada
import { createLogger, format, transports } from 'winston';

export const logger = createLogger({
  level: process.env.LOG_LEVEL ?? 'info',
  format: format.combine(
    format.timestamp(),
    format.errors({ stack: true }),  // captura stack traces de verdade
    format.json(),                   // saída estruturada, não texto solto
  ),
  transports: [new transports.Console()],
});

Antes vs. depois de um log:

// ❌ inútil às 2h: sem contexto, sem quem, sem onde
console.log('erro ao liberar play', e);

// ✅ responde perguntas: quem, o quê, onde, e correlacionável
logger.error('playback_denied', {
  module: 'content',
  event: 'playback_denied',
  userId: user.id,
  videoId: video.id,
  reason: 'subscription_inactive',
  requestId: ctx.requestId,     // ⬅️ correlaciona TODOS os logs deste request
  err: e,
});

Com o segundo, às duas da manhã você filtra event: playback_denied e reason, agrupa por userId, e vê na hora "todos os negados têm subscription_inactive desde 01:47" — e correlaciona com o deploy de billing daquele horário. O requestId é o herói: ele amarra todos os logs de um mesmo request através dos módulos, transformando uma investigação de agulha-no-palheiro numa query.

Propagação de contexto entre módulos

O requestId (e o userId) precisa fluir por todo o processamento sem você passá-lo manualmente em cada função (o que seria acoplamento e ruído). A ferramenta é o contexto assíncrono (AsyncLocalStorage no Node), que carrega o contexto do request de forma implícita e segura:

// ✅ shared/infra/context/request-context.ts
import { AsyncLocalStorage } from 'async_hooks';
export const requestContext = new AsyncLocalStorage<{ requestId: string; userId?: string }>();

// middleware: abre o contexto no início de cada request
export function contextMiddleware(req, res, next) {
  requestContext.run({ requestId: req.headers['x-request-id'] ?? randomUUID(), userId: req.user?.id }, next);
}

// um logger que injeta o contexto automaticamente em toda linha
export function log(level: string, msg: string, meta: object = {}) {
  const ctx = requestContext.getStore();
  logger.log(level, msg, { ...ctx, ...meta }); // requestId/userId entram sozinhos
}

Agora qualquer log em qualquer módulo, durante aquele request, carrega requestId e userId sem ninguém passá-los à mão. É observabilidade que respeita as fronteiras: o contexto é infraestrutura compartilhada (mecanismo, como na aula Persistência e State Isolation), não acoplamento de domínio.

Níveis com propósito: error (algo quebrou, precisa de ação), warn (anômalo mas tratado — ex.: retry), info (fatos de negócio: subscription_activated), debug (detalhe de desenvolvimento, desligado em prod). Logar tudo como info é tão inútil quanto não logar — vira ruído. E nunca logue segredos (tokens, senhas, PAN de cartão): log estruturado facilita vazamento tanto quanto facilita busca.

JWT entre módulos: autenticação sem acoplamento

No FakeFlix, Identity emite o token (login), mas Billing e Content precisam saber quem é o usuário e se o token é válido. O erro clássico é cada módulo verificar o JWT por conta própria — o que espalha o segredo de assinatura e o algoritmo por todos os módulos, criando um acoplamento oculto: mudar a política de token vira uma caçada por todo o código.

A solução respeita a aula Comunicação entre módulos: o padrão Public API Provider: Identity é o dono da autenticação e expõe a validação como Public API. Os outros módulos perguntam, não verificam por conta própria.

// ✅ identity/public/identity-token.api.ts — o contrato (aula *Comunicação entre módulos: o padrão Public API Provider*)
export interface IdentityTokenApi {
  validate(token: string): Promise<{ userId: string; roles: string[] } | null>;
}
export const IDENTITY_TOKEN_API = Symbol('IdentityTokenApi');
// ✅ identity/public/identity-token.provider.ts — o segredo/algoritmo vivem SÓ aqui
@Injectable()
export class IdentityTokenProvider implements IdentityTokenApi {
  constructor(private readonly jwt: JwtService) {} // interno de identity
  async validate(token: string) {
    try {
      const payload = await this.jwt.verifyAsync(token); // conhece o segredo/alg
      return { userId: payload.sub, roles: payload.roles ?? [] };
    } catch {
      return null; // token inválido/expirado → null, sem vazar o porquê
    }
  }
}

O guard que protege as rotas dos outros módulos depende do contrato, não da implementação — exatamente o padrão da aula Comunicação entre módulos: o padrão Public API Provider:

// ✅ shared/auth/jwt-auth.guard.ts — usa a Public API de Identity
@Injectable()
export class JwtAuthGuard implements CanActivate {
  constructor(@Inject(IDENTITY_TOKEN_API) private readonly identity: IdentityTokenApi) {}
  async canActivate(ctx: ExecutionContext): Promise<boolean> {
    const req = ctx.switchToHttp().getRequest();
    const token = req.headers.authorization?.replace('Bearer ', '');
    if (!token) return false;
    const user = await this.identity.validate(token); // PERGUNTA a Identity
    if (!user) return false;
    req.user = user;      // disponibiliza { userId, roles } para o handler
    return true;
  }
}

Assim, Content e Billing sabem quem é o usuário sem nunca conhecer o segredo do JWT, o algoritmo ou o formato do payload. Se Identity trocar de HS256 para RS256, adicionar rotação de chave, ou migrar para tokens opacos com introspecção — muda só dentro de Identity; o contrato validate(token): {userId, roles} permanece, e nenhum outro módulo se mexe. Autenticação virou uma pergunta a um dono, não um conhecimento espalhado. E, de novo, isso já é a preparação para extração (aula Quando (e quando NÃO) migrar para microserviços): o dia em que Identity virar um serviço, o provider troca de local para uma chamada de introspecção remota, e os guards não mudam.

Antes → Depois

Antes Depois
400 testes montam o objeto na mão Factory concentra a construção; muda em 1 lugar
"Unitário" que sobe banco Regra testada com fake na porta, em ms
Teste importa a entity do ORM Teste fala a língua do domínio
console.log('erro', e) Log JSON com event, userId, requestId
Passar contexto à mão em cada função AsyncLocalStorage propaga requestId sozinho
Cada módulo verifica o JWT (segredo espalhado) Identity valida via Public API; segredo num lugar só

Tradeoffs e armadilhas

  • Factory pode esconder acoplamento entre testes. Se a factory ganhar lógica condicional demais ("se status X, então preenche Y"), ela vira um mini-framework frágil. Mantenha-a simples: defaults + overrides. Regra de negócio não entra na factory.
  • Fakes podem divergir do real. Um InMemoryRepository que se comporta diferente do TypeORM (ex.: não valida constraint única) pode deixar passar bug que só aparece em produção. Por isso os testes de integração do repositório real existem — poucos, mas existem. O fake acelera; a integração valida.
  • Log estruturado custa disciplina e dinheiro. JSON é mais verboso, e volume de log tem custo (armazenamento, indexação em ferramentas como Datadog/ELK). Logar demais é tão ruim quanto de menos: defina níveis, amostre logs de alto volume, e nunca logue no caminho quente sem pensar no custo.
  • Validar o token a cada request tem custo. Se IdentityTokenApi.validate fizer I/O pesado (ex.: consultar o banco a cada chamada), vira gargalo. Para JWT assinado, a validação é local (verificar assinatura) e barata; para tokens opacos com introspecção remota, considere cache com TTL curto — pagando um pouco de consistência (um token revogado pode valer por alguns segundos) por muita performance. Tradeoff consciente (aula Por que não existe arquitetura perfeita).
  • Não logue PII/segredos. A mesma estrutura que facilita busca facilita vazamento. Redija campos sensíveis antes de logar.

Cola mental

  • Factory = construção do objeto de teste num lugar só; o teste declara só o que importa (defaults + overrides). Muda o modelo → muda 1 lugar, não 400.
  • Teste de regra usa fake na porta (aula DDD estratégico + Arquitetura Hexagonal no FakeFlix), roda em ms, fala a língua do domínio; teste de integração (poucos) usa o repositório real. Verde no isolado = fronteira de pé (aula Os 10 princípios da arquitetura modular (princípio 9)).
  • Log estruturado (JSON) com event, userId, requestId. requestId correlaciona tudo de um request entre módulos. Propague contexto com AsyncLocalStorage, não à mão.
  • Níveis com propósito (error/warn/info/debug); nunca logue segredos.
  • JWT entre módulos: Identity é o dono, expõe validate(token) via Public API (aula Comunicação entre módulos: o padrão Public API Provider); os outros perguntam. Segredo/algoritmo num lugar só → trocar política de token não toca ninguém.

Referências para Aprofundamento

Testes

  • Object Mother e Test Data Builder — Os dois padrões por trás das factories da aula; o segundo, de Nat Pryce, é a origem do estilo fluente.
  • Practical Test Pyramid — Ham Vocke — Onde cada tipo de teste compensa, e por que testar o módulo em isolamento é o degrau que sustenta os outros.
  • Test Containers — Quando o teste realmente precisa de banco, sobe um descartável em vez de compartilhar um ambiente.

Observabilidade

  • The Twelve-Factor App — Logs — A regra que evita metade dos problemas: log é fluxo de eventos para stdout, não arquivo que a aplicação gerencia.
  • Pino — Logger estruturado em JSON para Node, com o custo de serialização que a aula menciona.
  • OpenTelemetry — O passo seguinte ao log estruturado: o correlationId da aula é um trace ID esperando para nascer.

Autenticação

Exercícios

Exercício 1 — Refatore para factory

Você tem 15 testes que montam um User completo (10 campos) na mão, e cada um só se importa com um campo (role, emailVerified, etc.). Escreva a makeUser factory (com defaults sensatos) e reescreva dois testes: um que verifica "admin tem acesso" e outro que verifica "usuário não-verificado é bloqueado".

Ver resposta

✓ Resposta: ``typescript export function makeUser(overrides: Partial<User> = {}): User { return { id:u-${randomUUID()}`, email: 'user@example.com', emailVerified: true, // default: caso normal role: 'member', createdAt: new Date(), updatedAt: new Date(), ...overrides, }; }

it('admin tem acesso', () => { const user = makeUser({ role: 'admin' }); expect(canAccessAdminPanel(user)).toBe(true); });

it('usuário não-verificado é bloqueado', () => { const user = makeUser({ emailVerified: false }); expect(canLogin(user)).toBe(false); }); `` Cada teste declara só a variável sob teste; o resto vem dos defaults. Adicionar um campo obrigatório aUser` muda só a factory.

Exercício 2 — Ache o vazamento de persistência

Critique este "teste unitário":

it('cancela assinatura', async () => {
  const entity = await getRepository(SubscriptionEntity).save({ userId: 'u1', status: 'active' });
  const service = new SubscriptionService(getRepository(SubscriptionEntity));
  await service.cancel('u1');
  const updated = await getRepository(SubscriptionEntity).findOne({ where: { userId: 'u1' } });
  expect(updated.status).toBe('cancelled');
});

Que princípio (aula Os 10 princípios da arquitetura modular) ele viola e como reescrevê-lo como teste de regra isolado?

Ver resposta

✓ Resposta: Viola Testability in Isolation (Princípio 9, aula Os 10 princípios da arquitetura modular): apesar do nome "unitário", ele sobe o banco (getRepository(SubscriptionEntity)), depende do TypeORM, e vaza persistência ao operar sobre SubscriptionEntity (o modelo de ORM, não o domínio — aula Persistência e State Isolation). É lento, frágil e testa a infraestrutura junto com a regra. Reescrita como teste de regra isolado: usar o fake na porta e a factory de domínio:

it('cancela assinatura', async () => {
  const repo = new InMemorySubscriptionRepository();
  repo.seed(makeSubscription({ userId: 'u1', status: 'active' }));
  const service = new SubscriptionService(repo);
  await service.cancel('u1');
  expect((await repo.findByUserId('u1'))!.status).toBe('cancelled');
});

Roda em ms, sem banco, falando a língua do domínio. (O comportamento real do TypeOrmSubscriptionRepository fica coberto por um teste de integração separado, poucos e específicos.)

Exercício 3 — Melhore o log

Transforme este log inútil num log estruturado que responderia à pergunta "por que os pagamentos estão falhando desde as 3h?": console.log('pagamento falhou');. Liste os campos que você incluiria e por quê.

Ver resposta

✓ Resposta: Log estruturado, por exemplo:

log('error', 'payment_failed', {
  module: 'billing',
  event: 'payment_failed',
  userId: user.id,
  gateway: 'stripe',
  gatewayErrorCode: err.code,   // ex.: 'card_declined' vs 'gateway_timeout'
  amount: charge.amount,
  requestId: ctx.requestId,
  err,
});

Campos e porquês: event (filtrar todos os payment_failed), gateway/gatewayErrorCode (distinguir "cartão recusado" de "gateway fora do ar" — se desde as 3h só há gateway_timeout, o problema é o PSP, não os cartões), userId/amount (avaliar impacto), requestId (correlacionar com o resto do fluxo), timestamp (implícito no Winston, para cruzar com o horário do deploy). Isso transforma "pagamentos estão falhando" em "desde 03:02, 98% das falhas são gateway_timeout no Stripe" — diagnóstico em minutos.

Exercício 4 — Projete a fronteira de auth

Content precisa restringir uma rota a usuários com role: 'admin'. Descreva como isso funciona respeitando a aula Comunicação entre módulos: o padrão Public API Provider — quem valida o token, quem checa a role, e por que Content nunca deve conhecer o segredo do JWT. O que muda se Identity migrar de JWT assinado para tokens opacos com introspecção?

Ver resposta

✓ Resposta: Fluxo respeitando a aula Comunicação entre módulos: o padrão Public API Provider: (1) Quem valida o token: o JwtAuthGuard chama IdentityTokenApi.validate(token) — ou seja, Identity valida (é o dono da autenticação), retornando { userId, roles }. Content nunca verifica o token por conta própria. (2) Quem checa a role: Content, sobre o resultado já validado — um RolesGuard/decorator (@Roles('admin')) confere se req.user.roles inclui admin. Autenticação (quem é você) é de Identity; autorização de aquela rota específica (você pode entrar aqui?) é de Content, que conhece suas próprias regras de acesso. (3) Por que Content nunca conhece o segredo: se cada módulo verificasse a assinatura, o segredo/algoritmo do JWT estaria espalhado por todos — mudar a política de token viraria caçada, e um vazamento em qualquer módulo comprometeria a auth inteira. Concentrando em Identity, o segredo mora num lugar só. Se Identity migrar para tokens opacos com introspecção: muda só a implementação do provider (validate passa a chamar um endpoint de introspecção remoto em vez de verificar assinatura local); o contrato validate(token): {userId, roles} permanece, então o guard de Content e o resto do sistema não mudam nada. É o Princípio da Evolutibilidade (aula Os 10 princípios da arquitetura modular) em ação.

Exercício 5 — Tradeoff de validação

Um dev propõe cachear o resultado de validate(token) por 10 minutos para reduzir carga. Que benefício e que risco isso traz? Em que tipo de token (assinado vs. opaco) o cache faz mais sentido, e como você limitaria o risco?

Ver resposta

✓ Resposta: Benefício: cachear validate(token) evita revalidar/introspectar o mesmo token a cada request, reduzindo latência e carga — relevante em APIs de alto volume. Risco: um token revogado (logout, ban, comprometimento) continuaria sendo aceito até o cache expirar — uma janela de até 10 minutos em que um acesso que deveria ser negado é permitido. É uma troca de segurança por performance (aula Por que não existe arquitetura perfeita). Onde faz mais sentido: em tokens opacos com introspecção remota, onde a validação custa uma chamada de rede — cachear elimina esse I/O. Em JWT assinado, a validação já é local e barata (verificar assinatura em memória), então o cache traz pouco benefício e ainda adiciona o risco de revogação atrasada — geralmente não compensa. Como limitar o risco: TTL curto (segundos a 1-2 min, não 10), invalidação ativa do cache em eventos de revogação (Identity publica TokenRevoked e os caches removem a entrada — aula Subdomínios e comunicação assíncrona (BullMQ): mensagens vs. eventos), e nunca cachear para operações sensíveis (pagamento, mudança de dados críticos), que revalidam sempre. Assim você fica com o ganho de performance no caminho comum e fecha a janela onde ela é perigosa.