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

Arquitetura Hexagonal no Node.js

Atualizado em: 3 de setembro de 2026

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

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

Organizar 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

  1. Escolha um fluxo.
  2. Extraia o caso de uso.
  3. Defina portas necessárias.
  4. Envolva banco e HTTP em adapters.
  5. Crie composition root.
  6. 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.

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