Por que a Arquitetura Hexagonal não é apenas um termo da moda, mas uma salvação para o seu código?
Imagine que você está construindo uma casa. Você não embute as tomadas nas paredes de concreto, certo? Primeiro você passa a fiação, faz a marcação e depois conecta cuidadosamente as tomadas. No desenvolvimento de software é exatamente a mesma coisa — sua lógica de negócio (núcleo da aplicação) não deve depender de como ela é acessada: REST API, banco de dados ou filas de mensagens.
A Arquitetura Hexagonal (também conhecida como "Portas e Adaptadores", ou "Arquitetura Limpa" na interpretação de Alistair Cockburn) resolve exatamente esse problema. Neste artigo, vamos analisar como implementar essa arquitetura em TypeScript com exemplos reais de código. Você aprenderá como separar a lógica de negócio da infraestrutura, tornando o código testável e flexível.
O que é Arquitetura Hexagonal em termos simples?
Imagine um hexágono. No centro está sua aplicação com a lógica de negócio. Em cada face há uma "porta" (interface) através da qual o mundo externo pode se comunicar com o núcleo. Os adaptadores são implementações concretas: controladores, repositórios, clientes de serviços externos. Eles se conectam às portas como um plugue em uma tomada.
O princípio principal: as dependências apontam para dentro. O código externo (bancos de dados, frameworks) depende do núcleo, e não o contrário. Isso permite trocar o banco de dados de PostgreSQL para MongoDB sem tocar em uma única linha da lógica de negócio.
Conceitos principais
- Núcleo (Domain) — lógica de negócio, entidades, value objects, casos de uso. Não possui dependências externas.
- Porta (Port) — interface (em TypeScript é uma classe abstrata ou interface) que define o contrato para o mundo externo.
- Adaptador (Adapter) — implementação concreta da porta. Por exemplo, um repositório que trabalha com PostgreSQL via TypeORM.
- Application — camada que coordena a execução dos casos de uso.
Exemplo prático: sistema de gerenciamento de pedidos
Vamos projetar um sistema simples de pedidos. Teremos as entidades Order e Product, o caso de uso CreateOrder, uma porta para o repositório e um adaptador para PostgreSQL.
Passo 1. Definindo o núcleo (Domain layer)
Criaremos a pasta domain/. Aqui vivem as entidades e interfaces para as portas. Sem dependências de frameworks!
// domain/order.entity.tsexport class Order { constructor( public readonly id: string, public items: OrderItem[], public status: 'pending' | 'confirmed' | 'shipped', public total: number ) {}
public addItem(productId: string, quantity: number, price: number): void { this.items.push({ productId, quantity, price }); this.total = this.calculateTotal(); }
private calculateTotal(): number { return this.items.reduce((sum, item) => sum + item.price * item.quantity, 0); }}
export interface OrderItem { productId: string; quantity: number; price: number;}Agora definimos a porta para o repositório:
// domain/ports/order.repository.interface.tsimport { Order } from '../order.entity';
export interface IOrderRepository { save(order: Order): Promise<void>; findById(id: string): Promise<Order | null>; findAll(): Promise<Order[]>;}Passo 2. Caso de Uso — o coração da aplicação
O Caso de Uso é um cenário de uso. Ele contém a lógica de negócio e utiliza as portas, mas não sabe quem as implementa.
// application/use-cases/create-order.use-case.tsimport { Order } from '../../domain/order.entity';import { IOrderRepository } from '../../domain/ports/order.repository.interface';
export class CreateOrderUseCase { constructor(private readonly orderRepository: IOrderRepository) {}
async execute(use