A Arquitetura Hexagonal no Node.js, também chamada de Ports and Adapters, organiza a aplicação ao redor de regras e casos de uso. HTTP, banco de dados, filas, CLI e serviços externos ficam conectados ao núcleo por interfaces explícitas.
O objetivo é permitir que a aplicação funcione sem depender diretamente de uma tecnologia específica. Um caso de uso pode ser acionado por Express ou RabbitMQ e persistir em PostgreSQL ou em memória, desde que os adapters respeitem as portas definidas.
Neste guia, você aprenderá a identificar portas de entrada e saída, criar adapters, composition root, testes, transações, eventos, autorização e uma estrutura prática para projetos Node.js.
O que é Arquitetura Hexagonal?
A arquitetura foi apresentada por Alistair Cockburn como Hexagonal Architecture. O desenho em hexágono não representa seis camadas; apenas mostra que o núcleo pode possuir várias conexões equivalentes com o mundo externo.
O sistema é dividido em:
- Aplicação: regras, entidades e casos de uso.
- Portas: contratos de comunicação.
- Adapters: implementações que traduzem protocolos e tecnologias.
Para uma visão relacionada, consulte Clean Architecture no Node.js.
Dentro e fora
O núcleo contém decisões de negócio. O lado externo contém detalhes:
- Express, Fastify, Hono ou NestJS;
- PostgreSQL, MongoDB ou Redis;
- Kafka, RabbitMQ ou NATS;
- S3 ou MinIO;
- APIs de pagamento;
- cron e CLI.
O núcleo não deve importar esses detalhes.
Porta de entrada
Uma porta de entrada descreve o que a aplicação faz:
export interface CreateOrderUseCase {
execute(input: CreateOrderInput): Promise<CreateOrderOutput>;
}Controllers HTTP e consumidores de fila chamam essa porta.
Implementação do caso de uso
export class CreateOrderService
implements CreateOrderUseCase {
constructor(
private readonly orders: OrderRepository,
private readonly inventory: InventoryGateway,
private readonly uow: UnitOfWork
) {}
async execute(input: CreateOrderInput) {
return this.uow.run(async tx => {
const availability = await this.inventory.check(
input.items
);
const order = Order.create({
customerId: input.customerId,
items: availability.items
});
await tx.orders.add(order);
await tx.outbox.addMany(order.pullEvents());
return OrderPresenter.toOutput(order);
});
}
}O caso de uso depende de portas de saída, não de implementações.
Porta de saída
export interface OrderRepository {
add(order: Order): Promise<void>;
findById(id: OrderId): Promise<Order | null>;
}A porta é definida pelo núcleo porque expressa o que ele precisa.
Adapter PostgreSQL
export class PgOrderRepository
implements OrderRepository {
constructor(private readonly db: Queryable) {}
async add(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.db.query(`
INSERT INTO orders (
id,
customer_id,
status,
total_cents
) VALUES ($1, $2, $3, $4)
`, [
data.id,
data.customerId,
data.status,
data.totalCents
]);
}
async findById(id: OrderId) {
const result = await this.db.query(
'SELECT * FROM orders WHERE id = $1',
[id.value]
);
return result.rowCount === 0
? null
: OrderMapper.toDomain(result.rows[0]);
}
}Para mais detalhes, veja Repository Pattern no Node.js.
Adapter em memória
export class InMemoryOrderRepository
implements OrderRepository {
private readonly items = new Map<string, Order>();
async add(order: Order) {
this.items.set(order.id.value, order);
}
async findById(id: OrderId) {
return this.items.get(id.value) ?? null;
}
}Esse adapter é útil em testes e demonstra a independência do núcleo.
Adapter HTTP de entrada
export function createOrderController(
useCase: CreateOrderUseCase
) {
return async (req, res, next) => {
try {
const input = createOrderSchema.parse({
customerId: req.user.id,
items: req.body.items
});
const output = await useCase.execute(input);
res.status(201).json(output);
} catch (error) {
next(error);
}
};
}O adapter traduz HTTP para dados do caso de uso.
Adapter de fila
consumer.on('create-order', async message => {
await createOrder.execute({
customerId: message.customerId,
items: message.items,
idempotencyKey: message.eventId
});
});A mesma porta é acionada sem Express.
Adapter CLI
await createOrder.execute({
customerId: argv.customer,
items: parseItems(argv.items)
});Controllers, filas e CLI são adapters primários.
Adapters primários e secundários
- Primários ou driving: iniciam uma ação, como HTTP, CLI, testes e consumidores.
- Secundários ou driven: são usados pela aplicação, como banco, cache, gateway e publisher.
A distinção mostra a direção da chamada, não importância.
Composition Root
const pool = createPool(config.database);
const uow = new PgUnitOfWork(pool);
const inventory = new HttpInventoryGateway(httpClient);
const createOrder = new CreateOrderService(
new PgOrderRepository(pool),
inventory,
uow
);
app.post('/orders', createOrderController(createOrder));O composition root conhece todas as implementações. Consulte Injeção de Dependência no Node.js.
Estrutura de pastas
src/orders/
├── domain/
│ ├── order.ts
│ └── money.ts
├── application/
│ ├── ports/
│ └── create-order.ts
├── adapters/
│ ├── http/
│ ├── postgres/
│ └── messaging/
└── composition.tsOrganizar por feature mantém o módulo coeso.
Domínio independente
export class Money {
private constructor(readonly cents: number) {}
static fromCents(cents: number) {
if (!Number.isSafeInteger(cents) || cents < 0) {
throw new InvalidMoneyError();
}
return new Money(cents);
}
}Não use decorators de ORM na entidade se isso prender o core à biblioteca.
Mapper
export const OrderMapper = {
toDomain(row: OrderRow): Order {
return Order.restore({
id: OrderId.from(row.id),
customerId: CustomerId.from(row.customer_id),
status: row.status,
total: Money.fromCents(row.total_cents)
});
}
};O mapper evita que modelos externos vazem.
Gateway HTTP
export interface PaymentGateway {
authorize(input: PaymentInput): Promise<PaymentResult>;
}Implementação:
export class ProviderPaymentGateway
implements PaymentGateway {
async authorize(input) {
const response = await fetch(this.url, {
method: 'POST',
body: JSON.stringify(input),
signal: AbortSignal.timeout(3000)
});
return mapProviderResponse(response);
}
}Timeout e tradução de erros pertencem ao adapter.
Circuit Breaker
Um decorator pode proteger a porta:
const paymentGateway = new CircuitBreakerGateway(
providerGateway,
breaker
);Consulte Circuit Breaker no Node.js.
Cache como decorator
const products = new CachedProductRepository(
new PgProductRepository(pool),
redis
);O caso de uso não precisa saber se há cache.
Autorização como porta
export interface AuthorizationPolicy {
assertCanCreateOrder(
actor: Actor,
customerId: CustomerId
): Promise<void>;
}A policy pode usar RBAC, ABAC ou serviço externo.
Transações
Uma Unit of Work é uma porta secundária:
export interface UnitOfWork {
run<T>(
callback: (tx: TransactionContext) => Promise<T>
): Promise<T>;
}Consulte Unit of Work no Node.js.
Eventos e outbox
O domínio cria eventos, e um adapter persiste ou publica:
await tx.orders.add(order);
await tx.outbox.addMany(order.pullEvents());Consulte Outbox Pattern no Node.js.
Validação na borda
JSON inválido e campos ausentes são responsabilidade do adapter. Regras como “pedido cancelado não pode ser aprovado” permanecem no domínio.
Erros internos
class ProductUnavailableError extends Error {
readonly code = 'PRODUCT_UNAVAILABLE';
}O HTTP adapter mapeia para 409; uma CLI pode imprimir mensagem e retornar código 2.
Evite status HTTP no core
Se o caso de uso retorna statusCode, ele deixa de ser reutilizável em outros adapters.
Testes do caso de uso
test('cria pedido quando há estoque', async () => {
const orders = new InMemoryOrderRepository();
const inventory = new AvailableInventoryGateway();
const uow = new InMemoryUnitOfWork({ orders });
const useCase = new CreateOrderService(
orders,
inventory,
uow
);
const result = await useCase.execute(validInput);
assert.equal(result.status, 'pending');
});Nenhum framework é iniciado.
Testes de adapters
O adapter PostgreSQL é testado com banco real; o adapter HTTP é testado com request; o gateway externo pode usar servidor falso.
Testes de contrato
Uma suíte comum valida qualquer adapter de uma porta. Isso evita que o fake tenha comportamento diferente da implementação real.
Adapter falso versus mock
Fakes implementam comportamento e são mais legíveis para muitos testes. Mocks são úteis para verificar chamadas específicas, mas podem acoplar o teste à implementação.
Observabilidade
Logging e tracing podem ser adapters ou decorators:
const tracedUseCase = new TracedCreateOrder(
createOrder,
tracer
);O core não importa OpenTelemetry diretamente.
Feature flags
Uma porta permite trocar fornecedor:
interface FeatureFlags {
enabled(name: string, context: FlagContext): Promise<boolean>;
}Consulte Feature Flags no Node.js.
Frameworks DI
Containers podem montar adapters, mas o core não precisa conhecê-los. Decorators do NestJS podem ficar no composition layer ou wrappers.
Troca de tecnologia
Uma porta reduz impacto, mas não torna bancos semanticamente iguais. PostgreSQL e MongoDB possuem capacidades diferentes. O contrato deve refletir necessidades do domínio, não fingir que todo storage é idêntico.
Não crie porta para cada função
Abstraia fronteiras voláteis e efeitos externos. Interfaces desnecessárias adicionam arquivos sem melhorar isolamento.
Portas coesas
Prefira:
interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
save(order: Order): Promise<void>;
}Evite uma porta universal com CRUD de todas as entidades.
Dependência circular
Módulos devem expor portas públicas. Se orders importa internals de billing e billing importa orders, crie contrato ou evento em uma direção clara.
Migração gradual
- Escolha um fluxo.
- Extraia o caso de uso.
- Defina portas necessárias.
- Envolva banco e HTTP em adapters.
- Crie composition root.
- Adicione testes.
Não é necessário reescrever tudo.
Erros comuns
- Porta definida pela infraestrutura: o core passa a seguir o ORM.
- Controller com regra: adapter domina o caso de uso.
- Entidade com decorators: domínio depende da biblioteca.
- Interfaces demais: ruído aumenta.
- Adapter sem mapeamento: modelos externos vazam.
- Composition espalhada: dependências ficam ocultas.
- Fake incompatível: teste fornece falsa confiança.
Boas práticas
- Defina portas pelo núcleo.
- Separe adapters primários e secundários.
- Mantenha domínio sem framework.
- Faça composição em um ponto.
- Use mappers nas fronteiras.
- Modele contratos coesos.
- Teste casos de uso com fakes.
- Teste adapters com recursos reais.
- Use outbox e Unit of Work.
- Migre por fluxo.
Conclusão
A Arquitetura Hexagonal no Node.js coloca casos de uso no centro e tecnologias nas bordas. Portas definem o que a aplicação oferece e precisa; adapters traduzem HTTP, banco, filas e serviços externos.
Quando as dependências apontam para o núcleo, o sistema fica mais testável e menos preso a frameworks. Com portas coesas, composition root e adapters bem testados, a arquitetura mantém flexibilidade sem exigir abstrações para cada linha de código.




