Arquitetura de software

Domain-Driven Design: linguagem ubíqua, agregados e bounded contexts

Avançado10 min de leituraAtualizado

Esta página foi traduzida do inglês com a ajuda de IA e conferida com o original. Ler a versão em inglês

Domain-driven design é deixar que a forma do negócio determine a forma do código, e também um vocabulário para negociar fronteiras entre times. Este guia cobre o lado estratégico (bounded contexts, context maps, subdomínios) e o lado tático com um modelo completo em Java, e depois mostra como a arquitetura hexagonal mantém o domínio livre de frameworks.

O que você vai aprender

  • Construir uma linguagem ubíqua com os especialistas do domínio e mantê-la visível no código
  • Modelar entidades, value objects e agregados com invariantes que valem a cada commit
  • Traçar bounded contexts ao longo das fronteiras de linguagem e documentar as relações em um context map
  • Publicar e consumir eventos de domínio sem vazar o modelo interno
  • Aplicar a arquitetura hexagonal para que o domínio não dependa de nenhum framework

A maior parte do software que se torna impossível de manter chega lá pelo mesmo motivo: o código parou de descrever o negócio. Os nomes das classes se afastam do que as pessoas realmente falam nas reuniões, as regras vão parar no service que por acaso estava aberto naquele momento e, depois de dois anos, a única forma confiável de responder "o que acontece quando um pedido é cancelado depois do despacho?" é ler quatro mil linhas. Domain-driven design é um conjunto de práticas para evitar isso, apoiado numa afirmação que parece óbvia e é muito difícil de sustentar: o modelo no código e o modelo na cabeça dos especialistas do domínio devem ser o mesmo modelo.

O DDD tem duas metades que costumam ser ensinadas juntas e adotadas separadamente. A metade estratégica trata de fronteiras: quais partes do negócio ganham um modelo próprio, como esses modelos se relacionam e onde investir um esforço de design que é sempre escasso. A metade tática é um conjunto de blocos de construção para expressar um modelo em código. O DDD estratégico é onde está a maior parte do valor, e é justamente a parte que os times pulam.

A linguagem ubíqua

Uma linguagem ubíqua (ubiquitous language) é um vocabulário compartilhado por desenvolvedores e especialistas do domínio, usado de forma idêntica nas conversas, na documentação e no código. Não um glossário guardado numa wiki que ninguém abre, mas uma linguagem de trabalho que muda quando o entendimento do time muda.

O teste prático é simples. Sente-se com um especialista do domínio e descreva o que o código faz usando apenas as palavras que estão no código. Se você se pegar traduzindo ("o que nós chamamos de TransactionRecord é o que vocês chamam de instrução de liquidação, exceto quando é um estorno"), essa tradução é um imposto permanente e uma fonte permanente de defeitos, porque cada tradução é uma oportunidade de traduzir errado.

Construir a linguagem é, acima de tudo, prestar atenção às distinções. Especialistas raramente dizem "temos dois tipos de cliente"; eles dizem "para pessoa jurídica, precisamos da autorização antes de poder debitar". Essa frase contém uma distinção (pessoa jurídica versus pessoa física), um conceito (a autorização) e uma regra (sem autorização, sem débito), e as três coisas pertencem ao modelo. Técnicas como o event storming, em que o time inteiro coloca todos os eventos do domínio numa parede em ordem cronológica, existem justamente para fazer esse tipo de frase aparecer rapidamente.

Depois que a linguagem existe, defenda-a. Quando um desenvolvedor propuser um OrderProcessor, pergunte como o negócio chama essa atividade; se ninguém souber responder, a classe provavelmente é um balaio de comportamentos sem relação entre si. Quando um especialista usar uma palavra que o código não tem, isso é uma lacuna de modelagem, não um problema de nomenclatura.

Os blocos táticos

Os padrões táticos dão à linguagem um lugar para morar.

Value object. Definido inteiramente pelos seus atributos, imutável, sem identidade: Money, IBAN, DateRange, Quantity. Dois value objects com atributos iguais são intercambiáveis. É neles que a maior parte das regras do domínio pode morar a baixo custo: um valor que não pode ser negativo, duas moedas que precisam coincidir antes de uma soma, um intervalo cujo fim não pode vir antes do início. Torná-los imutáveis elimina toda uma categoria de bugs de aliasing, e dar comportamento a eles mantém esse comportamento fora dos services.

Entidade. Tem uma identidade que persiste através das mudanças de estado. Dois pedidos com conteúdo idêntico continuam sendo dois pedidos. A igualdade é pelo identificador, nunca pelos valores dos campos.

Agregado. Um conjunto de entidades e value objects em que uma entidade é designada como raiz. Tudo o que está fora referencia apenas a raiz, e a raiz é responsável pelas invariantes de todo o conjunto. O agregado é a unidade de carga, gravação e lock, o que faz dele a fronteira de consistência transacional e, portanto, uma das decisões de maior consequência no modelo.

Repository. Uma interface com cara de coleção para recuperar e persistir raízes de agregado, expressa em termos do domínio: orders.findAwaitingDispatch(), e não select * from orders where status = 3. A interface pertence ao domínio; a implementação pertence à infraestrutura.

Serviço de domínio. Comportamento que pertence ao domínio, mas não se encaixa naturalmente em nenhuma entidade específica: uma transferência entre duas contas, um cálculo de preço envolvendo vários agregados. Use com moderação; um domínio cheio de services e vazio de comportamento é o modelo anêmico.

Factory. Encapsula a criação quando montar um agregado válido não é trivial. Muitas vezes é um método estático na própria raiz.

Evento de domínio. O registro de algo significativo que aconteceu, nomeado no passado: OrderDispatched, PaymentDeclined, MandateRevoked. Eventos são a forma de um agregado provocar trabalho em outro sem ficar acoplado a ele.

Um modelo completo

As regras: um pedido só pode ser confirmado se tiver pelo menos uma linha e um endereço de entrega válido; não é possível adicionar linhas depois da confirmação; o total é a soma das linhas e não pode ultrapassar o limite de crédito do cliente, que é verificado fora do agregado.

// Value object: immutable, self-validating, behaviour included.
public record Money(BigDecimal amount, Currency currency) {
    public Money {
        if (amount.scale() > currency.getDefaultFractionDigits())
            throw new IllegalArgumentException("too many decimals for " + currency);
    }
    public Money plus(Money other) {
        if (!currency.equals(other.currency))
            throw new CurrencyMismatch(currency, other.currency);
        return new Money(amount.add(other.amount), currency);
    }
    public boolean exceeds(Money limit) { return amount.compareTo(limit.amount) > 0; }
}

// Aggregate root: the only thing outside code may hold a reference to.
public class Order {

    private final OrderId id;
    private final CustomerId customer;
    private final List<OrderLine> lines = new ArrayList<>();
    private DeliveryAddress address;
    private OrderStatus status = OrderStatus.DRAFT;
    private final List<DomainEvent> events = new ArrayList<>();

    public void addLine(Sku sku, int quantity, Money unitPrice) {
        if (status != OrderStatus.DRAFT)
            throw new OrderAlreadyConfirmed(id);          // invariant, enforced here and nowhere else
        lines.stream().filter(l -> l.sku().equals(sku)).findFirst()
             .ifPresentOrElse(l -> l.increaseBy(quantity),
                              () -> lines.add(new OrderLine(sku, quantity, unitPrice)));
    }

    public void confirm(Clock clock) {
        if (lines.isEmpty())  throw new EmptyOrder(id);
        if (address == null)  throw new MissingDeliveryAddress(id);
        status = OrderStatus.CONFIRMED;
        events.add(new OrderConfirmed(id, customer, total(), clock.instant()));
    }

    public Money total() {
        return lines.stream().map(OrderLine::lineTotal)
                    .reduce(Money::plus).orElseThrow();
    }

    public List<DomainEvent> pullEvents() {          // drained by the application layer after save
        List<DomainEvent> drained = List.copyOf(events);
        events.clear();
        return drained;
    }
}

// Repository interface: domain vocabulary, no persistence technology.
public interface Orders {
    Optional<Order> byId(OrderId id);
    List<Order> awaitingDispatch(DeliveryZone zone);
    void save(Order order);
}

Vale notar três coisas. Todas as regras de um pedido moram dentro de Order, então existe um único lugar para ler e um único lugar para mudar. Nada de fora consegue alcançar uma OrderLine diretamente, então nenhum chamador consegue contornar addLine. E não há uma única anotação de framework no domínio: o mapeamento de persistência fica num adaptador de infraestrutura, que é o assunto da próxima seção.

A verificação do limite de crédito está ausente do agregado de propósito. Ela precisa do agregado de cliente, e atravessar agregados dentro de uma mesma transação é o que faz os agregados crescerem até que todos os pedidos do sistema disputem a mesma linha de cliente. Em vez disso, a camada de aplicação carrega os dois, ou a verificação acontece de forma assíncrona em resposta a OrderConfirmed, com um cancelamento compensatório se falhar: o formato de saga discutido em transações distribuídas e consistência.

Bounded contexts

Um modelo único cobrindo uma empresa inteira desaba sob o próprio peso, porque a mesma palavra significa coisas diferentes para departamentos diferentes. Para vendas, um "cliente" é um prospect em alguma etapa do pipeline. Para o faturamento, um cliente é uma pessoa jurídica com identificador fiscal e uma forma de pagamento. Para o suporte, cliente é quem estiver do outro lado da linha. Forçar uma única classe Customer a atender os três produz um tipo com sessenta campos, a maioria null, e regras que se contradizem.

Um bounded context (contexto delimitado) é uma fronteira explícita dentro da qual um modelo e uma linguagem se aplicam de forma consistente. Fora dela, as mesmas palavras podem significar outra coisa, e tudo bem, desde que a fronteira seja explícita e a tradução aconteça nela. Um contexto tem um time, uma base de código, seu próprio armazenamento e seu próprio vocabulário.

Isso faz dos bounded contexts o melhor guia disponível para fronteiras de serviço. Uma fronteira de contexto é onde a linguagem muda, que é exatamente onde deve haver uma interface; por isso contextos e serviços costumam se corresponder um para um, embora um contexto possa ser implementado por vários serviços e, num monólito modular, seja simplesmente um módulo com fronteiras garantidas.

Ligada a isso está a distinção entre tipos de subdomínio, que indica onde investir esforço:

SubdomínioO que éInvestimento
Núcleo (core)O que diferencia o negócioAs melhores pessoas, a modelagem mais profunda, desenvolvido internamente
De suporte (supporting)Necessário, específico da empresa, mas não diferenciadorConstruir de forma simples, sem invenção
Genérico (generic)Problemas já resolvidos: identidade, contabilidade, e-mailComprar ou usar um serviço existente

Aplicar todo o DDD tático a um subdomínio genérico é um erro comum e caro. Ninguém ganha nada modelando à mão uma tabela de conversão de moedas.

Context maps

A partir do momento em que você tem vários contextos, tem relações, e o padrão de relação determina quanta dor cada mudança causa. Dar nome a elas torna explícita uma estrutura de poder que era implícita.

  • Partnership (parceria): dois times têm sucesso ou fracassam juntos e coordenam as entregas de forma deliberada.
  • Shared kernel (núcleo compartilhado): um modelo pequeno compartilhado de propósito por dois contextos, que só muda por acordo. Mantenha-o minúsculo; ele acopla os ciclos de release.
  • Customer/supplier (cliente/fornecedor): o time downstream é um cliente cujas necessidades entram no backlog do time upstream.
  • Conformist (conformista): o downstream adota o modelo do upstream como está, porque não tem nenhuma influência. É barato, e importa por atacado as decisões de design do upstream.
  • Anti-corruption layer (ACL) (camada anticorrupção): o downstream constrói uma camada de tradução para que o modelo do upstream nunca entre no seu. O padrão mais valioso para integrar com um sistema legado ou com um fornecedor externo.
  • Open host service (serviço de host aberto): o upstream publica um protocolo projetado deliberadamente para muitos consumidores, em vez de integrações sob medida.
  • Published language (linguagem publicada): um formato de intercâmbio compartilhado e bem documentado, muitas vezes combinado com um open host service.
  • Separate ways (caminhos separados): os contextos simplesmente não se integram, porque o custo supera o benefício.

A anti-corruption layer merece destaque. Quando um serviço consome a API de um parceiro ou um sistema de registro cujo modelo não tem nada a ver com o seu, traduzir na borda (um adaptador que converte o CUST_REC_TYPE_3 deles no seu Customer) impede que o modelo estrangeiro se espalhe pelo seu código. Sem ela, daqui a um ano os nomes de campo do parceiro estarão no seu banco de dados.

Arquitetura hexagonal

Os padrões táticos só sobrevivem se a infraestrutura não conseguir se infiltrar no domínio, e isso é uma questão de direção das dependências. A arquitetura hexagonal (ou, de forma equivalente, ports and adapters, ou ainda as arquiteturas onion e clean) enuncia a regra sem rodeios: as dependências apontam para dentro, em direção ao domínio, e o domínio não depende de nada.

  • Domínio: entidades, value objects, agregados, serviços de domínio, eventos de domínio, interfaces de repository. Nenhum import de framework, nada de SQL, nada de HTTP, nada de JSON.
  • Aplicação: casos de uso que orquestram o domínio: carregar um agregado, chamar um método, salvar, publicar eventos, gerenciar a fronteira transacional. Fina por design.
  • Adaptadores: o mundo externo, nas duas direções. Adaptadores primários (controllers REST, listeners de mensagens, jobs agendados) chamam a aplicação; adaptadores secundários (repositories JPA, clientes HTTP, publicadores no broker) implementam interfaces que o domínio declarou.
// Application layer: one use case, transaction boundary, no business rules of its own.
@Service
class ConfirmOrder {
    private final Orders orders;              // domain port
    private final DomainEventPublisher publisher;

    @Transactional
    public void handle(OrderId id) {
        Order order = orders.byId(id).orElseThrow(() -> new UnknownOrder(id));
        order.confirm(Clock.systemUTC());     // the rule lives in the aggregate
        orders.save(order);
        publisher.publishAll(order.pullEvents());
    }
}

O ganho prático é a testabilidade. O domínio não tem container para subir nem banco para popular, então seus testes rodam em milissegundos e se leem como especificações das regras de negócio. Essas ideias de camadas se conectam diretamente com a arquitetura em camadas e n-tier, e a inversão de dependência que faz tudo funcionar é o mesmo mecanismo descrito em o container IoC do Spring.

Uma observação pragmática sobre persistência. Os puristas mantêm as anotações JPA fora do domínio mantendo entidades de persistência separadas e um mapeamento entre elas; isso é de fato mais limpo, e de fato mais código. Anotar diretamente as classes do domínio é um meio-termo defensável para um time que, de outro modo, não manteria esse mapeamento, desde que o design do modelo seja guiado pelo domínio e não pelo que é conveniente de mapear. O que não é defensável é deixar o lazy loading, as configurações de cascade ou os identificadores gerados decidirem as fronteiras dos seus agregados.

Erros comuns

Padrões táticos sem design estratégico. Espalhar @ValueObject por todo lado num único modelo compartilhado gigantesco produz cerimônia sem benefício. Contextos primeiro.

O agregado deus. Um agregado Customer contendo pedidos, faturas e tickets significa que toda operação trava o cliente. Divida por invariante, conecte por evento.

Repositories que devolvem linhas. Um repository que expõe uma linguagem de consulta, projeções e paginação sobre campos arbitrários é um DAO com nome da moda. Necessidades de consulta que não se encaixam são legítimas: atenda-as com um modelo de leitura separado em vez de distorcer o agregado.

Publicar eventos internos como contrato público. Todo consumidor fica acoplado aos seus nomes de campo. Traduza os eventos de domínio em eventos de integração projetados deliberadamente, na fronteira.

Modelar isoladamente. Um modelo construído por desenvolvedores a partir de um documento de especificação, sem um especialista do domínio na sala, codifica as ambiguidades da especificação. A linguagem se constrói na conversa, ou não se constrói.

Principais pontos

  • A linguagem ubíqua é a prática central: um único vocabulário na conversa, na documentação e no código, revisado sempre que o entendimento muda.
  • Value objects carregam regras a baixo custo, entidades carregam identidade e agregados definem a fronteira de consistência: mantenha-os pequenos e altere um por transação.
  • Bounded contexts tornam legítimo que a mesma palavra signifique coisas diferentes em partes diferentes do negócio, e são o guia mais confiável para fronteiras de serviço.
  • Um context map dá nome a cada relação (conformist, customer/supplier, anti-corruption layer) e torna o acoplamento e o poder visíveis antes que doam.
  • Invista o esforço de modelagem no subdomínio núcleo; compre o genérico e mantenha os subdomínios de suporte sem invenção.
  • A arquitetura hexagonal mantém os frameworks fora do domínio, e é isso que torna o modelo testável em milissegundos e durável diante de mudanças de tecnologia.

Fontes e leituras complementares

Perguntas frequentes

DDD exige microsserviços?
De jeito nenhum. O DDD é anterior aos microsserviços e funciona muito bem dentro de um único artefato de deploy, em que os bounded contexts viram módulos com fronteiras garantidas. A relação é no sentido inverso: se você vai dividir um sistema em serviços, os bounded contexts são o melhor guia disponível para saber onde ficam as costuras, porque uma fronteira de contexto é exatamente o ponto em que a linguagem e o modelo mudam.
Qual deve ser o tamanho de um agregado?
Tão pequeno quanto as invariantes permitirem. Um agregado é uma fronteira de consistência carregada e salva como uma unidade, então um agregado grande significa transações grandes e contenção na raiz. Se uma regra realmente exige que duas entidades mudem juntas de forma atômica, elas pertencem ao mesmo agregado; se a regra tolera um pequeno atraso, separe-as e ligue as partes com um evento de domínio.
Um modelo de domínio anêmico é sempre um erro?
É um erro quando o domínio tem regras interessantes e elas acabam espalhadas por services e controllers em vez de ficarem junto dos dados que restringem. Para um CRUD realmente simples (dados de referência, telas de configuração), um modelo enxuto com uma camada de serviços é adequado e a cerimônia do DDD seria desperdício. Aplique os padrões táticos ao núcleo complexo, não de maneira uniforme.
Qual a diferença entre um evento de domínio e um evento de integração?
Um evento de domínio descreve algo que aconteceu dentro de um bounded context, usa a linguagem desse contexto e pode carregar identificadores internos. Um evento de integração é uma mensagem pública projetada deliberadamente para outros contextos, com um schema estável e um vocabulário que faz sentido do lado de fora. Publicar seus eventos de domínio direto no broker compartilhado acopla todos os consumidores ao seu modelo interno; traduza na fronteira.