Arquitetura Hexagonal | Um Guia Pratico para Aplicacoes Modernas
Aprenda o que e a arquitetura hexagonal, porque importa e como implementa-la. Um guia completo com exemplos reais para construir software sustentavel e testavel.
A arquitetura hexagonal e um padrao de design de software que isola a sua logica de negocio central de sistemas externos como bases de dados, APIs e interfaces de utilizador. A ideia e simples: o seu codigo de dominio nunca deve depender da infraestrutura. A infraestrutura depende do dominio.
O padrao foi introduzido por Alistair Cockburn em 2005. Tambem pode ouvi-lo chamado “Ports and Adapters.” Ambos os nomes descrevem o mesmo conceito.
Porque Existe
A maioria das aplicacoes comeca da mesma forma. A logica de negocio fica emaranhada com queries de base de dados, handlers HTTP e chamadas a servicos de terceiros. Funciona no inicio. Depois a base de codigo cresce, e tres coisas acontecem:
- Testar torna-se penoso. Nao se consegue testar regras de negocio sem levantar uma base de dados ou fazer mock de metade do framework.
- Mudar infraestrutura e arriscado. Trocar um fornecedor de pagamentos ou migrar de REST para GraphQL significa reescrever logica de negocio.
- O codigo e dificil de compreender. Ninguem consegue dizer onde o dominio termina e a infraestrutura comeca.
A arquitetura hexagonal resolve os tres problemas ao impor uma unica regra: as dependencias apontam sempre para dentro.
Os Conceitos Fundamentais
Pense na sua aplicacao como tres camadas concentricas.
O Dominio (Centro)
Esta e a sua logica de negocio. Funcoes puras, entidades, objetos de valor e servicos de dominio. Tem zero dependencias de frameworks, bases de dados ou bibliotecas externas. Fala a sua propria linguagem.
Para um sistema de e-commerce, esta camada contem conceitos como Order, Product, PricingRule e InventoryPolicy. Estes objetos nao sabem nada sobre SQL, HTTP ou JSON.
Ports (Camada Intermédia)
Os ports sao interfaces que definem como o mundo exterior interage com o dominio. Sao contratos, nao implementacoes.
Ha dois tipos:
- Inbound ports (driving). Definem o que a aplicacao pode fazer. Pense neles como casos de uso. “Fazer uma encomenda.” “Cancelar uma subscricao.” “Gerar uma fatura.”
- Outbound ports (driven). Definem o que a aplicacao precisa do mundo exterior. “Guardar uma encomenda.” “Enviar uma notificacao.” “Obter a taxa de cambio.”
Os ports fazem parte da camada de dominio. Sao escritos na linguagem do dominio, nao na linguagem da infraestrutura. Escreve-se OrderRepository, nao PostgresOrderDAO.
Adapters (Camada Exterior)
Os adapters sao as implementacoes concretas que ligam o mundo real aos seus ports.
- Inbound adapters traduzem pedidos externos em chamadas de dominio. Um controlador REST, um resolver GraphQL, um comando CLI, um consumidor de fila de mensagens. Todos estes sao inbound adapters.
- Outbound adapters implementam as interfaces de outbound port. Um repositorio PostgreSQL, um remetente de email SMTP, um gateway de pagamento Stripe. Todos estes sao outbound adapters.
O ponto-chave: os adapters dependem dos ports. Os ports nunca dependem dos adapters. E isto que faz todo o padrao funcionar.
Um Exemplo Concreto
Digamos que esta a construir uma funcionalidade de colocacao de encomendas. Eis como as camadas se dividem.
Dominio (entidades e regras):
// domain/Order.ts
class Order {
readonly id: string;
readonly items: OrderItem[];
readonly status: OrderStatus;
calculateTotal(): Money {
return this.items.reduce(
(sum, item) => sum.add(item.price.multiply(item.quantity)),
Money.zero()
);
}
confirm(): Order {
if (this.items.length === 0) {
throw new EmptyOrderError();
}
return new Order({ ...this, status: "confirmed" });
}
}
Inbound port (interface de caso de uso):
// ports/inbound/PlaceOrder.ts
interface PlaceOrder {
execute(command: PlaceOrderCommand): Promise<OrderConfirmation>;
}
Outbound ports (o que o caso de uso precisa):
// ports/outbound/OrderRepository.ts
interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
}
// ports/outbound/PaymentGateway.ts
interface PaymentGateway {
charge(amount: Money, method: PaymentMethod): Promise<PaymentResult>;
}
// ports/outbound/NotificationSender.ts
interface NotificationSender {
sendOrderConfirmation(order: Order): Promise<void>;
}
Servico de aplicacao (implementa o inbound port):
// application/PlaceOrderService.ts
class PlaceOrderService implements PlaceOrder {
constructor(
private orders: OrderRepository,
private payments: PaymentGateway,
private notifications: NotificationSender
) {}
async execute(command: PlaceOrderCommand): Promise<OrderConfirmation> {
const order = Order.create(command.items);
const confirmed = order.confirm();
const payment = await this.payments.charge(
confirmed.calculateTotal(),
command.paymentMethod
);
if (!payment.successful) {
throw new PaymentFailedError(payment.reason);
}
await this.orders.save(confirmed);
await this.notifications.sendOrderConfirmation(confirmed);
return OrderConfirmation.from(confirmed, payment);
}
}
Adapters (infraestrutura):
// adapters/inbound/OrderController.ts
class OrderController {
constructor(private placeOrder: PlaceOrder) {}
async handlePost(req: Request): Promise<Response> {
const command = PlaceOrderCommand.fromRequest(req.body);
const confirmation = await this.placeOrder.execute(command);
return Response.json(confirmation.toResponse(), { status: 201 });
}
}
// adapters/outbound/PostgresOrderRepository.ts
class PostgresOrderRepository implements OrderRepository {
async save(order: Order): Promise<void> {
await this.db.query(
"INSERT INTO orders (id, items, status) VALUES ($1, $2, $3)",
[order.id, JSON.stringify(order.items), order.status]
);
}
async findById(id: string): Promise<Order | null> {
const row = await this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
return row ? Order.fromPersistence(row) : null;
}
}
Repare como PlaceOrderService nunca menciona PostgreSQL, HTTP ou qualquer framework. Funciona puramente com conceitos de dominio e interfaces de port.
A Regra de Dependencia
Esta e a regra mais importante. Se nao tirar mais nada deste artigo, tire isto:
As dependencias do codigo-fonte devem apontar sempre para dentro, em direcao ao dominio.
- Os adapters dependem dos ports. Nunca o inverso.
- Os ports dependem das entidades de dominio. Nunca dos adapters.
- O dominio nao depende de nada externo.
Isto e imposto atraves de injecao de dependencias. A sua composition root (o ponto de entrada da aplicacao) liga tudo:
// main.ts (composition root)
const orderRepo = new PostgresOrderRepository(db);
const payments = new StripePaymentGateway(stripeClient);
const notifications = new EmailNotificationSender(mailer);
const placeOrder = new PlaceOrderService(orderRepo, payments, notifications);
const controller = new OrderController(placeOrder);
A camada de dominio nunca importa da camada de adapters. Se vir um import de adapters/ dentro de domain/ ou ports/, algo esta errado.
Estrutura do Projeto
Uma estrutura de pastas limpa torna as fronteiras visiveis:
src/
domain/
Order.ts
OrderItem.ts
Money.ts
errors/
EmptyOrderError.ts
PaymentFailedError.ts
ports/
inbound/
PlaceOrder.ts
CancelOrder.ts
outbound/
OrderRepository.ts
PaymentGateway.ts
NotificationSender.ts
application/
PlaceOrderService.ts
CancelOrderService.ts
adapters/
inbound/
http/
OrderController.ts
cli/
PlaceOrderCommand.ts
outbound/
persistence/
PostgresOrderRepository.ts
payment/
StripePaymentGateway.ts
notification/
EmailNotificationSender.ts
main.ts
Qualquer programador na equipa pode olhar para esta arvore e compreender onde colocar novo codigo.
Beneficios para Testes
E aqui que a arquitetura hexagonal realmente compensa.
Testes unitarios do dominio:
test("order calculates total correctly", () => {
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 3 },
{ product: "Gadget", price: Money.eur(25), quantity: 1 },
]);
expect(order.calculateTotal()).toEqual(Money.eur(55));
});
Sem base de dados. Sem servidor HTTP. Sem framework de mocking. Logica pura, testes puros.
Testar casos de uso com fake adapters:
test("place order charges payment and saves", async () => {
const orders = new InMemoryOrderRepository();
const payments = new FakePaymentGateway({ alwaysSucceeds: true });
const notifications = new FakeNotificationSender();
const service = new PlaceOrderService(orders, payments, notifications);
const result = await service.execute({
items: [{ product: "Widget", price: 10, quantity: 2 }],
paymentMethod: { type: "card", token: "tok_test" },
});
expect(result.status).toBe("confirmed");
expect(orders.savedOrders).toHaveLength(1);
expect(payments.charges).toHaveLength(1);
expect(notifications.sent).toHaveLength(1);
});
Testa o caso de uso completo sem tocar em infraestrutura real. Os fake adapters implementam as mesmas interfaces de port, por isso sao intercambiaveis com os reais.
Testes de integracao apenas para adapters:
test("PostgresOrderRepository saves and retrieves", async () => {
const repo = new PostgresOrderRepository(testDb);
const order = Order.create([
{ product: "Widget", price: Money.eur(10), quantity: 1 },
]);
await repo.save(order.confirm());
const found = await repo.findById(order.id);
expect(found).toEqual(order.confirm());
});
Cada adapter tem o seu proprio teste de integracao. Se o adapter passa no teste, e o caso de uso passa com fake adapters, o sistema inteiro funciona.
Quando Usar Arquitetura Hexagonal
A arquitetura hexagonal adiciona estrutura e indireccao. Isso tem um custo. Eis quando vale a pena:
- Aplicacoes de longa vida. Se o projeto sera mantido durante anos, o investimento paga-se rapidamente.
- Regras de negocio complexas. Se o seu dominio tem logica nao trivial que precisa de ser testada minuciosamente.
- Multiplos pontos de entrada. Se a mesma logica e chamada de uma API web, um CLI, uma fila de mensagens e um cron job.
- A infraestrutura pode mudar. Se nao tem a certeza se vai manter a sua base de dados atual, fornecedor cloud ou servicos de terceiros.
- Multiplos programadores. Fronteiras claras reduzem conflitos de merge e tornam as code reviews mais rapidas.
Quando a Ignorar
Nem todo o projeto precisa deste nivel de estrutura:
- Aplicacoes CRUD simples. Se a aplicacao e maioritariamente ler e escrever dados com logica de negocio minima, a indireccao adiciona custo sem beneficio.
- Prototipos e MVPs. A velocidade importa mais que a arquitetura quando esta a validar uma ideia. Refatore depois.
- Scripts e ferramentas pequenas. Uma ferramenta CLI de 200 linhas nao precisa de ports e adapters.
A quantidade certa de arquitetura depende da complexidade do problema. Comece simples. Introduza fronteiras hexagonais quando a dor de nao as ter se torne real.
Erros Comuns
Vazar infraestrutura para o dominio. Se a sua entidade Order tem um decorador @Column ou um metodo toJSON(), a infraestrutura vazou. Mantenha os seus objetos de dominio limpos.
Criar ports que espelham infraestrutura. Um port chamado SqlQuery anula o proposito. Os ports devem descrever o que o dominio precisa na linguagem do dominio, nao como a infraestrutura funciona.
Sobre-abstrair. Nem tudo precisa de um port. Se tem uma funcao utilitaria que formata datas, use-a diretamente. Reserve ports para fronteiras de infraestrutura reais.
Saltar a composition root. Sem um local claro onde as dependencias sao ligadas, o padrao desmorona. Cada dependencia deve ser injetada, nao importada diretamente.
Hexagonal vs. Outros Padroes
Pode perguntar-se como a arquitetura hexagonal se relaciona com outros padroes bem conhecidos:
- Clean Architecture (Robert C. Martin): Muito semelhante. A Clean Architecture adiciona mais camadas (entidades, casos de uso, interface adapters, frameworks) mas a ideia central e a mesma: as dependencias apontam para dentro.
- Onion Architecture (Jeffrey Palermo): Tambem muito semelhante. A Onion Architecture usa aneis concentricos com o dominio no centro. A terminologia difere, mas a regra de dependencia e identica.
- Layered Architecture (N-tier): A abordagem tradicional onde as camadas se empilham verticalmente (apresentacao, negocio, dados). A diferenca crucial: na arquitetura em camadas, a camada de negocio tipicamente depende da camada de dados. Na arquitetura hexagonal, essa dependencia e invertida.
Os tres padroes modernos (hexagonal, clean, onion) partilham o mesmo principio fundamental. O dominio nao depende da infraestrutura. Diferem principalmente em convencoes de nomenclatura e no numero de camadas que prescrevem.
Como Comecar
Se quer aplicar arquitetura hexagonal a um projeto existente, nao tente refatorar tudo de uma vez. Comece com um bounded context ou uma funcionalidade:
- Identifique a logica de negocio central nessa funcionalidade. Extraia-a para funcoes puras ou classes sem dependencias de framework.
- Defina ports para as dependencias externas que essa funcionalidade usa. Crie interfaces na linguagem do dominio.
- Mova o codigo de infraestrutura existente para classes adapter que implementam essas interfaces.
- Ligue tudo numa composition root usando injecao de dependencias.
- Escreva testes para o dominio e casos de uso usando fake adapters.
Repita para a proxima funcionalidade. Com o tempo, as fronteiras hexagonais espalham-se pela base de codigo naturalmente.
Consideracoes Finais
A arquitetura hexagonal nao e sobre seguir um template rigido. E sobre uma ideia simples: proteger a sua logica de negocio do caos do mundo exterior. As bases de dados mudam. Os frameworks saem de moda. As APIs sao descontinuadas. As suas regras de negocio devem sobreviver a tudo isso.
O padrao funciona porque se alinha com a forma como o software realmente evolui. Os requisitos mudam constantemente. A infraestrutura muda periodicamente. Mas as regras centrais do seu negocio tendem a ser a parte mais estavel do sistema. Construa em torno dessa estabilidade.
Se esta a construir um produto que precisa de durar, a arquitetura hexagonal e um dos melhores investimentos que pode fazer na sua base de codigo.
Precisa de ajuda a desenhar ou refatorar a arquitetura da sua aplicacao? Contacte-nos. Ajudamos equipas a construir software que se mantem sustentavel a medida que cresce.