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 comoinfoé 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
InMemoryRepositoryque 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.validatefizer 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.requestIdcorrelaciona tudo de um request entre módulos. Propague contexto comAsyncLocalStorage, 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
correlationIdda aula é um trace ID esperando para nascer.
Autenticação
- RFC 8725 — JSON Web Token Best Current Practices — As armadilhas do JWT descritas pela própria IETF, incluindo o
alg: nonee a confusão entre assinatura e criptografia. - OWASP JSON Web Token Cheat Sheet — Checklist prático de validação, expiração e revogaçã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.