DDD: Hexagonal Architecture e Clean Architecture - Isolando o domínio (Parte 13)

DDD8 min de leitura

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.

Referências