Trilha 1 · Fundamentos e mentalidade · ~26 min · pleno/sênior

Herdei um serviço de emissão de notas fiscais que o time anterior descrevia com orgulho como "Clean Architecture por o livro". Abri o repositório e contei as camadas: Domain, Application, Infrastructure, Presentation. Dentro de Application, cada operação tinha um UseCase, um Input, um Output, um InputPort, um OutputPort, um Presenter e um ViewModel. Para emitir uma nota, o dado atravessava sete transformações antes de chegar ao banco. Sete objetos que só copiavam campos de um para o outro.

Perguntei ao dev sênior por que tanta camada. A resposta foi honesta e reveladora: "porque é assim no diagrama do Uncle Bob." Ninguém sabia dizer qual problema cada camada resolvia naquele contexto. Elas estavam ali porque o desenho canônico as mostrava. Isso não é Clean Architecture — é cargo cult de Clean Architecture. Copiaram a forma sem entender a força que ela deveria conter.

No mesmo mês, vi o oposto: um time que jurava "odiar Clean Architecture, é over-engineering" e por isso importava o Prisma dentro da regra de negócio, deixava o cálculo de imposto dentro do controller HTTP e não conseguia testar nada sem subir o banco inteiro. Eles jogaram fora o bebê (a regra da dependência, que é ouro) junto com a água do banho (as sete camadas cerimoniais).

Esta aula é sobre separar as duas coisas. Clean Architecture tem uma ideia central genial e uma embalagem que quase sempre é exagerada. Vamos ficar com a ideia e jogar fora a embalagem.

A única coisa que você precisa levar da Clean Architecture

Robert C. Martin ("Uncle Bob") popularizou a Clean Architecture com aquele diagrama de círculos concêntricos: Entidades no centro, Casos de Uso em volta, Adaptadores de Interface depois, e Frameworks/Drivers na borda. As pessoas decoram os círculos. Mas os círculos não são a ideia — são uma ilustração da ideia. A ideia é uma frase só, e ela é o coração de tudo:

A Regra da Dependência: dependências de código-fonte apontam sempre para dentro, em direção às políticas de mais alto nível. Nada num círculo interno pode saber qualquer coisa sobre um círculo externo.

Traduzindo para o que importa no dia a dia: a regra de negócio não depende de detalhe; o detalhe depende da regra de negócio. "Detalhe" é tudo que pode ser trocado sem mudar o que o software faz: o banco (Postgres ou Mongo), o framework web (Express ou Fastify), o formato de entrega (REST, gRPC, CLI), o provedor de e-mail, a fila. "Política de alto nível" é a regra que existe porque o negócio existe: "uma nota fiscal cancelada não pode ser reemitida", "uma assinatura vencida há 7 dias é suspensa".

Repare que isso é exatamente o mesmo princípio da Arquitetura Hexagonal da aula DDD estratégico + Arquitetura Hexagonal no FakeFlix (a seta aponta para dentro; o core não conhece http nem persistence). A Regra da Dependência da Clean Architecture e a inversão de dependência da Hexagonal são a mesma ideia com nomes diferentes. Essa é a primeira grande economia: você já aprendeu o essencial da Clean Architecture na aula DDD estratégico + Arquitetura Hexagonal no FakeFlix. O resto é embalagem.

O que essa regra compra

Porque ela é a que dá as três propriedades que realmente importam:

  • Testabilidade: se a regra não depende do banco, você testa a regra sem banco (em milissegundos, sem Docker).
  • Substituibilidade: trocar Postgres por outro banco não toca na regra.
  • Longevidade: frameworks morrem a cada 3 anos; a regra de "como cobramos" dura o negócio inteiro. Manter uma longe da outra é o que faz o código sobreviver.

Tudo o mais na Clean Architecture — casos de uso explícitos, presenters, view models, DTOs de fronteira — são técnicas opcionais para honrar essa regra. Algumas valem em certos contextos. Nenhuma é obrigatória.

Entidades e Casos de Uso: o que são de verdade

Dois conceitos da Clean Architecture merecem entendimento (não decoração):

Entidade (no sentido de Uncle Bob, que difere um pouco da "entity" do ORM ou do DDD): o objeto que carrega as regras de negócio mais gerais e de mais alto nível — as que existiriam mesmo se não houvesse aplicativo nenhum. A regra "uma assinatura só está ativa se status = active e currentPeriodEnd > agora" é de entidade: vale para qualquer app que lide com assinaturas, independente de tela, banco ou API.

Caso de Uso (Use Case): uma regra específica da aplicação — orquestra entidades para realizar uma operação que um usuário quer fazer. "Cancelar assinatura" é um caso de uso: ele coordena (buscar a assinatura, verificar se há fatura em aberto, marcar como cancelada, disparar um evento). O caso de uso é o "verbo" do sistema.

A distinção prática: entidade = a regra que não muda com a operação; caso de uso = a orquestração de uma operação específica. Um mesmo dado de assinatura (entidade) participa de vários casos de uso (ativar, cancelar, fazer upgrade, aplicar tolerância).

Onde o cargo cult entra

O erro do serviço de notas fiscais da abertura foi transformar todo caso de uso num aparato de 5-7 classes (Input, Output, InputPort, OutputPort, Presenter, ViewModel...). Esse aparato existe no desenho canônico para resolver um problema específico: desacoplar totalmente a lógica da forma de entrega/apresentação, útil quando você tem múltiplas interfaces muito diferentes (uma API, uma CLI, uma tela desktop) consumindo a mesma lógica, ou quando a formatação de saída é complexa e variável.

Na imensa maioria dos sistemas web — que têm uma API REST e retornam JSON — esse aparato não resolve problema nenhum. Ele só adiciona transformações. É complexidade acidental pura (aula Complexidade essencial vs. acidental e carga cognitiva). O Presenter que converte Output em ViewModel para depois virar JSON está fazendo o trabalho que res.json() já fazia, com quatro classes a mais.

Código: do "cargo cult" ao "adequado"

Vamos pegar o caso de uso "cancelar assinatura" no FakeFlix e mostrar três doses (revisitando o raciocínio de doses da aula Por que não existe arquitetura perfeita), do exagero ao adequado.

Dose 3 — Clean Architecture "por o livro" (o cargo cult)

// application/cancel-subscription/cancel-subscription.input.ts
export class CancelSubscriptionInput {
  constructor(public readonly userId: string) {}
}

// application/cancel-subscription/cancel-subscription.output.ts
export class CancelSubscriptionOutput {
  constructor(public readonly cancelledAt: Date, public readonly status: string) {}
}

// application/cancel-subscription/cancel-subscription.input-port.ts
export interface CancelSubscriptionInputPort {
  execute(input: CancelSubscriptionInput): Promise<void>;
}

// application/cancel-subscription/cancel-subscription.output-port.ts
export interface CancelSubscriptionOutputPort {
  present(output: CancelSubscriptionOutput): void;
}

// application/cancel-subscription/cancel-subscription.use-case.ts
export class CancelSubscriptionUseCase implements CancelSubscriptionInputPort {
  constructor(
    private readonly repo: SubscriptionRepository,
    private readonly outputPort: CancelSubscriptionOutputPort, // ⬅️ o caso de uso "empurra" pro presenter
  ) {}

  async execute(input: CancelSubscriptionInput): Promise<void> {
    const sub = await this.repo.findByUserId(input.userId);
    if (!sub) throw new SubscriptionNotFoundError();
    sub.cancel();
    await this.repo.save(sub);
    // não RETORNA o resultado; entrega ao output port
    this.outputPort.present(new CancelSubscriptionOutput(sub.cancelledAt!, sub.status));
  }
}

// presentation/cancel-subscription.presenter.ts
export class CancelSubscriptionPresenter implements CancelSubscriptionOutputPort {
  public viewModel?: { cancelledAt: string; status: string };
  present(output: CancelSubscriptionOutput): void {
    this.viewModel = {
      cancelledAt: output.cancelledAt.toISOString(),
      status: output.status,
    };
  }
}

// presentation/cancel-subscription.controller.ts
@Post('subscriptions/cancel')
async cancel(@Req() req) {
  const presenter = new CancelSubscriptionPresenter();
  const useCase = new CancelSubscriptionUseCase(this.repo, presenter);
  await useCase.execute(new CancelSubscriptionInput(req.user.id));
  return presenter.viewModel; // depois de tudo isso... só um JSON
}

Conte os artefatos: 6 classes/interfaces para uma operação que faz "busca, cancela, salva, responde JSON". O OutputPort/Presenter/ViewModel inteiro existe para desacoplar a apresentação — mas só há uma apresentação (JSON). Estamos pagando um custo de flexibilidade que nunca será colhido. Isto é o Vilão B da aula Por que não existe arquitetura perfeita fantasiado de rigor arquitetural.

Dose 2 — Caso de uso explícito, sem o teatro de apresentação (adequado quando há orquestração real)

// billing/core/use-case/cancel-subscription.use-case.ts
@Injectable()
export class CancelSubscriptionUseCase {
  constructor(
    private readonly repo: SubscriptionRepository,       // porta (aula *DDD estratégico + Arquitetura Hexagonal no FakeFlix*)
    private readonly invoices: InvoiceGateway,            // porta
    private readonly events: DomainEventPublisher,        // porta
  ) {}

  async execute(userId: string): Promise<CancelledSubscription> {
    const sub = await this.repo.findByUserId(userId);
    if (!sub) throw new SubscriptionNotFoundError(userId);

    // regra ESPECÍFICA da aplicação: não cancela com fatura em aberto
    if (await this.invoices.hasOpenInvoice(userId)) {
      throw new CannotCancelWithOpenInvoiceError(userId);
    }

    sub.cancel();                       // regra de ENTIDADE (muda o estado seguindo invariantes)
    await this.repo.save(sub);
    await this.events.publish(new SubscriptionCancelledEvent(userId, sub.cancelledAt!));

    return { cancelledAt: sub.cancelledAt!, status: sub.status }; // apenas RETORNA
  }
}
// billing/http/rest/controller/subscription.controller.ts
@Post('subscriptions/cancel')
async cancel(@Req() req) {
  const result = await this.cancelSubscription.execute(req.user.id);
  return { cancelledAt: result.cancelledAt.toISOString(), status: result.status };
}

Aqui o caso de uso paga a pena: ele orquestra três colaboradores (repo, invoices, events) e carrega uma regra de negócio de aplicação não-trivial ("não cancela com fatura em aberto"). Isso merece um objeto com nome próprio — CancelSubscriptionUseCase documenta uma capacidade do sistema e concentra a orquestração num lugar testável. Mas note o que sumiu: nada de Input/Output/Presenter/ViewModel. O caso de uso recebe um userId e retorna um resultado; o controller formata. A Regra da Dependência é 100% respeitada (o core não conhece HTTP), com um terço das classes.

Dose 1 — Service direto (adequado quando é quase-CRUD)

// billing/core/service/subscription.service.ts
@Injectable()
export class SubscriptionService {
  constructor(private readonly repo: SubscriptionRepository) {}

  async cancel(userId: string): Promise<void> {
    const sub = await this.repo.findByUserId(userId);
    if (!sub) throw new SubscriptionNotFoundError(userId);
    sub.cancel();
    await this.repo.save(sub);
  }
}

Quando a operação é só "busca, muda estado, salva" — sem orquestração de múltiplos colaboradores, sem regra de aplicação complexa — um método de service é o suficiente. Ele já respeita a Regra da Dependência (depende da porta SubscriptionRepository, não do ORM). Criar um UseCase dedicado aqui seria cerimônia. Um Presenter seria absurdo.

O ponto das três doses: todas as três honram a única regra que importa (dependência apontando para dentro). Elas diferem só em quanta estrutura de caso de uso/apresentação você empilha por cima — e isso deve ser função da orquestração real e da variação real de apresentação, não do diagrama canônico. A Dose 3 é adequada num sistema com CLI + API + relatórios com formatação complexa dividindo a mesma lógica. Ela é ridícula num CRUD web. Contexto, sempre (aula Por que não existe arquitetura perfeita).

A Hexagonal já basta na maioria dos casos

Se a Regra da Dependência é o ouro da Clean Architecture, e a Hexagonal (aula DDD estratégico + Arquitetura Hexagonal no FakeFlix) já a implementa com core/http/persistence + portas, então para a maioria dos sistemas você não precisa de "Clean Architecture" como algo separado — você precisa da Hexagonal bem feita.

O que a Hexagonal entrega com menos cerimônia:

Objetivo Clean "canônica" Hexagonal
Regra não depende de infra Regra da Dependência + 4 círculos Porta no core, adaptador fora
Trocar banco sem tocar regra Interface no domínio Interface no domínio (idêntico)
Testar regra sem infra Fake na fronteira Fake na porta (idêntico)
Isolar apresentação Presenter + OutputPort + ViewModel Controller formata o retorno
Nº de artefatos por operação Alto (5-7) Baixo (1-3)

A Hexagonal descarta a parte mais pesada (o aparato de Presenter/OutputPort/ViewModel) e mantém a parte valiosa (portas e adaptadores realizando a inversão de dependência). Você adota os pedaços "extras" da Clean Architecture cirurgicamente, só onde há evidência de que resolvem um problema real:

  • Adote casos de uso explícitos quando a orquestração é rica (Dose 2) — vale muito em domínios complexos como Billing.
  • Adote Presenter/ViewModel só quando há múltiplas apresentações divergentes da mesma lógica, ou formatação de saída complexa e reutilizada.
  • Ignore o resto até a dor aparecer.

A regra de bolso da aula: aproveite a Regra da Dependência (obrigatória) e os casos de uso (quando há orquestração). Ignore o aparato de apresentação (Presenter/OutputPort/ViewModel) até você ter, de verdade, mais de uma forma de apresentar a mesma lógica.

Um erro sutil: onde vive a validação e onde vive a regra

Uma confusão que a Clean Architecture ajuda a resolver — e que vale destacar porque aparece em todo code review. Há três tipos de "checagem" e cada um tem seu lugar:

// 1) VALIDAÇÃO DE ENTRADA (formato) → fica na borda (http), não é regra de negócio
class CancelSubscriptionDto {
  @IsUUID() userId: string;   // "isso é um UUID?" — detalhe de entrega, vive no adaptador http
}

// 2) REGRA DE APLICAÇÃO (orquestração/política do caso de uso) → vive no use case (core)
if (await this.invoices.hasOpenInvoice(userId)) {
  throw new CannotCancelWithOpenInvoiceError(userId); // política específica desta operação
}

// 3) INVARIANTE DE ENTIDADE (verdade sempre válida do objeto) → vive na entidade (core)
class Subscription {
  cancel() {
    if (this.status === 'cancelled') throw new AlreadyCancelledError(); // invariante do objeto
    this.status = 'cancelled';
    this.cancelledAt = new Date();
  }
}

Misturar esses três é a origem de metade da "complexidade acidental" em código de negócio. Validação de UUID no meio da regra de negócio acopla a regra ao formato de entrada. Invariante de entidade espalhada pelo service faz a entidade virar um saco de dados anêmico que qualquer um corrompe. A Regra da Dependência te guia: formato de entrada é detalhe (fica fora), regra é política (fica dentro), invariante é do objeto (fica na entidade).

Antes → Depois

Antes (cargo cult) Depois (adequado)
6-7 classes por operação por padrão 1-3, conforme a orquestração real
Presenter/ViewModel sempre Controller formata; presenter só se há múltiplas apresentações
"Camadas porque o diagrama tem" Camadas porque resolvem um problema nomeável
Regra importa o ORM (o outro extremo) Regra depende de porta; ORM no adaptador
Validação, política e invariante misturadas Cada checagem no seu lugar (borda / use case / entidade)

Tradeoffs e armadilhas

  • O risco de jogar fora demais. Ao "simplificar", o time da abertura que odiava Clean Architecture importou Prisma na regra. Simplificar não é abandonar a Regra da Dependência — essa é inegociável em qualquer sistema com regra de negócio real. Simplificar é abandonar o aparato cerimonial, não o princípio.
  • Casos de uso explícitos têm custo real de navegação. Um UseCase por operação gera muitos arquivos pequenos. Em domínios ricos (Billing), isso paga (cada capacidade fica nomeada e testável). Em domínios rasos (um CRUD de configurações), vira fragmentação (aula Coesão: o outro lado da moeda) e você navega por 40 arquivos de uma linha. Meça a orquestração antes de decidir.
  • "Entidade rica" vs. "modelo anêmico". A Clean/DDD empurra invariantes para dentro da entidade (bom: a entidade se protege). Mas entidade rica demais, com toda lógica dentro, pode acoplar regras que mudam por motivos diferentes. O equilíbrio é o mesmo de sempre: coesão (aula Coesão: o outro lado da moeda) — o que muda junto fica junto.
  • Múltiplas apresentações podem justificar o aparato completo. Se o seu sistema realmente serve REST + GraphQL + CLI + relatórios PDF a partir da mesma lógica, aí o Presenter/OutputPort ganha sentido: cada apresentação implementa o output port do seu jeito. O erro não é ter o aparato; é tê-lo por reflexo, sem a variação que ele isola.

Cola mental

  • A única coisa obrigatória da Clean Architecture é a Regra da Dependência: regra de negócio não depende de detalhe; detalhe depende da regra. É a mesma ideia da Hexagonal (aula DDD estratégico + Arquitetura Hexagonal no FakeFlix).
  • Entidade = regra que vale sempre (invariante do objeto). Caso de uso = orquestração de uma operação específica.
  • Adote casos de uso explícitos quando há orquestração rica; use service direto quando é quase-CRUD.
  • Ignore o aparato de apresentação (Presenter/OutputPort/ViewModel) até ter múltiplas apresentações reais — senão é complexidade acidental (aula Complexidade essencial vs. acidental e carga cognitiva).
  • Três checagens, três lugares: formato na borda (http), política no use case, invariante na entidade.
  • Simplificar ≠ abandonar a Regra da Dependência. Jogue fora a cerimônia, nunca o princípio.

Referências para Aprofundamento

A fonte

As críticas que dão o contrapeso

Exercícios

Exercício 1 — Ache a violação da Regra da Dependência

O código está em billing/core/use-case/. O que viola o princípio e qual a consequência prática?

import { PrismaClient } from '@prisma/client';
import { Response } from 'express';

export class RenewSubscriptionUseCase {
  private prisma = new PrismaClient();
  async execute(userId: string, res: Response) {
    const sub = await this.prisma.subscription.findFirst({ where: { userId } });
    sub!.currentPeriodEnd = addMonths(sub!.currentPeriodEnd, 1);
    await this.prisma.subscription.update({ where: { id: sub!.id }, data: sub! });
    res.json({ ok: true }); // ⬅️
  }
}
Ver resposta

✓ Resposta: Duas violações, ambas invertem a Regra da Dependência (o core passa a depender de detalhes externos): (a) importa e instancia PrismaClient dentro do use case — a regra agora depende do ORM concreto; (b) recebe res: Response do Express — a regra agora depende do framework web e da forma de entrega HTTP. Consequências práticas: impossível testar o use case sem subir Prisma/banco e sem simular o objeto Response; trocar de ORM ou de protocolo (REST→gRPC) obriga a reescrever a regra; e o caso de uso passou a "saber" de HTTP, misturando entrega com política. Correção: depender de uma porta SubscriptionRepository (implementada em persistence/ com Prisma) e retornar um resultado em vez de escrever em res — o controller http/ faz o res.json().

Exercício 2 — Escolha a dose

Para cada operação do FakeFlix, escolha entre service direto (Dose 1), use case explícito (Dose 2) ou aparato completo com presenter (Dose 3), e justifique: (a) updateVideoTitle(videoId, title) — troca um campo, sem regra; (b) checkout(userId, planId) — valida plano, cria assinatura, cobra o gateway, emite nota, dispara evento de boas-vindas, e o resultado é exibido tanto numa API REST quanto num relatório administrativo com formatação própria; (c) suspendForNonPayment(userId) — verifica dias de inadimplência, aplica tolerância, muda status, dispara evento.

Ver resposta

✓ Resposta: (a) Dose 1 (service direto). Troca de um campo, sem orquestração nem regra; um updateTitle no service basta (respeitando a porta do repositório). Use case aqui seria cerimônia. (b) Dose 3 (aparato completo). Há orquestração rica (validar, criar, cobrar, emitir nota, evento) e — o gatilho decisivo — duas apresentações divergentes da mesma lógica (API REST + relatório administrativo com formatação própria). É o cenário em que Presenter/OutputPort/ViewModel realmente pagam: cada apresentação implementa o output port do seu jeito, sem a lógica saber qual é qual. (c) Dose 2 (use case explícito). Orquestração real (tolerância, mudança de status, evento) e regra de aplicação não-trivial justificam um SuspendForNonPaymentUseCase nomeado e testável — mas há uma só apresentação, então nada de presenter; ele retorna e o controller formata.

Exercício 3 — Classifique a checagem

Diga se cada uma é validação de formato (borda), regra de aplicação (use case) ou invariante de entidade, e onde deve viver: (a) "o e-mail tem formato válido"; (b) "não é possível cancelar uma assinatura que já está cancelada"; (c) "só faz upgrade de plano se não houver fatura em aberto"; (d) "o planId recebido é um dos planos existentes".

Ver resposta

✓ Resposta: (a) Validação de formato → borda (http/DTO). "E-mail é válido?" é sobre o formato da entrada; vive no adaptador de entrada. (b) Invariante de entidade → na entidade. "Não cancelar o que já está cancelado" é uma verdade do objeto Subscription; o método cancel() a protege. (c) Regra de aplicação → use case. "Só faz upgrade se não há fatura em aberto" é política de uma operação específica, orquestrando um colaborador (invoices); vive no use case. (d) Validação de formato/existência → borda, com nuance: "é um dos planos existentes" pode ser validação de entrada simples (enum) na borda; se exigir consultar o catálogo de planos com regras, vira regra de aplicação no use case. O critério: se é só "o formato/valor é aceitável?", borda; se envolve política do negócio, use case.

Exercício 4 — Refatore o cargo cult

Você encontra um GetUserProfileUseCase com Input, Output, InputPort, OutputPort, Presenter e ViewModel — e a operação apenas busca um usuário por id e retorna nome e e-mail como JSON, numa única API REST. Descreva o que você remove, o que mantém, e por quê. A Regra da Dependência sobrevive à sua simplificação?

Ver resposta

✓ Resposta: Remove: Input, Output, InputPort, OutputPort, Presenter e ViewModel — todo o aparato de apresentação, porque há uma única apresentação (JSON) e nenhuma orquestração (só um findById). Mantém: a dependência de uma porta de repositório (o core/service continua sem conhecer o ORM). Resultado: um getProfile(userId) num service (ou até no controller chamando o repo via porta) que retorna { name, email }, e o controller formata como JSON. A Regra da Dependência sobrevive, sim: o que foi removido foi o teatro de apresentação (cerimônia), não o princípio — a regra/consulta continua sem depender de HTTP nem do ORM concreto. Você jogou fora a embalagem e manteve o ouro.

Exercício 5 — O outro extremo

Um colega argumenta: "Regra da Dependência é firula acadêmica; vou deixar o SubscriptionService usar o Repository do TypeORM direto, sem interface, pra economizar código". Dê dois custos concretos e futuros dessa decisão, conectando com testabilidade e com a aula Quando (e quando NÃO) migrar para microserviços (extração de serviço).

Ver resposta

✓ Resposta: Dois custos concretos: (1) Testabilidade perdida. Sem a interface/porta, testar SubscriptionService exige subir o TypeORM e um banco real (ou um mock frágil do Repository do TypeORM, com dezenas de métodos); os testes ficam lentos e acoplados à infra, então na prática o time testa menos — que é onde os bugs de regra passam. Com a porta, você injeta um fake em memória e testa a regra em milissegundos (como na aula DDD estratégico + Arquitetura Hexagonal no FakeFlix). (2) Extração futura cara (aula Quando (e quando NÃO) migrar para microserviços). No dia em que Billing precisar virar um serviço separado, um SubscriptionService amarrado ao Repository do TypeORM não tem ponto de corte limpo: a regra está soldada ao ORM/banco local. Com a porta, a regra depende de um contrato, e trocar a implementação (local → remota) é confinado ao adaptador — a extração fica barata. Ou seja, "economizar" a interface hoje compra dívida em testabilidade agora e em evolução depois. É precisamente a Regra da Dependência protegendo você desses dois custos — não é firula.


Pronta a aula Clean Architecture: o que aproveitar e o que ignorar. Quando quiser, manda "próxima" que eu desenvolvo a aula Coesão: o outro lado da moeda — Coesão: o outro lado da moeda (com mais código e casos, como você pediu).