DDD: Hexagonal Architecture e Clean Architecture - Isolando o domínio (Parte 13)
Este é o décimo terceiro e último artigo da série sobre Domain-Driven Design. Vamos explorar duas arquiteturas complementares que protegem e isolam o domínio: Hexagonal Architecture e Clean Architecture.
O que é Hexagonal Architecture?
Hexagonal Architecture (também conhecida como Ports and Adapters) foi criada por Alistair Cockburn. O objetivo é isolar o núcleo da aplicação (domínio) das preocupações externas.
"Permite que uma aplicação seja igualmente dirigida por usuários, programas, testes automatizados ou scripts de lote, e seja desenvolvida e testada isoladamente de seus dispositivos e bancos de dados."
Problema: Dependências Invertidas
<?php
// ❌ PROBLEMA: Domínio dependente de infraestrutura
class PedidoService
{
// Dependência direta do banco de dados
private MySQL $database;
private SendGridEmailService $emailService;
private StripePaymentGateway $paymentGateway;
public function __construct()
{
$this->database = new MySQL();
$this->emailService = new SendGridEmailService();
$this->paymentGateway = new StripePaymentGateway();
}
public function processarPedido(array $pedidoData): void
{
// Lógica de negócio misturada com infraestrutura
$pedido = $this->database->query('SELECT * FROM pedidos WHERE id = ?', [$pedidoData['id']]);
if ($pedido['valor'] > 100) {
$this->paymentGateway->processPayment([
'amount' => $pedido['valor'],
'cardToken' => $pedidoData['cardToken']
]);
}
$this->emailService->sendEmail([
'to' => $pedido['customerEmail'],
'subject' => 'Pedido confirmado',
'body' => "Seu pedido {$pedido['id']} foi confirmado!"
]);
}
}
Solução: Hexagonal Architecture
1. Definindo Ports (Interfaces)
<?php
// ✅ SOLUÇÃO: Domínio define interfaces (Ports)
// Port para persistência
interface PedidoRepository
{
public function buscarPorId(string $id): ?Pedido;
public function salvar(Pedido $pedido): void;
}
// Port para notificação
interface NotificacaoService
{
public function enviarConfirmacao(Pedido $pedido): void;
}
// Port para pagamento
interface PagamentoGateway
{
public function processar(Pagamento $pagamento): ResultadoPagamento;
}
2. Domínio Usando os Ports
// Domínio puro, sem dependências externas
class ProcessarPedidoUseCase {
constructor(
private pedidoRepository: PedidoRepository,
private pagamentoGateway: PagamentoGateway,
private notificacaoService: NotificacaoService
) {}
async executar(comando: ProcessarPedidoComando): Promise<void> {
// 1. Buscar pedido
const pedido = await this.pedidoRepository.buscarPorId(comando.pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado');
}
// 2. Lógica de negócio pura
if (pedido.valor.valor > 100) {
const pagamento = pedido.criarPagamento(comando.metodoPagamento);
const resultado = await this.pagamentoGateway.processar(pagamento);
if (!resultado.sucesso) {
throw new Error('Falha no pagamento');
}
pedido.marcarComoPago();
}
// 3. Confirmar pedido
pedido.confirmar();
await this.pedidoRepository.salvar(pedido);
// 4. Notificar cliente
await this.notificacaoService.enviarConfirmacao(pedido);
}
}
3. Adapters (Implementações)
// Adapter para banco de dados
class PedidoRepositoryPostgreSQL implements PedidoRepository {
constructor(private database: PostgreSQLConnection) {}
async buscarPorId(id: string): Promise<Pedido | null> {
const row = await this.database.query(
'SELECT * FROM pedidos WHERE id = $1',
[id]
);
if (!row) return null;
return this.mapearParaDominio(row);
}
async salvar(pedido: Pedido): Promise<void> {
const data = this.mapearParaPersistencia(pedido);
await this.database.query(
'UPDATE pedidos SET status = $1, valor = $2 WHERE id = $3',
[data.status, data.valor, data.id]
);
}
private mapearParaDominio(row: any): Pedido {
return new Pedido(
row.id,
new Cliente(row.cliente_id, row.cliente_nome),
new Dinheiro(row.valor),
StatusPedido.fromString(row.status)
);
}
private mapearParaPersistencia(pedido: Pedido): any {
return {
id: pedido.id,
status: pedido.status.toString(),
valor: pedido.valor.valor
};
}
}
// Adapter para email
class NotificacaoEmailAdapter implements NotificacaoService {
constructor(private emailProvider: EmailProvider) {}
async enviarConfirmacao(pedido: Pedido): Promise<void> {
await this.emailProvider.enviar({
destinatario: pedido.cliente.email,
assunto: 'Pedido Confirmado',
corpo: `Seu pedido ${pedido.id} foi confirmado com sucesso!`,
template: 'confirmacao-pedido'
});
}
}
// Adapter para pagamento
class PagamentoStripeAdapter implements PagamentoGateway {
constructor(private stripeClient: StripeClient) {}
async processar(pagamento: Pagamento): Promise<ResultadoPagamento> {
try {
const resultado = await this.stripeClient.charges.create({
amount: Math.round(pagamento.valor.valor * 100), // centavos
currency: 'brl',
source: pagamento.token,
description: `Pagamento pedido ${pagamento.pedidoId}`
});
return new ResultadoPagamento(
resultado.id,
resultado.status === 'succeeded' ? StatusPagamento.APROVADO : StatusPagamento.REJEITADO
);
} catch (error) {
return new ResultadoPagamento(
null,
StatusPagamento.ERRO,
error.message
);
}
}
}
Clean Architecture
Clean Architecture, de Robert C. Martin (Uncle Bob), é uma evolução natural que organiza o código em camadas concêntricas.
Estrutura das Camadas
// 1. ENTITIES (núcleo mais interno)
class Pedido {
constructor(
private id: string,
private cliente: Cliente,
private itens: ItemPedido[],
private status: StatusPedido = StatusPedido.PENDENTE
) {}
confirmar(): void {
if (this.status !== StatusPedido.PENDENTE) {
throw new Error('Pedido já foi processado');
}
this.status = StatusPedido.CONFIRMADO;
}
calcularTotal(): Dinheiro {
return this.itens.reduce(
(total, item) => total.somar(item.subtotal()),
Dinheiro.zero()
);
}
}
// 2. USE CASES (regras de negócio da aplicação)
class ConfirmarPedidoUseCase {
constructor(
private pedidoRepository: PedidoRepository,
private estoque: EstoqueService,
private notificacao: NotificacaoService
) {}
async executar(pedidoId: string): Promise<void> {
const pedido = await this.pedidoRepository.buscarPorId(pedidoId);
// Verificar estoque
const temEstoque = await this.estoque.verificarDisponibilidade(pedido);
if (!temEstoque) {
throw new Error('Produto indisponível');
}
// Confirmar pedido (regra de negócio)
pedido.confirmar();
// Reservar estoque
await this.estoque.reservar(pedido);
// Salvar
await this.pedidoRepository.salvar(pedido);
// Notificar
await this.notificacao.enviarConfirmacao(pedido);
}
}
// 3. INTERFACE ADAPTERS (conversores)
class PedidoController {
constructor(private confirmarPedidoUseCase: ConfirmarPedidoUseCase) {}
async confirmar(request: HttpRequest): Promise<HttpResponse> {
try {
const pedidoId = request.params.id;
await this.confirmarPedidoUseCase.executar(pedidoId);
return {
status: 200,
body: { message: 'Pedido confirmado com sucesso' }
};
} catch (error) {
return {
status: 400,
body: { error: error.message }
};
}
}
}
// 4. FRAMEWORKS & DRIVERS (camada mais externa)
class ExpressRouteAdapter {
static adapt(controller: any, method: string) {
return async (req: any, res: any) => {
const httpRequest = {
params: req.params,
body: req.body,
query: req.query
};
const httpResponse = await controller[method](httpRequest);
res.status(httpResponse.status).json(httpResponse.body);
};
}
}
Dependency Injection
Para conectar as camadas, usamos injeção de dependência:
// Container de dependências
class DIContainer {
private dependencies = new Map();
register<T>(token: string, factory: () => T): void {
this.dependencies.set(token, factory);
}
resolve<T>(token: string): T {
const factory = this.dependencies.get(token);
if (!factory) {
throw new Error(`Dependência não encontrada: ${token}`);
}
return factory();
}
}
// Configuração das dependências
class ApplicationComposition {
static configure(): DIContainer {
const container = new DIContainer();
// Infraestrutura
container.register('database', () => new PostgreSQLConnection());
container.register('emailProvider', () => new SendGridProvider());
container.register('paymentGateway', () => new StripeGateway());
// Repositories
container.register('pedidoRepository', () =>
new PedidoRepositoryPostgreSQL(container.resolve('database'))
);
// Services
container.register('notificacaoService', () =>
new NotificacaoEmailAdapter(container.resolve('emailProvider'))
);
// Use Cases
container.register('confirmarPedidoUseCase', () =>
new ConfirmarPedidoUseCase(
container.resolve('pedidoRepository'),
container.resolve('estoqueService'),
container.resolve('notificacaoService')
)
);
// Controllers
container.register('pedidoController', () =>
new PedidoController(container.resolve('confirmarPedidoUseCase'))
);
return container;
}
}
Estrutura de Pastas
src/
├── domain/ # Camada de Domínio
│ ├── entities/
│ │ ├── Pedido.ts
│ │ ├── Cliente.ts
│ │ └── ItemPedido.ts
│ ├── value-objects/
│ │ ├── Dinheiro.ts
│ │ └── Email.ts
│ ├── repositories/ # Ports
│ │ └── PedidoRepository.ts
│ └── services/ # Ports
│ └── NotificacaoService.ts
│
├── application/ # Casos de Uso
│ ├── use-cases/
│ │ ├── ConfirmarPedidoUseCase.ts
│ │ └── CancelarPedidoUseCase.ts
│ └── ports/
│ ├── PagamentoGateway.ts
│ └── EstoqueService.ts
│
├── infrastructure/ # Adapters
│ ├── repositories/
│ │ └── PedidoRepositoryPostgreSQL.ts
│ ├── gateways/
│ │ ├── PagamentoStripeAdapter.ts
│ │ └── NotificacaoEmailAdapter.ts
│ └── database/
│ └── PostgreSQLConnection.ts
│
├── presentation/ # Interface Adapters
│ ├── controllers/
│ │ └── PedidoController.ts
│ ├── middlewares/
│ └── routes/
│ └── pedidoRoutes.ts
│
└── main/ # Frameworks & Drivers
├── config/
│ └── DIContainer.ts
├── server/
│ └── ExpressServer.ts
└── app.ts
Testes em Arquitetura Limpa
1. Testes de Unidade (Domínio)
describe('Pedido', () => {
it('deve calcular total corretamente', () => {
const item1 = new ItemPedido(produto1, 2, new Dinheiro(50));
const item2 = new ItemPedido(produto2, 1, new Dinheiro(30));
const pedido = new Pedido('123', cliente, [item1, item2]);
expect(pedido.calcularTotal().valor).toBe(130);
});
it('deve confirmar pedido pendente', () => {
const pedido = new Pedido('123', cliente, itens);
pedido.confirmar();
expect(pedido.status).toBe(StatusPedido.CONFIRMADO);
});
});
2. Testes de Integração (Use Cases)
describe('ConfirmarPedidoUseCase', () => {
let useCase: ConfirmarPedidoUseCase;
let mockRepository: jest.Mocked<PedidoRepository>;
let mockEstoque: jest.Mocked<EstoqueService>;
let mockNotificacao: jest.Mocked<NotificacaoService>;
beforeEach(() => {
mockRepository = {
buscarPorId: jest.fn(),
salvar: jest.fn()
};
mockEstoque = {
verificarDisponibilidade: jest.fn(),
reservar: jest.fn()
};
mockNotificacao = {
enviarConfirmacao: jest.fn()
};
useCase = new ConfirmarPedidoUseCase(
mockRepository,
mockEstoque,
mockNotificacao
);
});
it('deve confirmar pedido com estoque disponível', async () => {
const pedido = new Pedido('123', cliente, itens);
mockRepository.buscarPorId.mockResolvedValue(pedido);
mockEstoque.verificarDisponibilidade.mockResolvedValue(true);
await useCase.executar('123');
expect(pedido.status).toBe(StatusPedido.CONFIRMADO);
expect(mockRepository.salvar).toHaveBeenCalledWith(pedido);
expect(mockNotificacao.enviarConfirmacao).toHaveBeenCalledWith(pedido);
});
});
Vantagens das Arquiteturas
1. Testabilidade
// Domínio pode ser testado sem infraestrutura
const pedido = new Pedido(cliente, itens);
pedido.confirmar();
expect(pedido.status).toBe(StatusPedido.CONFIRMADO);
2. Flexibilidade
// Pode trocar implementações facilmente
const prodRepository = new PedidoRepositoryMySQL(); // ou MongoDB
const emailService = new NotificacaoWhatsApp(); // ou SMS
const pagamentoGateway = new PagamentoPagseguro(); // ou PayPal
3. Independência
// Use cases não dependem de frameworks
class ProcessarPedidoUseCase {
// Não sabe se é REST, GraphQL, CLI, etc.
// Não sabe se é MySQL, MongoDB, etc.
// Apenas executa lógica de negócio
}
Conclusão
Hexagonal Architecture e Clean Architecture trabalham juntas para criar sistemas robustos, testáveis e flexíveis.
Benefícios:
- ✅ Testabilidade - Domínio isolado e testável
- ✅ Flexibilidade - Fácil troca de implementações
- ✅ Manutenibilidade - Separação clara de responsabilidades
- ✅ Evolução - Frameworks podem ser trocados sem afetar o core
Quando usar:
- ✅ Sistemas complexos
- ✅ Longa duração
- ✅ Múltiplas integrações
- ✅ Necessidade de testes
Quando não usar:
- ❌ Protótipos rápidos
- ❌ Sistemas muito simples
- ❌ Time pequeno e inexperiente
Com isso concluímos nossa série sobre Domain-Driven Design. Do conceito básico às arquiteturas avançadas, exploramos como criar software que realmente reflete e serve ao negócio.