A regra única: as dependências apontam para dentro
A Clean Architecture é frequentemente representada como quatro círculos concêntricos, e esse desenho a faz parecer mais complicada do que realmente é. A ideia central é uma única restrição sobre os imports: as dependências do código-fonte apontam para dentro, em direção ao domínio. Camadas externas podem importar camadas internas. Camadas internas não podem importar as externas.
Essa é a regra de dependência, e quase todo o resto é uma consequência dela. O domínio — o código que decide o que é um pedido e quando ele pode ser cancelado — não importa nada do framework, do driver do banco de dados ou da biblioteca HTTP. É código puro que poderia rodar em um processo de teste, em um script ou em um runtime completamente diferente.
O motivo para se importar com isso não é a pureza por si só. É que as coisas com maior probabilidade de mudar estão do lado de fora. Bancos de dados são atualizados ou substituídos, frameworks HTTP saem de moda, um provedor de pagamentos é trocado. As regras de negócio também mudam, mas de forma muito mais lenta e por motivos diferentes. Se as regras dependem das ferramentas, cada mudança de ferramenta arrasta as regras consigo. Se a dependência aponta para dentro, a mudança de ferramenta fica confinada a um adapter.
Este é o mesmo instinto de separar uma biblioteca de quem a chama. Você quer que a parte valiosa e estável seja utilizável e testável sem ter que carregar as partes voláteis junto.
A regra é sobre imports, não sobre pastas. Renomear services para domain e mover arquivos de lugar não resolve nada se o código interno ainda importar o ORM. O teste é mecânico: abra o arquivo mais interno e olhe para a sua lista de imports. Se ele mencionar um banco de dados, um framework ou um fornecedor, a fronteira é apenas decorativa.
As camadas e o que cada uma pode conhecer
O diagrama clássico possui quatro anéis. De dentro para fora:
Entities (ou o domínio). Os objetos de negócio e seus invariantes: um Order deve ter pelo menos uma linha, um valor de Money não pode misturar moedas, uma fatura não pode ser paga duas vezes. Esta camada é a mais estável e a mais valiosa. Ela deve ser código puro, sem imports externos.
Use cases (a camada de aplicação). As coisas que o sistema faz: fazer um pedido, cancelar uma assinatura, resetar uma senha. Um use case orquestra entities para cumprir uma intenção, chama as ports necessárias e retorna dados simples. Ele contém o fluxo, não as regras.
Interface adapters. Controllers, presenters, implementações de repositórios, serializadores. Eles traduzem os formatos do mundo externo para os do domínio. Um controller transforma uma requisição HTTP em uma entrada de use-case; um repositório transforma objetos de domínio em linhas de banco de dados e vice-versa.
Frameworks and drivers. Express, Postgres, Redis, o SDK da nuvem. O anel mais externo, onde residem os maiores detalhes e onde menos reflexão de design é necessária. Esta camada é a “cola”.
A regra é simples: uma seta pode apontar para dentro, nunca para fora. O use case pode chamar OrderRepository, porque essa interface é definida no domínio. O domínio não pode chamar pg.Pool, porque pg é um detalhe externo. Quando você sentir a vontade de importar um tipo de banco de dados para um use case, a resposta não é um import melhor — é uma port.
Portas e adaptadores, a visão hexagonal
A arquitetura hexagonal, também chamada de portas e adaptadores, diz a mesma coisa através de uma imagem que muitas pessoas acham mais fácil de aplicar. A aplicação está no centro. Ao redor dela estão as portas: interfaces que declaram o que a aplicação precisa do mundo externo e o que o mundo externo pode solicitar a ela. Conectados a essas portas estão os adaptadores: implementações concretas.
Existem duas direções de portas:
- Driven ports (saída) descrevem o que a aplicação precisa: um
OrderRepository, umPaymentGateway, umClock. O domínio é dono da interface; a infraestrutura a implementa. - Driving ports (entrada) descrevem o que pode ser solicitado à aplicação: uma interface de caso de uso que um controller ou um consumidor de mensagens chama.
A parte importante é a propriedade (ownership). A interface vive ao lado do código que a utiliza, na camada interna, e não ao lado da implementação. É isso que torna a inversão de dependência possível: o domínio declara OrderRepository, e a classe Postgres importa o domínio para implementá-la. A seta de imports aponta para dentro, embora a seta de controle aponte para fora.
Uma porta deve ser moldada pelas necessidades da aplicação, não pelas capacidades do banco de dados. Se OrderRepository expõe findByCustomerAndStatusPaginated, o esquema do banco de dados vazou para o vocabulário da aplicação. Se ela expõe findByCustomer, a aplicação está descrevendo o que precisa e o adaptador pode decidir como satisfazer isso.
O composition root é o que faz a imagem funcionar em tempo de execução. Interfaces sozinhas não conectam nada; alguém precisa escolher a implementação e entregá-la. Essa escolha acontece uma única vez, na inicialização, em um único arquivo. Se você se pegar construindo um PostgresOrderRepository dentro de um caso de uso, a inversão é apenas nominal — o caso de uso simplesmente moveu sua dependência de um import para uma chamada de construtor.
Inversão de dependência na prática
A inversão de dependência é o mecanismo, não o objetivo. O objetivo é que o domínio defina o contrato e a infraestrutura o implemente.
Sem a inversão, o caso de uso importa a classe do repositório:
import { PostgresOrderRepository } from "../infrastructure/postgres-order-repository.js";
Com a inversão, o caso de uso importa uma interface, e a classe concreta é injetada:
import type { OrderRepository } from "../domain/order-repository.js";
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
}
O repositório concreto é escolhido apenas uma vez, na inicialização, em um composition root. Esse é o único arquivo que importa tanto o domínio quanto a infraestrutura. Todo o restante enxerga apenas interfaces.
const orderRepository = new PostgresOrderRepository(pool);
const placeOrder = new PlaceOrder(orderRepository);
O ganho prático é imediato. Em produção, PlaceOrder recebe um repositório Postgres. Em um teste unitário, recebe um fake em memória. O código do caso de uso é idêntico em ambos, e ele nunca precisou saber qual dos dois recebeu.
Um exemplo prático: realizando um pedido
Acompanhe uma operação através das camadas.
O controller recebe POST /orders. Ele faz o parse do body para um objeto simples, valida se o id do cliente está presente e se as linhas estão bem formadas, e chama o use case. Ele não toca no banco de dados e não constrói um Order por conta própria.
router.post("/orders", async (req, res) => {
const result = await placeOrder.execute({
customerId: req.body.customerId,
lines: req.body.lines.map((line) => ({
sku: line.sku,
quantity: line.quantity,
unitPrice: Money.fromCents(line.unitPriceCents),
})),
});
res.status(201).json(result);
});
O use case constrói a entidade com Order.place, que impõe a invariante de que um pedido precisa de pelo menos uma linha. Em seguida, ele chama this.orders.save(order) — uma port. Ele retorna { orderId, totalCents }, dados simples, sem que nenhuma entidade vaze.
O adapter implementa save. Ele abre uma transação, faz o upsert da linha do pedido, substitui as linhas e faz o commit. Ele mapeia order.lines para linhas e, em findById, mapeia as linhas de volta com Order.reconstitute. Esse mapeamento é responsabilidade do adapter; o use case nunca vê uma linha.
Observe o que o domínio importou: Money, que também é código de domínio. Nada mais. Observe o que o use case importou: o domínio. Observe o que o adapter importou: o domínio e pg. As setas de dependência apontam todas para dentro, e o mapeamento acontece exatamente na fronteira.
Clean Architecture não é a mesma coisa que DDD
Esses dois conceitos são frequentemente mencionados juntos, mas não são a mesma coisa.
Domain-Driven Design é um conjunto de ideias sobre a modelagem de um negócio complexo: entidades com identidade, value objects sem identidade, aggregates como limites de consistência, repositories para persistência e uma linguagem ubíqua compartilhada por desenvolvedores e especialistas do domínio. Trata-se de como o modelo de domínio se parece.
Clean Architecture trata de para onde as dependências apontam. Ela não diz nada sobre se você precisa de aggregates, domain events ou de uma linguagem ubíqua.
Você pode usar um sem o outro:
- Um app com Clean Architecture e um domínio anêmico e superficial ainda mantém a direção das dependências, mesmo que as regras sejam fracas.
- Um app DDD com entidades TypeORM anotadas no domínio possui regras ricas, mas as dependências estão erradas.
Eles se combinam bem, e essa combinação é o que a maioria das pessoas quer dizer quando fala em um “backend estruturado adequadamente”. Mas, se o seu domínio for simples, adotar a regra de dependência sem o DDD completo é um resultado perfeitamente válido. Use value objects como Money onde eles evitem bugs reais; não introduza um aggregate só porque um livro disse para fazer isso.
Onion, hexagonal e clean: três nomes, uma única direção
As equipes utilizam esses nomes como se fossem padrões concorrentes. Na verdade, são três representações da mesma restrição, publicadas com anos de diferença.
- Arquitetura Hexagonal (Cockburn, 2005) coloca a aplicação no centro, com portas que ela mesma detém e adaptadores ao seu redor. Sua contribuição distintiva é a simetria: tanto a entrada quanto a saída são adaptadores.
- Arquitetura Onion (Palermo, 2008) desenha camadas concêntricas e enfatiza que o modelo de domínio fica no núcleo e que as dependências apontam para dentro.
- Clean Architecture (Martin, 2012) nomeia quatro anéis e define a regra de dependência explicitamente, adicionando uma camada de casos de uso entre as entidades e os adaptadores.
O vocabulário difere, mas a regra não. Em um code review, discutir qual diagrama está correto é desperdício de tempo para todos. O que importa é se o domínio importa algum framework e se a interface reside ao lado de quem a utiliza. Se essas duas respostas estiverem corretas, o padrão está sendo aplicado.
Leituras não precisam de cerimônia
Um erro frequente é forçar as queries a passarem pela mesma maquinaria que as escritas. Um use case existe para proteger invariantes; uma query não possui invariantes para proteger. Envolver um SELECT em uma entity, um use case e um mapper adiciona arquivos e bugs de mapeamento sem nenhum benefício.
Uma divisão pragmática é comum: comandos passam pelo domínio e pelas ports, enquanto queries vão direto para um read model e retornam DTOs.
// queries/order-summary.ts
export async function getOrderSummary(pool: Pool, orderId: string) {
const { rows } = await pool.query(
`SELECT o.id, o.status, o.total_cents, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1`,
[orderId],
);
return rows[0] ?? null;
}
Esta query importa pg, e tudo bem. Ela é um adapter de camada externa sem lógica de domínio, portanto a regra de dependência não tem nada a proteger. Manter as leituras simples não é um compromisso; é o padrão aplicado honestamente, porque o valor de um modelo de domínio é impor regras, e uma leitura não impõe nada.
Esta é a semente do CQRS, e você pode parar por aqui. Você não precisa de bancos de dados separados ou projeções de eventos para permitir que as leituras ignorem o domínio — apenas uma linha clara entre operações que decidem e operações que consultam.
Testando o domínio sem um banco de dados
O benefício mais concreto da regra de dependência é a velocidade dos testes. Como o domínio não importa nada, seus testes não precisam de nada.
Um teste unitário para PlaceOrder constrói um repositório fake em memória e o passa como argumento. O teste verifica se o pedido foi salvo e se o total está correto. Ele é executado em microssegundos e não precisa de container, migrations ou rede.
const orders = new InMemoryOrderRepository();
const useCase = new PlaceOrder(orders);
const result = await useCase.execute({
customerId: "cust_1",
lines: [{ sku: "book", quantity: 2, unitPrice: Money.fromCents(1500) }],
});
expect(result.totalCents).toBe(3000);
Testes de entidades são ainda mais simples. Money testes verificam que adicionar moedas mistas lança um erro e que multiply escala corretamente. Order testes verificam que fazer um pedido vazio lança um erro e que cancelar duas vezes é inofensivo. Nenhum deles menciona um banco de dados.
O banco de dados real ainda precisa de testes, mas agora o teste é sobre o adapter, não sobre as regras: PostgresOrderRepository.save escreve as linhas corretamente e findById reconstrói a entidade com fidelidade? Isso se resume a um teste de integração focado por adapter e, quando ele falha, você sabe que o problema está no mapeamento, não na lógica de negócio.
Essa divisão é o ponto principal. Sem ela, cada teste de uma regra arrasta um banco de dados junto, tornando os testes lentos, instáveis e raros. Com ela, as regras permanecem cobertas e os testes lentos são poucos e direcionados.
O preço da indireção
A Clean Architecture não é gratuita, e fingir o contrário leva as equipes a aplicá-la em todo lugar e a acabar detestando-a.
Mais arquivos. Um único endpoint que antes era apenas um handler e uma query torna-se um controller, um use case, um port, um adapter e um mapper. Para um recurso CRUD, isso significa cinco arquivos onde um bastaria, e o rastreio da requisição até a query torna-se mais longo.
Mapeamento em ambas as direções. Linhas do banco tornam-se entidades, entidades tornam-se respostas e, às vezes, DTOs ficam no meio. O mapeamento é tedioso, repetitivo e fácil de errar sutilmente — um campo ausente, uma moeda definida como padrão silenciosamente. Isso exige seus próprios testes, e esses testes não estão testando valor de negócio.
Um vocabulário mais amplo. Ports, adapters, use cases, composition roots, DTOs. Um novo desenvolvedor precisa aprender a estrutura antes de conseguir encontrar qualquer coisa, e uma equipe que a adota parcialmente tem o pior dos dois mundos: indireção sem fronteiras consistentes.
Uma falsa sensação de desacoplamento. Importar uma interface não torna você independente da tecnologia por trás dela. Se o seu port expõe semânticas de SQL, ou se o seu domínio depende do comportamento de ON CONFLICT, você continua acoplado de qualquer maneira. A interface é uma costura, não um campo de força.
A abordagem honesta é que isso é um investimento. Ele traz retorno quando o domínio é rico o suficiente para ser testado, quando a infraestrutura tem probabilidade de mudar e quando várias pessoas precisam de fronteiras claras. Ele não traz retorno em uma tela de configurações.
O retorno é mais fácil de perceber na manutenção. Quando uma regra muda, a alteração ocorre em uma entidade e um teste. Quando uma query precisa de um índice, a mudança ocorre em um adapter. Quando a equipe quer testar um novo provedor de pagamentos, eles escrevem um segundo adapter e alteram uma linha no composition root. Nenhuma dessas mudanças gera efeitos cascata nas outras, e esse é todo o retorno sobre os arquivos extras.
Onde traçar a linha
A resposta pragmática não é “tudo ou nada”. Trace a fronteira onde existe uma regra e deixe o restante simples.
Uma heurística útil: se uma operação possui uma regra de negócio que poderia estar errada de uma forma complexa, atribua a ela um use case e um objeto de domínio. Fazer um pedido, aplicar um desconto, cancelar uma assinatura — estes possuem invariantes que valem a pena proteger. Se uma operação é apenas uma leitura ou escrita direta, sem tomadas de decisão, deixe que seja uma query simples, opcionalmente atrás de um pequeno repository, e não a envolva em cerimônias.
No mesmo sistema, você pode ter ambos. Um use case PlaceOrder com um aggregate Order rico e um fake em memória pode coexistir com um GetProductById que é apenas uma única query mapeada para um DTO. Ninguém é prejudicado por essa assimetria, e a base de código permanece proporcional ao problema.
Seja igualmente pragmático com o ORM. Muitas equipes mantêm um ORM para leituras e usam adaptadores de repository escritos à mão para o caminho de escrita do domínio. Outras utilizam SQL puro em todo lugar e aceitam que o adaptador faça o mapeamento. A regra é sobre a direção, não sobre a ferramenta: desde que o domínio não importe o ORM, você está livre para usar o que quer que o adaptador precise.
O meio-termo mais comum vale a pena ser dito claramente. Um pacote de domínio com entidades reais para os dois ou três conceitos que carregam regras. Um use case para cada comando significativo. Interfaces de repository apenas onde o caminho de escrita precise delas. Todo o resto — leituras, telas de administração, relatórios — como handlers simples sobre SQL. Isso não é um compromisso; é o padrão escalado para o problema.
Estruturando um projeto
O layout de pastas deve tornar a direção das dependências óbvia à primeira vista. Um formato comum:
src/
domain/
order.ts
money.ts
order-repository.ts
application/
place-order.ts
cancel-order.ts
infrastructure/
postgres-order-repository.ts
stripe-payment-gateway.ts
interfaces/
http/
order-controller.ts
routes.ts
main.ts
Os nomes importam menos do que a regra. domain não importa nada dos outros. application importa domain. infrastructure e interfaces importam ambos. main.ts conecta todos eles e é o único arquivo permitido a conhecer todas as camadas.
Se você prefere fatias verticais — uma pasta por funcionalidade com domain, application e infrastructure dentro dela — isso também funciona e escala bem quando as funcionalidades são independentes. O que você perde é um lugar único e óbvio para procurar regras transversais; o que você ganha é que a funcionalidade se torna autocontida.
Force a direção com ferramentas em vez de disciplina. O no-restricted-imports, dependency-cruiser do ESLint ou um plugin de import-boundary podem falhar o build quando domain importa pg. Uma regra que é apenas uma convenção é uma regra que será quebrada às 17h de uma sexta-feira.
Independentemente do layout que você escolher, mantenha a raiz de composição (composition root) explícita e pequena. Um único main.ts que importa os adaptadores concretos e constrói os casos de uso é fácil de ler e fácil de alterar. Quando a conexão está espalhada por módulos que constroem suas próprias dependências, ninguém consegue dizer com qual banco de dados um caso de uso está realmente conversando, e a costura (seam) que a arquitetura prometeu desaparece.
O que pertence a um caso de uso
Um caso de uso é uma única intenção expressa como uma classe com um único método público: PlaceOrder, CancelOrder, RefundPayment. Seu método execute recebe entradas simples, orquestra o domínio e as portas, e retorna saídas simples. Se você não conseguir nomear a intenção como um verbo e um substantivo, o caso de uso provavelmente está fazendo coisas demais.
Um caso de uso deve conter fluxo, não regras. Ele decide a ordem das etapas — carregar, agir, persistir, publicar. Ele não decide o que torna um pedido válido; isso reside na entidade. Essa separação é importante porque as regras são reutilizadas em vários casos de uso, enquanto os fluxos geralmente não são.
export class CancelOrder {
constructor(
private readonly orders: OrderRepository,
private readonly events: EventPublisher,
) {}
async execute(input: { orderId: string; reason: string }) {
const order = await this.orders.findById(input.orderId);
if (!order) throw new OrderNotFound(input.orderId);
order.cancel(); // the rule lives in the entity
await this.orders.save(order);
await this.events.publish("order.cancelled", { orderId: order.id });
}
}
O que um caso de uso não deve fazer: construir SQL, ler req ou res, conhecer códigos de status HTTP, enviar e-mail diretamente ou importar um framework. Cada um desses itens é ou uma preocupação da camada externa ou pertence a uma porta. Se o caso de uso importa express, a fronteira falhou, independentemente de como as pastas sejam nomeadas.
A autorização é uma questão genuína de design. Verificar permissões no caso de uso mantém as regras em um só lugar e as torna testáveis; verificá-las em um middleware exige menos código, mas é mais fácil de esquecer. Uma resposta viável é realizar verificações superficiais na borda e autorizações de nível de negócio dentro do caso de uso, pois “apenas o proprietário pode cancelar” é uma regra, não uma preocupação de roteamento.
Transações, efeitos colaterais e a fronteira
Transações são uma preocupação de infraestrutura, mas a fronteira delas é uma preocupação da aplicação. O caso de uso sabe que um conjunto de alterações deve ser commitado em conjunto; ele não deve saber que o mecanismo é BEGIN e COMMIT no Postgres.
Duas abordagens funcionam bem. A mais simples é tornar cada método do repositório transacional por conta própria, o que funciona bem quando um caso de uso realiza apenas uma escrita. Quando um caso de uso escreve através de vários repositórios e as alterações devem ser atômicas, introduza uma porta de unit of work:
export interface UnitOfWork {
run<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
O adaptador a implementa com uma conexão e uma transação, e o caso de uso envolve seu trabalho em unitOfWork.run. O domínio continua sendo o dono da interface, o adaptador é o dono do SQL, e a atomicidade fica explícita na camada de aplicação, onde ela deve estar.
Efeitos colaterais seguem a mesma regra. Enviar um e-mail, cobrar um cartão ou publicar um evento deve ser uma porta — EmailSender, PaymentGateway, EventPublisher — e não uma chamada direta ao fetch. Isso mantém o caso de uso testável, pois o fake registra o que teria sido enviado, e mantém a escolha do provedor no adaptador.
A ordenação de efeitos colaterais em relação ao banco de dados é sutil. Um caso de uso que salva um pedido e depois publica um evento enfrenta o problema de escrita dupla (dual-write): o processo pode morrer entre as duas operações. O padrão confiável é escrever o evento na mesma transação que o estado — um outbox — e deixar que um relay o publique. O caso de uso solicita a um EventPublisher que registre o evento; o adaptador decide se isso significa uma linha no outbox ou uma publicação direta.
Domínio anêmico, abstrações vazantes e tipos de framework
Existem três modos de falha que se parecem com Clean Architecture, mas não são.
Um domínio anêmico é um conjunto de classes que possuem apenas campos, getters e setters, enquanto toda a lógica reside nos services. As pastas estão corretas, as setas de dependência estão corretas, mas o domínio está vazio. Isso não é um desastre — uma camada de service sobre dados simples é um design legítimo — mas chamá-lo de domínio rico é autoengano, e geralmente significa que as invariantes são aplicadas em vários lugares e esquecidas em um.
Uma abstração vazante (leaky abstraction) é um port moldado por sua implementação. OrderRepository.upsertOnConflict menciona Postgres. PaymentGateway.chargeWithStripeToken menciona um vendor. Um port deve falar a linguagem da aplicação: save, findById, charge. Quando um port “vaza”, trocar o adapter significa alterar a interface e todos os seus chamadores, o que anula o propósito de ter um port.
Tipos de framework no domínio são a violação mais direta. Uma entidade anotada com @Entity e @Column, ou um use case cujo tipo de entrada é um Request do Express, importou uma camada externa. Pode até compilar e passar nos testes, mas a regra de dependência foi quebrada, e agora o framework decide quando e como o domínio é construído. Mantenha decorators e tipos de request nas camadas externas e passe objetos simples para as camadas internas.
Estratégia de testes de dentro para fora
A regra de dependência faz com que a pirâmide de testes surja naturalmente. Teste de dentro para fora, onde os testes são rápidos, e avance para as camadas externas apenas o necessário.
Testes de domínio cobrem entidades e value objects. São testes unitários puros, sem I/O, e devem existir em grande quantidade, pois as regras de negócio são onde os bugs são mais caros. Money rejeita moedas mistas; Order rejeita uma lista de linhas vazia; cancelar duas vezes é uma operação nula (no-op).
Testes de caso de uso cobrem o fluxo. Construa o caso de uso com fakes em memória para cada porta, chame execute e faça asserções sobre o resultado e sobre o que os fakes registraram. Eles detectam etapas ausentes, ordenação incorreta e salvamentos esquecidos, e ainda assim rodam em microssegundos.
Testes de adaptador cobrem o mapeamento. Execute-os contra um PostgreSQL real em um container, insira e leia os dados de volta, e valide se a entidade percorreu o ciclo (round-trip) fielmente. É aqui que você descobre que status retornou como uma string ou que uma coluna nullable produziu uma linha undefined. Um teste focado por método do adaptador geralmente é suficiente.
Testes end-to-end cobrem a fiação: uma requisição HTTP real passando pelo controller, caso de uso e banco de dados, validando a resposta. Mantenha-os em número reduzido. Eles são lentos, quebram por motivos irrelevantes e seu trabalho é provar que a raiz de composição está conectada, não testar as regras novamente.
Essa divisão significa que um teste de regra falhando aponta para o domínio, um teste de fluxo falhando aponta para o caso de uso, um teste de round-trip falhando aponta para o adaptador e um teste end-to-end falhando aponta para a fiação. Essa clareza diagnóstica vale mais do que a quantidade bruta de testes.
Quando introduzir a boundary
Raramente você acerta as boundaries logo de início, pois ainda não sabe onde as regras estão. Uma sequência prática é começar com handlers simples e um módulo de acesso a dados, observar onde a lógica se acumula e, nesse momento, extrair um use case e um domain object.
Sinais de que vale a pena introduzir uma boundary:
- A mesma regra é aplicada em mais de um handler.
- Um teste de regra de negócio precisa de um banco de dados ou de um servidor rodando.
- Alterar um framework ou um ORM exigiria mexer na lógica.
- Uma função mistura validação, persistência e chamadas externas, tornando-se difícil de nomear.
- Dois desenvolvedores continuam colidindo no mesmo arquivo.
Sinais de que você não deve se preocupar:
- A operação é apenas uma leitura ou escrita direta, sem tomadas de decisão.
- As regras ainda mudam diariamente e estabilizá-las agora seria prematuro.
- O app inteiro é mantido por uma única pessoa e possui apenas alguns poucos endpoints.
Introduza a boundary onde houver dor, não em todos os lugares ao mesmo tempo. Um codebase com três boundaries bem definidas e muito código simples é mais saudável do que um onde cada tabela tem um aggregate e cada chamada tem um port.
Melhores práticas
- Mantenha a seta de dependência apontando para dentro e não permita que o domínio importe nada.
- Defina as ports onde elas são utilizadas, na camada interna, e não onde são implementadas.
- Modele as ports com base nas necessidades da aplicação, não nas capacidades do banco de dados.
- Coloque as implementações concretas em adapters e escolha-as apenas no composition root.
- Faça o mapeamento entre linhas, objetos de domínio e DTOs nas fronteiras; nunca permita que os tipos de uma camada atravessem para outra.
- Atribua apenas uma intenção a cada use case e faça com que ele retorne dados simples.
- Garanta a validade de invariantes no domínio através de factories e construtores privados, não nos controllers.
- Realize testes unitários de use cases com fakes em memória e testes de integração de adapters separadamente.
- Mantenha o domínio livre de decorators de frameworks, anotações de ORM e tipos HTTP.
- Aplique o padrão onde existem regras de negócio e mantenha operações simples de CRUD enxutas.
- Force as fronteiras de importação com uma regra de lint ou dependency-cruiser no CI.
Erros comuns
- Anotar entidades de domínio com decorators de ORM e chamar isso de Clean Architecture.
- Retornar entidades ou linhas do banco de dados diretamente para os controllers.
- Definir a interface do repositório ao lado da classe Postgres em vez de ao lado do use case.
- Escrever um domínio anêmico de getters e setters, mantendo a lógica no service.
- Deixar semânticas de SQL vazarem para uma port e acreditar que as camadas estão desacopladas.
- Abstrair cada tabela e chamada, transformando um app CRUD em pura cerimônia.
- Colocar o composition root em todos os lugares, fazendo com que nada seja realmente invertido.
- Testar use cases contra o banco de dados real e perder a velocidade que justificou a separação.
- Pular o mapeamento e passar
anyatravés de uma boundary. - Adotar DDD, CQRS e event sourcing completos de uma vez só porque um diagrama sugeriu.
Próximos passos
A Clean Architecture define fronteiras dentro de uma única aplicação. O guia de Modular Monolith mostra como tornar essas fronteiras explícitas entre módulos sem arcar com os custos de rede, o que é o próximo passo ideal para a maioria dos sistemas. Quando uma fronteira genuinamente precisa se tornar uma unidade implantável, o guia de Microservices aborda a origem das bordas dos serviços e o que muda quando uma chamada se torna remota. Se você deseja que as portas sejam simples e autodocumentadas, o guia de TypeScript cobre interfaces e tipos, e o PostgreSQL é o banco de dados para o qual a maioria dos adapters é escrita.