Trilha 4 · Escala e evolução · ~20 min
Quando o FakeFlix começou a receber uploads de vídeo de verdade, o request de "publicar vídeo" começou a estourar timeout. O motivo era óbvio em retrospecto: o handler HTTP estava fazendo a transcodificação (converter o vídeo para várias resoluções) dentro do request. Transcodificar um vídeo de 2h leva minutos; nenhum request HTTP espera minutos. O usuário via erro, o vídeo às vezes processava mesmo assim, às vezes não. Caos.
A solução é separar o que precisa acontecer agora (registrar que o vídeo existe, responder ao usuário) do que pode acontecer depois (processar). Isso é comunicação assíncrona. Mas ao entrar nesse mundo, um erro conceitual custa caro, e quase todo time comete: confundir mensagem com evento. Parece purismo de vocabulário. Não é — é a diferença entre um design que escala e um que vira espaguete distribuído.
O assíncrono quebra o acoplamento temporal
Lembre da aula As dimensões do acoplamento: força, distância e tempo: acoplamento temporal síncrono é quando duas coisas precisam acontecer juntas no tempo. O handler de "publicar vídeo" estava temporalmente acoplado à transcodificação — um só terminava quando o outro terminava. Assíncrono quebra isso: o handler registra a intenção e retorna; a transcodificação roda quando um worker estiver livre.
O ganho é duplo: latência (o usuário recebe resposta em milissegundos) e resiliência/escala (se chegarem 500 vídeos de uma vez, eles enfileiram e são processados no ritmo dos workers, em vez de derrubar o sistema). Você troca imediatismo por vazão e robustez — o tradeoff clássico do async.
Mensagem vs. evento: a distinção que muda tudo
Duas formas de comunicação assíncrona que parecem iguais no código (ambas "põem algo numa fila") mas têm semânticas opostas:
Mensagem (comando): um pedido dirigido a um destinatário específico para fazer algo. Tempo verbal imperativo. "ProcesseEsteVídeo". Quem envia sabe quem vai receber e espera que uma ação aconteça. Há um acoplamento intencional: o remetente depende de existir alguém que execute o comando.
Evento: a notificação de um fato que já ocorreu. Tempo verbal passado. "VídeoFoiPublicado". Quem publica não sabe (nem se importa) quem vai ouvir. Zero, um ou dez consumidores podem reagir. O publicador não espera nada de volta; ele apenas anuncia o que aconteceu.
| Mensagem (comando) | Evento (fato) | |
|---|---|---|
| Intenção | "faça X" | "X aconteceu" |
| Tempo verbal | Imperativo/futuro | Passado |
| Remetente conhece o destinatário? | Sim | Não |
| Nº de consumidores | Tipicamente um | Zero a muitos |
| Acoplamento | Remetente → executor | Desacoplado (publish/subscribe) |
| Exemplo FakeFlix | ProcessVideo (comando para o worker) |
VideoPublished (fato que o catálogo, notificações e analytics podem ouvir) |
Por que isso muda o design? Porque determina quem depende de quem. Se você modela como evento algo que é na verdade um comando, você espalha a responsabilidade: ninguém "possui" a ação, e você acaba com múltiplos consumidores tentando fazer a mesma coisa, ou nenhum. Se você modela como comando algo que deveria ser evento, você acopla o publicador a cada novo interessado: toda vez que um novo módulo quiser reagir a "vídeo publicado", você tem que mexer em quem publica. A regra prática:
Comando quando há uma ação que precisa acontecer e você sabe quem a executa. Evento quando você está anunciando um fato e outros módulos podem querer reagir — agora ou no futuro — sem que você precise saber quais.
No FakeFlix, "processar este vídeo" é um comando (há um worker específico responsável, a ação precisa acontecer). "Vídeo foi publicado" é um evento (o catálogo atualiza a listagem, notificações avisa quem segue o criador, analytics registra — três reações independentes que não deveriam acoplar quem publica).
Código: BullMQ no FakeFlix
BullMQ (filas sobre Redis) é o mecanismo. Modelamos o comando de processar vídeo.
O producer (enfileira o comando, responde rápido):
// content/video-processor/producer/video-processing.producer.ts
@Injectable()
export class VideoProcessingJobProducer {
constructor(@InjectQueue(QUEUES.VIDEO_PROCESSING) private queue: Queue) {}
async processVideo(videoId: string, url: string) {
await this.queue.add<VideoProcessingJobData>('process', {
videoId,
url,
timestamp: new Date(),
}, {
attempts: 3, // retry automático em falha
backoff: { type: 'exponential', delay: 5000 }, // espera crescente entre tentativas
});
}
}
O controller registra o vídeo e delega — sem transcodificar no request:
// content/http/rest/controller/video.controller.ts
@Post('videos')
async publish(@Body() dto: PublishVideoDto): Promise<{ videoId: string; status: string }> {
const video = await this.catalog.registerPending(dto); // rápido: só grava metadados
await this.videoProcessing.processVideo(video.id, dto.sourceUrl); // enfileira o comando
return { videoId: video.id, status: 'processing' }; // responde em ms
}
O consumer (worker) executa o comando, isolado do request:
// content/video-processor/consumer/video-processing.consumer.ts
@Processor(QUEUES.VIDEO_PROCESSING)
export class VideoProcessingConsumer extends WorkerHost {
constructor(
private readonly transcoder: TranscoderService,
private readonly eventBus: EventBus,
) { super(); }
async process(job: Job<VideoProcessingJobData>): Promise<void> {
const { videoId, url } = job.data;
await this.transcoder.toRenditions(url, ['480p', '1080p', '4k']); // o trabalho pesado
// Terminou o COMANDO → agora anuncia o FATO como EVENTO:
this.eventBus.publish(new VideoPublishedEvent(videoId, new Date()));
}
}
Repare na sequência conceitual dentro do worker: ele executa um comando (process) e, ao terminar, publica um evento (VideoPublishedEvent). O comando tinha um dono (este worker); o evento não sabe quem vai ouvir. Outros módulos assinam:
// content/catalog/... reage ao fato
@OnEvent('content.video.published')
addToCatalog(e: VideoPublishedEvent) { this.catalog.markAvailable(e.videoId); }
// notifications/... também reage, sem o worker saber que ele existe
@OnEvent('content.video.published')
notifyFollowers(e: VideoPublishedEvent) { /* ... */ }
Amanhã, quando "analytics" quiser contar publicações, ele só adiciona um listener de VideoPublishedEvent — sem tocar no worker nem no producer. É a diferença prática de ter modelado como evento: novos interessados entram sem acoplar quem publica.
Escalando o subdomínio individualmente
Lembre da aula DDD estratégico + Arquitetura Hexagonal no FakeFlix: Content tem subdomínios (admin, catalog, video-processor). O video-processor é o único pesado em CPU. Com o processamento numa fila, você pode rodar N workers só de vídeo, escalando esse subdomínio de forma independente do resto:
# escala só os workers de vídeo, sem tocar no catálogo nem no admin
kubectl scale deployment video-processor-worker --replicas=12
Isso é Individual Scale, um dos 10 princípios da arquitetura modular (aula Os 10 princípios da arquitetura modular): você escala a parte que precisa, na medida que precisa, sem arrastar o sistema inteiro junto. A fila é o que torna esse isolamento de escala possível — os workers são stateless e consomem trabalho no próprio ritmo.
Antes → Depois
| Antes | Depois |
|---|---|
| Transcodifica dentro do request HTTP | Enfileira comando, responde em ms |
| 500 uploads simultâneos derrubam o sistema | Enfileiram e processam no ritmo dos workers |
| Falha na transcodificação = erro pro usuário | Retry automático (backoff), usuário não vê |
| Novo interessado em "vídeo publicado" = mexer no publicador | Novo listener do evento, publicador intacto |
| Escala tudo junto | Escala só video-processor |
Tradeoffs
- Consistência eventual entra em cena. Entre "respondi
processing" e "vídeo disponível no catálogo" há uma janela. A UI precisa lidar com o estado intermediário ("processando..."). Nem todo fluxo tolera essa janela — cobrança que precisa de confirmação imediata, por exemplo, não. - Complexidade operacional real. Você agora tem Redis para manter, filas para monitorar, dead-letter queues para jobs que falham sempre, e observabilidade de fluxo assíncrono (muito mais difícil de debugar que uma chamada de função — aula As dimensões do acoplamento: força, distância e tempo). Isso é custo, e só vale quando a dor (latência/escala) é real. Num MVP, transcodificar síncrono pode ser aceitável (aula Por que não existe arquitetura perfeita).
- Entrega e idempotência. "attempts: 3" significa que um job pode rodar mais de uma vez (a primeira quase-terminou e caiu). O consumer precisa ser idempotente — processar o mesmo vídeo duas vezes não pode gerar dois catálogos. Isso e a publicação confiável de eventos são o tema da aula Event-Driven na prática: Transactional Outbox, idempotência e sagas (Transactional Outbox).
- Modelar errado custa caro. Comando disfarçado de evento (ou vice-versa) só mostra a fatura meses depois, quando a mudança fica difícil. Vale investir no design da semântica agora.
Cola mental
- Async existe para quebrar acoplamento temporal síncrono (ganha latência e escala; paga consistência eventual e complexidade operacional).
- Comando/mensagem = "faça X", destinatário conhecido, tipicamente um executor. Evento = "X aconteceu", publicador não sabe quem ouve, zero-a-muitos.
- Modele comando quando uma ação precisa acontecer e você sabe quem executa; evento quando anuncia um fato e outros podem reagir no futuro.
- Padrão comum: worker executa um comando e, ao fim, publica um evento.
- Fila viabiliza Individual Scale: escale só o subdomínio pesado (
video-processor). - Idempotência e entrega confiável não são grátis (aula Event-Driven na prática: Transactional Outbox, idempotência e sagas).
Referências para Aprofundamento
Mensagem, evento e a diferença entre eles
- What do you mean by "Event-Driven"? — Martin Fowler — Separa quatro coisas que costumam ser chamadas de "evento". É a distinção que a aula usa entre comando e notificação.
- Enterprise Integration Patterns — Gregor Hohpe e Bobby Woolf — O catálogo clássico: Command Message, Event Message, Document Message, Dead Letter Channel. Os nomes desta aula vêm daqui.
- Domain Event — Martin Fowler — Por que o evento é sempre um fato no passado, e o que isso implica no nome.
A ferramenta
- BullMQ — documentação — Filas em Redis para Node. Vale ler as seções de retry, backoff e concorrência antes de escolher os números.
- BullMQ — Retrying failing jobs — O comportamento de falha, que é onde a fila realmente se paga ou vira dor.
- Dead Letter Channel — EIP — Para onde vai a mensagem que esgotou as tentativas. Sem isso, a fila perde trabalho em silêncio.
Exercícios
Exercício 1 — Comando ou evento?
Classifique cada interação do FakeFlix e justifique: (a) "cobrar o cartão do usuário no dia do vencimento"; (b) "a assinatura foi cancelada"; (c) "gerar thumbnail deste vídeo"; (d) "o pagamento foi confirmado"; (e) "enviar e-mail de boas-vindas"; (f) "o usuário terminou de assistir um episódio".
Ver resposta
✓ Resposta: (a) Comando — uma ação específica que precisa acontecer, com um executor claro (o cobrador). (b) Evento — fato consumado; billing anuncia, e content (revogar acesso), notifications (avisar), analytics (churn) podem reagir. (c) Comando — ação dirigida a um worker. (d) Evento — fato; múltiplos interessados (ativar assinatura, liberar conteúdo, emitir nota). (e) Comando — ação dirigida ao serviço de e-mail. (f) Evento — fato que recomendações, "continue assistindo" e analytics podem consumir. Padrão geral: verbos no imperativo dirigidos a um executor = comando; fatos no passado que interessam a vários = evento.
Exercício 2 — Consequência da modelagem
Você modelou "vídeo publicado" como comando enviado diretamente do worker para o módulo de catálogo (catalog.addVideo(...)). Agora o produto quer que notificações e analytics também reajam à publicação. Que problema essa modelagem cria, e como o modelo de evento resolveria?
Ver resposta
✓ Resposta: Problema: ao modelar como comando direto (catalog.addVideo), o worker passou a conhecer e depender do catálogo. Para cada novo interessado (notificações, analytics), você tem que abrir o worker e adicionar mais uma chamada — acoplando o publicador a cada consumidor novo, violando o "não sei quem ouve". O worker vira um ponto que muda toda vez que alguém quer reagir à publicação. Modelo de evento resolve: o worker publica VideoPublishedEvent (um fato) e não conhece ninguém; catálogo, notificações e analytics assinam independentemente. Adicionar um quarto interessado é adicionar um listener — o worker permanece intocado. É a diferença entre acoplamento remetente→executor e publish/subscribe desacoplado.
Exercício 3 — Ache o acoplamento temporal
Por que fazer a transcodificação dentro do handler HTTP é um caso de acoplamento temporal (aula As dimensões do acoplamento: força, distância e tempo)? Qual dimensão a fila reduz e qual ela introduz?
Ver resposta
✓ Resposta: Fazer a transcodificação dentro do handler acopla temporalmente a resposta ao usuário e o término do trabalho pesado: o request só termina quando a transcodificação termina, então os dois têm que acontecer no mesmo intervalo de tempo (síncrono). A fila reduz esse acoplamento temporal síncrono — o handler retorna imediatamente e o trabalho roda depois, desacoplado no tempo. Em troca, ela introduz acoplamento temporal assíncrono/de ordem e consistência eventual: há uma janela entre "respondi processing" e "vídeo disponível", possibilidade de reprocessamento (idempotência) e necessidade de garantir entrega. Trocou uma forma de acoplamento temporal por outra — a da fila é a que se quer pagar aqui, porque latência e escala eram a dor real.
Exercício 4 — Idempotência
O job de processar vídeo tem attempts: 3. Descreva um cenário em que o mesmo vídeo é processado duas vezes e o que precisa ser verdade no consumer para que isso não corrompa o catálogo.
Ver resposta
✓ Resposta: Cenário: o worker pega o job, transcodifica e publica o vídeo, mas cai antes de marcar o job como concluído (crash, timeout de rede com o Redis). O BullMQ, vendo o job não confirmado, reentrega (tentativa 2), e outro worker processa o mesmo videoId de novo. Para não corromper, o consumer precisa ser idempotente: processar o mesmo videoId duas vezes deve levar ao mesmo estado final, não a dois registros. Na prática, isso significa operações como "marcar disponível" serem upsert/condicionais (markAvailable que não duplica se já disponível), a transcodificação sobrescrever os mesmos artefatos em vez de criar duplicatas, e a publicação do evento ser idealmente deduplicável (chave de idempotência por videoId). A garantia forte de "publicar o evento exatamente uma vez junto com a escrita" é o que o Transactional Outbox resolve (aula Event-Driven na prática: Transactional Outbox, idempotência e sagas).
Exercício 5 — Desafio: nomeie e escolha o canal
Para cada situação do FakeFlix, diga se é mensagem (comando) ou evento (notificação), escreva o nome que você daria ao payload e indique se o produtor pode ou não seguir em frente sem o consumidor:
(a) O upload de um vídeo terminou e é preciso gerar as versões em 480p, 720p e 4k. (b) Uma assinatura foi cancelada; Content precisa parar de liberar o catálogo premium e o setor de marketing quer disparar uma pesquisa de saída. (c) A cobrança mensal falhou e é preciso tentar de novo em 3 dias. (d) Um usuário confirmou o e-mail.
Ver resposta
✓ Resposta: (a) Mensagem (comando) — TranscodeVideoCommand. Há um destinatário conhecido, um trabalho específico a ser feito e alguém esperando o resultado (o vídeo não fica disponível sem ele). O produtor segue em frente, mas o trabalho é obrigatório e a fila precisa de retry e dead-letter: comando perdido é vídeo que nunca publica. (b) Evento — SubscriptionCancelled. Repare no gatilho do exercício: dois consumidores com propósitos diferentes (Content e marketing), e Billing não deveria conhecer nenhum dos dois. Um comando aqui obrigaria Billing a saber que existe uma pesquisa de saída, e um terceiro interessado amanhã viraria mais uma linha no cancelamento. O produtor segue em frente e não sabe nem quer saber quem escuta — é exatamente a assimetria que separa evento de comando. (c) Mensagem (comando) com atraso — RetryChargeCommand, agendada para daqui a 3 dias. Destinatário único (o próprio Billing), ação específica, e não é um fato a ser anunciado: é trabalho marcado. Em BullMQ, um job com delay. Cuidado com idempotência: a retentativa não pode virar cobrança dupla. (d) Evento — UserEmailConfirmed. Fato consumado em Identity. Hoje talvez ninguém escute; amanhã Billing pode querer liberar o trial e marketing pode querer iniciar o onboarding. Publicar como evento deixa esse futuro barato; transformar em comando ActivateTrialCommand amarraria Identity a uma regra de Billing que não é dele. O padrão das respostas: conte os interessados e pergunte quem detém a regra. Um destinatário conhecido, com trabalho a fazer → comando. Um fato do passado com N interessados que o produtor não deve conhecer → evento. E o nome denuncia a escolha: comando é verbo no imperativo, evento é verbo no particípio.