Home > Blog > Desenvolvimento Web
Desenvolvimento Web
JavaScript
Programação

Clean Architecture no Node.js

Atualizado em: 3 de setembro de 2026

Estação de trabalho usada no desenvolvimento de aplicações Node.js

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.ts

A 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

  1. Escolha um caso de uso importante.
  2. Extraia regra do controller.
  3. Defina repository.
  4. Crie adapter para o banco atual.
  5. Adicione testes.
  6. 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.

10 melhores cursos de programação em 2026

Descubra os melhores cursos de programação. Aprenda a escolher o curso ideal para iniciar ou avançar na carreira de desenvolvedor

POSTS RELACIONADOS

Ver todos

Seta para a direita