A Clean Architecture no Node.js organiza o sistema para que regras de negócio não dependam de frameworks, bancos, filas ou detalhes de transporte. Express, PostgreSQL, Redis e serviços externos tornam-se componentes substituíveis ao redor de um núcleo estável.
O principal benefício é controlar a direção das dependências: camadas externas conhecem as internas, mas o domínio não importa código de infraestrutura. Isso melhora testes, permite trocar tecnologias e reduz o risco de um framework definir toda a estrutura do projeto.
Neste guia, você aprenderá a separar entidades, casos de uso, portas, adapters, controllers, repositories, composição, erros, testes e migração gradual de uma aplicação existente.
O que é Clean Architecture?
Clean Architecture é uma abordagem que combina ideias de arquitetura hexagonal, onion architecture e separação por camadas. O princípio central é descrito por Robert C. Martin em The Clean Architecture: dependências apontam para políticas mais centrais.
O domínio e os casos de uso não conhecem HTTP, banco ou bibliotecas externas. Adaptadores convertem formatos e implementam interfaces definidas pelo núcleo.
Para organizar casos de uso, consulte Service Layer no Node.js. Para persistência isolada, veja Repository Pattern no Node.js.
Camadas principais
- Domínio: entidades, value objects e regras fundamentais.
- Aplicação: casos de uso e portas.
- Adapters: controllers, presenters e implementações de gateways.
- Infraestrutura: frameworks, banco, filas e configuração.
Os nomes podem variar; a direção das dependências é mais importante.
Estrutura de pastas
src/
├── domain/
│ ├── entities/
│ ├── value-objects/
│ └── errors/
├── application/
│ ├── use-cases/
│ └── ports/
├── adapters/
│ ├── http/
│ ├── persistence/
│ └── messaging/
├── infrastructure/
│ ├── database/
│ ├── config/
│ └── server/
└── main/
└── composition-root.tsA estrutura por feature também funciona, desde que preserve limites.
Entidade de domínio
export class Order {
private constructor(
readonly id: OrderId,
private status: OrderStatus,
readonly total: Money
) {}
static create(input: CreateOrderProps): Order {
if (input.total.isNegative()) {
throw new InvalidOrderTotalError();
}
return new Order(
OrderId.create(),
'pending',
input.total
);
}
approve(): void {
if (this.status !== 'pending') {
throw new InvalidOrderStatusError();
}
this.status = 'approved';
}
}A entidade não importa Prisma, Express ou bibliotecas de validação.
Porta de saída
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}A interface pertence à camada que precisa dela, normalmente aplicação ou domínio.
Caso de uso
export class ApproveOrder {
constructor(
private readonly orders: OrderRepository
) {}
async execute(input: ApproveOrderInput) {
const order = await this.orders.findById(
OrderId.from(input.orderId)
);
if (!order) {
throw new OrderNotFoundError();
}
order.approve();
await this.orders.save(order);
return {
id: order.id.value,
status: 'approved'
};
}
}O caso de uso coordena regra e persistência por interface.
Adapter PostgreSQL
export class PgOrderRepository implements OrderRepository {
constructor(private readonly db: Queryable) {}
async findById(id: OrderId) {
const result = await this.db.query(
'SELECT id, status, total_cents FROM orders WHERE id = $1',
[id.value]
);
if (result.rowCount === 0) return null;
return OrderMapper.toDomain(result.rows[0]);
}
async save(order: Order) {
const data = OrderMapper.toPersistence(order);
await this.db.query(`
UPDATE orders
SET status = $1,
total_cents = $2
WHERE id = $3
`, [data.status, data.totalCents, data.id]);
}
}O adapter conhece SQL e mapeia para tipos internos.
Controller HTTP
export function approveOrderController(
useCase: ApproveOrder
) {
return async (req, res, next) => {
try {
const output = await useCase.execute({
orderId: req.params.id
});
res.status(200).json(output);
} catch (error) {
next(error);
}
};
}O controller traduz protocolo, não contém regra de negócio.
Presenter
Para saídas complexas, um presenter converte DTO interno em HTTP:
export const OrderPresenter = {
toHTTP(order: OrderOutput) {
return {
id: order.id,
status: order.status,
total: order.total.toFixed(2)
};
}
};Isso evita que formato de API entre no domínio.
Composition Root
const pool = createPool(config.database);
const orderRepository = new PgOrderRepository(pool);
const approveOrder = new ApproveOrder(orderRepository);
app.post(
'/orders/:id/approve',
approveOrderController(approveOrder)
);O ponto de entrada conhece implementações e faz a injeção. Consulte Injeção de Dependência no Node.js.
Regra de dependência
Uma camada interna não deve importar uma externa:
// incorreto em domain/
import { PrismaClient } from '@prisma/client';
import express from 'express';Use lint e boundaries para detectar violações.
Portas de entrada
O caso de uso é uma porta de entrada. HTTP, filas e CLI podem chamá-lo.
consumer.on('order.approve.requested', async message => {
await approveOrder.execute({ orderId: message.orderId });
});A mesma regra funciona fora do Express.
Portas de saída
Interfaces representam dependências externas:
- repositories;
- gateway de pagamento;
- serviço de e-mail;
- clock;
- gerador de IDs;
- feature flags;
- publicador de eventos.
Clock injetável
interface Clock {
now(): Date;
}Evite new Date() espalhado em regras sensíveis ao tempo. Um fake torna testes determinísticos.
Gerador de IDs
interface IdGenerator {
next(): string;
}Produção usa UUID; teste usa valores previsíveis.
Erros de domínio
class InvalidOrderStatusError extends Error {
readonly code = 'INVALID_ORDER_STATUS';
}O domínio comunica significado, não status HTTP.
Mapeamento de erros
if (error instanceof OrderNotFoundError) {
return res.status(404).json({ code: error.code });
}
if (error instanceof InvalidOrderStatusError) {
return res.status(409).json({ code: error.code });
}O adapter escolhe a representação do protocolo.
Validação
Validação sintática ocorre na borda. Invariantes continuam no domínio.
const input = schema.parse(req.body);
await useCase.execute(input);Consulte Ajv no Node.js.
Transações
O caso de uso define a unidade de trabalho. Uma implementação pode fornecer repositories transacionais:
await unitOfWork.run(async tx => {
await useCase.execute(input, tx);
});Veja Unit of Work no Node.js.
Eventos
O domínio pode gerar eventos, enquanto a infraestrutura publica por outbox:
order.approve();
await tx.orders.save(order);
await tx.outbox.addMany(order.pullEvents());Consulte Outbox Pattern no Node.js.
Autorização
O caso de uso deve receber identidade e aplicar policy. Não dependa apenas de middleware que esconde botões.
Configuração
Variáveis de ambiente são carregadas na infraestrutura e transformadas em um objeto validado. O domínio não acessa process.env.
Consulte Variáveis de Ambiente no Node.js.
Framework como detalhe
Express, Fastify, Hono ou NestJS podem servir de adapter. A arquitetura não exige trocar frameworks frequentemente; ela impede que o framework domine a regra.
Clean Architecture com NestJS
NestJS fornece módulos e injeção, mas entidades e casos de uso ainda podem permanecer sem decorators. Consulte NestJS no Node.js.
Banco como detalhe
Trocar PostgreSQL por MongoDB não deve exigir reescrever casos de uso. Na prática, diferenças de modelo podem afetar o domínio, mas a fronteira limita o impacto.
Não abstraia tudo
Uma função de hash ou parser estável pode ser usada diretamente quando não cria acoplamento relevante. Interfaces para cada operação simples geram ruído.
Use abstrações nas fronteiras
Priorize interfaces para recursos com efeitos, volatilidade ou dificuldade de teste:
- banco;
- rede;
- filesystem;
- tempo;
- aleatoriedade;
- fila;
- serviços pagos.
Organização por feature
modules/orders/
├── domain/
├── application/
├── adapters/
└── infrastructure/Essa organização mantém arquivos relacionados próximos e evita pastas globais gigantes.
Dependência entre módulos
Módulos devem conversar por contratos públicos, eventos ou casos de uso. Não importe tabelas e internals arbitrariamente.
Testes unitários
test('aprova pedido pendente', async () => {
const repository = new InMemoryOrderRepository();
await repository.add(Order.pending('order-1'));
const useCase = new ApproveOrder(repository);
const result = await useCase.execute({
orderId: 'order-1'
});
assert.equal(result.status, 'approved');
});O teste não inicia servidor nem banco.
Testes de adapter
Teste PgOrderRepository com PostgreSQL real e controller com requests HTTP. Cada camada possui responsabilidades diferentes.
Testes de contrato
A mesma suíte pode validar repositories em memória e reais:
- salva e recupera;
- retorna null quando ausente;
- preserva tipos;
- respeita tenant;
- detecta concorrência.
Mocks demais
Se um teste precisa configurar dezenas de mocks, o caso de uso pode ter responsabilidades demais. Prefira fakes simples e objetos reais do domínio.
Observabilidade
Tracing, logs e métricas entram como decorators ou adapters. O domínio não deve depender de um SDK específico.
Decorator de tracing
class TracedApproveOrder implements ApproveOrderPort {
constructor(
private readonly inner: ApproveOrderPort,
private readonly tracer: Tracer
) {}
execute(input) {
return this.tracer.span('approve_order', () =>
this.inner.execute(input)
);
}
}Migração gradual
- Escolha um caso de uso importante.
- Extraia regra do controller.
- Defina repository.
- Crie adapter para o banco atual.
- Adicione testes.
- Repita por feature.
Não reescreva todo o sistema de uma vez.
Strangler interno
Rotas antigas podem chamar novos casos de uso gradualmente. O valor vem da melhoria contínua, não de uma estrutura perfeita antes de entregar.
Sinais de excesso
- DTOs idênticos copiados em cinco camadas;
- interfaces com uma implementação estável sem benefício;
- mappers que apenas retornam o mesmo objeto;
- casos de uso de uma linha para CRUD trivial;
- pastas profundas difíceis de navegar.
Sinais de falta de separação
- entidades importam ORM;
- controllers contêm SQL;
- testes exigem servidor para toda regra;
- troca de fila altera domínio;
- process.env aparece em qualquer módulo;
- status HTTP aparece em casos de uso.
Erros comuns
- Pastas sem regra: a arquitetura existe apenas no nome.
- Dependência invertida incorretamente: domínio importa infraestrutura.
- Interfaces demais: complexidade cresce sem isolamento.
- Entidade anêmica: regra fica toda em services.
- Framework escondido parcialmente: decorators vazam para o core.
- Reescrita total: projeto fica parado.
Boas práticas
- Proteja domínio e casos de uso.
- Defina portas nas fronteiras.
- Mantenha controllers finos.
- Faça composição no ponto de entrada.
- Use mappers entre modelos.
- Separe validação sintática e invariantes.
- Controle transações na aplicação.
- Teste core sem infraestrutura.
- Teste adapters com recursos reais.
- Migre por feature.
Conclusão
A Clean Architecture no Node.js mantém regras de negócio independentes de frameworks e infraestrutura. Entidades e casos de uso definem políticas; adapters traduzem HTTP, banco e mensagens.
O valor não está em criar muitas camadas, mas em controlar dependências. Com portas nas fronteiras, composition root, testes por responsabilidade e migração gradual, a aplicação ganha flexibilidade sem transformar arquitetura em burocracia.



