Como aplicar DDD em projetos de médio porte sem over-engineering

1. Entendendo o escopo: DDD para médio porte vs. enterprise

Projetos de médio porte (5-15 desenvolvedores) frequentemente caem na armadilha de tentar replicar práticas de grandes corporações. A diferença fundamental não está na complexidade do negócio, mas na escala: enquanto sistemas enterprise precisam coordenar dezenas de times e contextos, projetos médios podem adotar uma abordagem mais enxuta.

Mitos comuns sobre DDD:
- DDD não exige microsserviços — você pode ter múltiplos Bounded Contexts em um único monólito modular
- Arquitetura hexagonal não é obrigatória — uma separação clara de camadas já resolve
- Event Sourcing e CQRS são ferramentas, não pré-requisitos

O princípio do “just enough DDD” significa aplicar apenas os padrões que trazem valor imediato. Se um conceito não reduz ruído de comunicação ou não simplifica a lógica de negócio, ele é over-engineering.

2. Delimitação de contextos com bom senso

Identificar Bounded Contexts exige critérios práticos:
- Equipe: cada contexto deve caber na cabeça de 2-3 desenvolvedores
- Linguagem ubíqua: termos diferentes para o mesmo conceito indicam contextos separados
- Ciclo de vida: contextos com frequência de mudança distinta devem ser separados

Exemplo: e-commerce de médio porte

Catálogo (produtos, categorias, preços)
  └── Shared Kernel: ID do produto
Pedidos (carrinho, checkout, histórico)
  └── Customer/Supplier: Catálogo fornece dados de produto
Pagamento (transações, reembolsos, antifraude)
  └── Customer/Supplier: Pedidos fornece valor e dados do cliente
Entrega (logística, rastreio, transportadoras)
  └── Evento: PedidoConfirmado → inicia processo de entrega

Apenas 4 contextos, com mapeamento simplificado. Nada de camadas de domínio compartilhado complexas ou relacionamentos OHS/PL.

3. Linguagem ubíqua prática e evolutiva

Construir a linguagem não precisa de semanas de reunião. Funciona melhor:
- Workshops curtos: 2-3 sessões de 1 hora com stakeholders e desenvolvedores
- Glossário vivo: um README no repositório principal, não um documento de 50 páginas
- Evolução natural: o termo muda conforme o entendimento amadurece

Exemplo de evolução:

Sprint 1: "item" → tudo que está no carrinho
Sprint 3: "produto em estoque" → item com quantidade disponível
Sprint 5: "produto em catálogo" → item exibido na loja (pode estar sem estoque)

A cada sprint, revise o glossário. Se alguém usa um termo diferente, investigue se é um novo conceito ou apenas variação linguística.

4. Modelagem tática: o essencial sem exageros

Entidades e Value Objects

Use Value Objects quando o conceito tem identidade baseada em seus atributos:

// Value Object: dinheiro (comparado por valor, não por ID)
class Dinheiro {
    constructor(valor: number, moeda: string) {
        if (valor < 0) throw new Error("Valor não pode ser negativo");
        this.valor = valor;
        this.moeda = moeda;
    }
    equals(outro: Dinheiro): boolean {
        return this.valor === outro.valor && this.moeda === outro.moeda;
    }
}

// Entidade: Pedido (identidade própria)
class Pedido {
    constructor(id: string, clienteId: string) {
        this.id = id;          // Identidade única
        this.clienteId = clienteId;
        this.itens = [];
        this.status = "rascunho";
    }
    adicionarItem(produto: Produto, quantidade: number) {
        // Lógica de negócio aqui
    }
}

Aggregate Rules

  • 1 aggregate por transação: se você precisa salvar duas coisas juntas, provavelmente são o mesmo aggregate
  • Limite de 5-7 entidades: acima disso, o aggregate fica difícil de gerenciar
  • Consistência imediata: dentro do aggregate, tudo deve ser consistente; entre aggregates, consistência eventual

Repositórios e Domain Services

Repositórios apenas para aggregates raiz:

interface PedidoRepositorio {
    buscarPorId(id: string): Pedido;
    salvar(pedido: Pedido): void;
}

Domain Services para operações que não cabem em nenhuma entidade:

class CalculadoraDeFrete {
    calcular(pedido: Pedido, cepDestino: string): Dinheiro {
        // Lógica que envolve múltiplos aggregates
    }
}

5. Estratégia de implementação sem frameworks pesados

A separação de camadas mínima:

src/
  dominio/         # Entidades, Value Objects, Domain Services
  aplicacao/       # Casos de uso (orquestração)
  infraestrutura/  # Repositórios concretos, APIs externas

Exemplo de aggregate completo:

// domínio/Pedido.ts
class Pedido {
    private itens: ItemPedido[] = [];

    constructor(
        readonly id: string,
        readonly clienteId: string,
        private repositorio: PedidoRepositorio
    ) {}

    adicionarItem(produtoId: string, quantidade: number, precoUnitario: Dinheiro) {
        if (this.status !== "rascunho") {
            throw new Error("Só é possível adicionar itens em pedidos rascunho");
        }
        const item = new ItemPedido(produtoId, quantidade, precoUnitario);
        this.itens.push(item);
    }

    confirmar() {
        if (this.itens.length === 0) {
            throw new Error("Pedido sem itens não pode ser confirmado");
        }
        this.status = "confirmado";
        this.repositorio.salvar(this);
        // Disparar evento de domínio (ver seção 6)
    }
}

Nada de ORM complexo, repositórios genéricos ou injeção de dependência elaborada. Apenas classes com lógica de negócio clara.

6. Integração entre contextos sem acoplamento

Anti-corruption Layer (ACL) simplificado

Quando um contexto precisa se comunicar com um sistema legado:

// infraestrutura/adaptadores/CatalogoLegadoAdapter.ts
class CatalogoLegadoAdapter implements CatalogoPort {
    buscarProduto(produtoId: string): Produto {
        const resposta = await http.get(`/api/v1/produtos/${produtoId}`);
        // Traduz do modelo legado para o modelo do domínio
        return new Produto(
            resposta.data.id,
            resposta.data.nome,
            new Dinheiro(resposta.data.preco, "BRL")
        );
    }
}

Eventos de domínio com mensageria básica

// domínio/eventos/PedidoConfirmado.ts
class PedidoConfirmado {
    constructor(
        readonly pedidoId: string,
        readonly clienteId: string,
        readonly total: Dinheiro,
        readonly itens: ItemPedido[]
    ) {}
}

// aplicacao/ConfirmarPedidoUseCase.ts
class ConfirmarPedidoUseCase {
    async executar(pedidoId: string) {
        const pedido = await this.repositorio.buscarPorId(pedidoId);
        pedido.confirmar();
        // Publicar evento no barramento
        await this.barramento.publicar(new PedidoConfirmado(
            pedido.id,
            pedido.clienteId,
            pedido.calcularTotal(),
            pedido.itens
        ));
    }
}

O contexto de Pagamento escuta PedidoConfirmado e inicia o processamento. Sem Event Store, apenas RabbitMQ ou SQS.

7. Ciclo de feedback e refatoração contínua

Event Storming leve: 2 horas a cada sprint para validar:
- Os eventos de domínio ainda fazem sentido?
- Novos conceitos surgiram?
- Algum contexto está muito grande?

Indicadores de over-engineering:
- Repositórios genéricos com métodos que nunca são usados
- Aggregates com 1-2 entidades (provavelmente deveriam ser VO)
- Interfaces para tudo (se só tem uma implementação, talvez não precise)
- Camadas de aplicação que só delegam para o domínio

Checklist final para saber se o DDD está ajudando:

✅ A equipe usa a mesma linguagem para falar de negócio
✅ Mudanças de regra afetam poucos arquivos
✅ Novos desenvolvedores entendem o domínio em 1 semana
❌ Você tem mais de 5 contextos para um sistema de 8 pessoas
❌ A equipe gasta mais tempo configurando ferramentas do que modelando
❌ Existe um diagrama UML que ninguém atualiza há 3 meses

DDD de médio porte é sobre comunicação e clareza, não sobre arquitetura impressionante. Quando o time consegue mudar uma regra de negócio em um único aggregate sem quebrar outros contextos, você acertou.


Referências