Domain-Driven Design: introdução e aplicação prática

1. Fundamentos do Domain-Driven Design

Domain-Driven Design (DDD) é uma abordagem de desenvolvimento de software que coloca o domínio do negócio no centro do processo de design. Surgiu como resposta ao problema da complexidade acidental — aquela que criamos ao modelar soluções técnicas que não refletem fielmente a realidade do negócio — em oposição à complexidade essencial, que é inerente ao próprio domínio.

Os três pilares fundamentais do DDD são:

  • Ubiquitous Language: uma linguagem compartilhada entre desenvolvedores e especialistas de negócio
  • Bounded Context: limites explícitos que definem onde um modelo é válido
  • Centralidade do domínio: o código deve refletir o modelo de negócio, não detalhes técnicos

DDD é mais indicado para projetos com lógica de negócio rica, regras complexas e equipes multidisciplinares que precisam colaborar intensamente. Para aplicações CRUD simples, pode ser excessivo.

2. Ubiquitous Language e Modelagem Colaborativa

A Ubiquitous Language é o vocabulário comum que todos os membros da equipe usam — desde a conversa no café até o código-fonte. Por exemplo, se especialistas de negócio chamam algo de "Reserva Confirmada", o código deve ter uma classe ou evento com esse mesmo nome, não "OrderApproved".

Técnicas de modelagem colaborativa como Event Storming e Domain Storytelling ajudam a descobrir essa linguagem. Em uma sessão de Event Storming, por exemplo, a equipe inteira coloca post-its em uma parede representando eventos do domínio, comandos e regras de negócio.

O glossário vivo deve ser mantido em um documento acessível (wiki ou README do repositório) e atualizado sempre que a linguagem evolui.

3. Bounded Contexts e Context Mapping

Bounded Contexts são limites explícitos onde um modelo de domínio específico é válido. Eles mapeiam os subdomínios do negócio:

  • Core subdomain: a parte mais crítica e diferenciadora do negócio
  • Supporting subdomain: necessário, mas não estratégico
  • Generic subdomain: soluções comuns que podem ser compradas ou terceirizadas

As estratégias de integração entre contextos incluem:

  • Customer/Supplier: um contexto fornece dados que outro consome
  • Conformist: um contexto se adapta ao modelo do outro
  • Anti-Corruption Layer: uma camada de tradução protege um contexto de modelos externos

Exemplo prático: em um e-commerce, separamos "Vendas" e "Estoque" como bounded contexts distintos. O contexto de Vendas não sabe como o estoque funciona internamente — ele apenas envia um comando "ReservarItem" e recebe uma resposta.

4. Entidades, Value Objects e Agregados

Entidades possuem identidade única e ciclo de vida. Um Pedido (ID #12345) continua sendo o mesmo pedido mesmo que seu status mude.

class Pedido {
    id: PedidoId
    clienteId: ClienteId
    itens: List<ItemPedido>
    status: StatusPedido

    fun confirmar() {
        validarItensDisponiveis()
        this.status = StatusPedido.CONFIRMADO
        adicionarEvento(PedidoConfirmado(this.id))
    }
}

Value Objects são imutáveis e se equivalem por seus atributos. Um Endereço com os mesmos campos é o mesmo endereço, independente de "identidade".

class Endereco {
    val rua: String
    val numero: String
    val cep: String
    val cidade: String
}

Agregados são clusters de entidades e value objects tratados como uma unidade. A raiz do agregado (ex: Pedido) garante a consistência transacional de todo o grupo.

class AgregadoPedido {
    val raiz: Pedido
    // Apenas a raiz expõe métodos públicos
    fun aplicarCupom(cupom: Cupom) {
        raiz.validarCupom(cupom)
        raiz.itens.forEach { item -> item.aplicarDesconto(cupom) }
    }
}

5. Domain Events e Comunicação Assíncrona

Domain Events representam algo relevante que ocorreu no domínio. Eles permitem comunicação assíncrona entre bounded contexts sem acoplamento forte.

class PedidoConfirmado(
    val pedidoId: String,
    val dataConfirmacao: DateTime
) : DomainEvent

class EstoqueInsuficiente(
    val pedidoId: String,
    val sku: String,
    val quantidadeSolicitada: Int,
    val quantidadeDisponivel: Int
) : DomainEvent

Exemplo prático: quando um pedido é confirmado, o domínio publica PedidoConfirmado. O contexto de Estoque reage, tentando reservar itens. Se falha, publica EstoqueInsuficiente, que dispara notificação ao cliente e rollback parcial.

// Reação ao evento
fun handle(evento: PedidoConfirmado) {
    val resultado = servicoEstoque.reservarItens(evento.pedidoId)
    if (!resultado.sucesso) {
        publicar(EstoqueInsuficiente(evento.pedidoId, resultado.sku, 
                  resultado.quantidade, resultado.disponivel))
    }
}

6. Repositórios, Fábricas e Camada de Aplicação

Repositórios abstraem a persistência, focando em agregados completos. O código de domínio nunca chama o banco diretamente.

interface RepositorioPedido {
    fun buscarPorId(id: PedidoId): Pedido?
    fun salvar(pedido: Pedido)
    fun buscarPorCliente(clienteId: ClienteId): List<Pedido>
}

Fábricas constroem objetos complexos garantindo invariantes. Uma fábrica de Pedido, por exemplo, valida que há ao menos um item antes de criar a instância.

Camada de aplicação orquestra casos de uso sem conter lógica de negócio:

class ServicoAplicacaoPedido(
    val repositorio: RepositorioPedido,
    val servicoEstoque: ServicoEstoque
) {
    fun confirmarPedido(comando: ConfirmarPedido) {
        val pedido = repositorio.buscarPorId(comando.pedidoId)
        pedido.confirmar()
        repositorio.salvar(pedido)
        // Dispara eventos
        eventBus.publicar(pedido.eventos)
    }
}

7. DDD na Prática: Exemplo End-to-End

Cenário: sistema de aluguel de carros com regras de reserva, faturamento e disponibilidade.

Bounded Contexts identificados:
- Reservas (core): gerencia agendamentos e disponibilidade
- Faturamento (supporting): calcula valores e processa pagamentos
- Frota (generic): gerencia manutenção dos veículos

Agregado principal (Reserva):

class Reserva {
    val id: ReservaId
    val clienteId: ClienteId
    val veiculoId: VeiculoId
    val periodo: PeriodoLocacao
    val valor: ValorLocacao
    val status: StatusReserva

    fun confirmar() {
        require(status == StatusReserva.PENDENTE)
        require(periodo.dataInicio.isAfter(LocalDate.now()))
        this.status = StatusReserva.CONFIRMADA
        adicionarEvento(ReservaConfirmada(this.id, this.veiculoId, this.periodo))
    }

    fun cancelar(motivo: String) {
        this.status = StatusReserva.CANCELADA
        adicionarEvento(ReservaCancelada(this.id, motivo))
    }
}

Caso de uso: Confirmar Reserva

class ConfirmarReservaHandler(
    val repositorio: RepositorioReserva,
    val servicoDisponibilidade: ServicoDisponibilidade,
    val eventBus: EventBus
) {
    fun executar(comando: ConfirmarReserva) {
        val reserva = repositorio.buscarPorId(comando.reservaId)
        require(servicoDisponibilidade.veiculoDisponivel(reserva.veiculoId, reserva.periodo))
        reserva.confirmar()
        repositorio.salvar(reserva)
        eventBus.publicar(reserva.eventos)
    }
}

8. Armadilhas Comuns e Boas Práticas

Armadilhas:

  1. Superengenharia: aplicar todos os padrões DDD em um CRUD simples. DDD não é sobre ter Aggregate Roots, Repositories e Domain Events em todo lugar — é sobre modelar o domínio corretamente.

  2. Ignorar a linguagem ubíqua: criar classes como OrderEntity, OrderRepositoryImpl enquanto o negócio chama de "Pedido" e "Arquivo de Pedidos". O código deve refletir o vocabulário do negócio.

  3. Agregados gigantes: colocar tudo dentro de um único agregado por preguiça de modelar, gerando problemas de concorrência e performance.

Boas práticas:

  • Comece pelo subdomínio core, onde o DDD agrega mais valor
  • Use Event Storming para descobrir o modelo antes de escrever código
  • Mantenha agregados pequenos — a regra prática é "mude apenas o que precisa ser consistente"
  • Refatore gradualmente: não precisa aplicar DDD do dia para a noite

DDD é uma jornada, não um destino. Comece pequeno, foque no que é essencial para o negócio e evolua com refatoração contínua.

Referências