Separando domínios com DDD estratégico: subdomínios, bounded contexts e o mapa de contexto
Antes de pensar em módulo ou serviço, você precisa saber onde estão as fronteiras. Um guia prático com Evans, Vernon e Kononov aplicado a uma plataforma escolar.
Publicado em · Read in English
Neste artigo
- 1Introdução
- 2Pré-requisitos
- 3Conceito
- 3.1Espaço do problema vs. espaço da solução
- 3.2Tipos de subdomínio
- 3.3Aplicando na plataforma escolar
- 3.4A linguagem denuncia a fronteira
- 3.5Heurísticas para traçar fronteiras
- 3.6O mapa de contexto
- 4Mão na massa
- 4.1Setup
- 4.2Um “aluno” por contexto
- 4.3Published Language entre matrícula e pedagógico
- 4.4Anti-Corruption Layer para o gateway
- 5Testando
- 6Armadilhas e trade-offs
- 7Resumo
- 8Conclusão
- 9Código no GitHub
TL;DR
- Subdomínio é um pedaço do problema do negócio; bounded context é a fronteira de um modelo na solução. Confundir os dois é o erro mais comum de quem “aplica DDD”.
- Classifique os subdomínios em core, supporting e generic: isso decide onde você investe modelagem rica e onde compra pronto.
- A fronteira certa aparece na linguagem: quando a mesma palavra muda de significado, você cruzou uma fronteira. O mapa de contexto registra como os dois lados se relacionam.
Introdução#
No post anterior separei monorepo, monolito modular e microsserviços. Mas deixei uma pergunta aberta: onde traçar as fronteiras dos módulos?
A resposta mais comum é “por entidade”: módulo de Aluno, módulo de Turma, módulo de Pagamento. Isso quase sempre dá errado, porque entidades atravessam o negócio inteiro. O “Aluno” da secretaria, o da sala de aula e o do financeiro são três coisas diferentes com o mesmo nome.
O DDD estratégico existe para resolver exatamente isso. E é a parte do DDD que mais importa e menos se pratica: muita gente conhece Entity, Value Object e Repository (a parte tática) e nunca desenhou um mapa de contexto.
Vou usar três livros como base: o Evans (2003), que criou os conceitos; o Vernon (Implementing DDD e DDD Distilled), que os tornou praticáveis; e o Khononov (Learning Domain-Driven Design), que traz as heurísticas mais objetivas para decidir fronteiras. O domínio de exemplo é uma plataforma de gestão escolar multi-tenant.
Pré-requisitos#
- Ter lido o post anterior da série, ou estar confortável com a ideia de monolito modular
- Noção de DDD tático (entidade, agregado) ajuda, mas não é obrigatória
- JDK 21+ para rodar os exemplos em Kotlin (o Gradle vem pelo wrapper)
Conceito#
Espaço do problema vs. espaço da solução#
Essa separação vem do Vernon e organiza tudo o resto:
| Espaço do problema | Espaço da solução |
|---|---|
| Domínio: o negócio inteiro | Bounded context: fronteira onde um modelo vale |
| Subdomínio: uma área de atuação do negócio | Módulo / serviço: onde o bounded context vive no código |
| Você descobre | Você decide |
Subdomínios existem quer você modele ou não. Bounded contexts são uma decisão de design. O ideal é que um bounded context cubra um subdomínio, mas não é regra: um contexto pode cobrir vários subdomínios pequenos, e um subdomínio grande pode precisar de mais de um contexto.
Tipos de subdomínio#
O Evans já falava em core domain. O Khononov deixou a classificação mais operacional:
| Tipo | Característica | Estratégia |
|---|---|---|
| Core | Diferencia o negócio; complexo e mutável | Modelagem rica, time mais forte, código próprio |
| Supporting | Necessário, mas não diferencia; regras simples | Código próprio, mas simples (CRUD bem feito serve) |
| Generic | Problema resolvido; todo mundo tem igual | Comprar, usar SaaS ou biblioteca |
O ponto que muita gente perde: a classificação depende da estratégia do negócio, não da tecnologia. Faturamento é generic para a maioria das empresas e core para uma fintech.
Aplicando na plataforma escolar#
Suponha que a proposta de valor do produto seja acompanhamento pedagógico: o professor registra avaliações e frequência, e a plataforma mostra a evolução de cada aluno para coordenação e família.
| Subdomínio | Tipo | Por quê |
|---|---|---|
| Acompanhamento pedagógico (avaliações, frequência, evolução) | Core | É o que vende o produto |
| Matrícula e turmas | Supporting | Toda escola precisa; regras simples e estáveis |
| Comunicação com responsáveis | Supporting | Importante, mas não diferencia |
| Mensalidades e cobrança | Generic | Gateway de pagamento resolve |
| Identidade e acesso | Generic | Keycloak, Auth0, Cognito |
| Envio ao censo escolar | Supporting | Obrigatório, com regras impostas de fora |
Se o produto mudasse de estratégia e passasse a competir por gestão financeira das escolas, “mensalidades” viraria core e a tabela inteira mudaria. É por isso que essa conversa precisa acontecer com produto, não só com engenharia.
A linguagem denuncia a fronteira#
A linguagem ubíqua do Evans vale dentro de um bounded context. Quando a mesma palavra muda de significado, você cruzou uma fronteira. Pegue “aluno”:
- Na matrícula, aluno é alguém com documentos, responsáveis legais e uma matrícula num ano letivo.
- No acompanhamento pedagógico, aluno é alguém com notas, faltas e uma trajetória de aprendizagem.
- Nas mensalidades, “aluno” quase não existe: quem importa é o responsável financeiro, que paga e pode ter vários alunos.
Um modelo único de Aluno tentando servir os três vira aquela classe com 40 campos em que metade é nula dependendo de quem chama. Três modelos, um por contexto, ligados por um id, é o desenho certo.
Heurísticas para traçar fronteiras#
Do Khononov e da prática:
- Comece largo e estreite depois. Fronteiras largas erradas são baratas de corrigir; fronteiras estreitas erradas viram chamadas de rede e sagas. Isso vale principalmente para o core, onde o entendimento ainda está mudando.
- Siga a linguagem, não as entidades. Mudou o significado, mudou o contexto.
- Siga as invariantes. Regras que precisam ser verdade ao mesmo tempo (consistência forte) ficam no mesmo contexto.
- Olhe para quem muda junto. Se toda mudança em A exige mudança em B, A e B provavelmente são um contexto só.
- Considere os times. A lei de Conway não é sugestão: um contexto com dois times donos vira campo de batalha.
O mapa de contexto#
Depois de traçar as fronteiras, o mapa de contexto do Evans descreve como os contextos se relacionam. Os padrões mais úteis:
| Padrão | Quando usar |
|---|---|
| Customer/Supplier | Um contexto consome o outro, e o fornecedor considera as necessidades do cliente |
| Conformist | Você consome e não tem poder de negociar; aceita o modelo do outro como está |
| Anti-Corruption Layer (ACL) | Você consome um modelo ruim ou estranho e traduz na fronteira para não contaminar o seu |
| Open Host Service / Published Language | Você expõe uma interface estável e documentada para vários consumidores |
| Shared Kernel | Dois contextos compartilham um pedaço pequeno de modelo; use com muita parcimônia |
Dois detalhes desse mapa: o envio ao censo é conformist porque ninguém negocia layout com o governo; e as mensalidades usam uma ACL porque o modelo do gateway (cobranças, transações, webhooks) não deve vazar para dentro do contexto.
Mão na massa#
O código fica em Kotlin, num projeto Gradle com um pacote por contexto. O objetivo aqui é mostrar o efeito da fronteira no modelo, não um sistema completo.
Setup#
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modular/kotlin./gradlew testUm “aluno” por contexto#
package escola.matricula
import java.time.LocalDateimport java.util.UUID
@JvmInline value class EstudanteId(val valor: UUID)
data class Estudante( val id: EstudanteId, val nome: String, val dataNascimento: LocalDate, val responsaveisLegais: List<ResponsavelLegal>,)
data class ResponsavelLegal(val nome: String, val cpf: String, val parentesco: String)package escola.pedagogico
import java.util.UUID
// Mesmo id da matrícula, modelo completamente diferente@JvmInline value class AprendizId(val valor: UUID)
data class Aprendiz( val id: AprendizId, val nomeSocial: String, val avaliacoes: List<Avaliacao>, val faltas: Int,) { // Sem avaliações não existe média: null, em vez do NaN de 0.0 / 0.0 fun mediaPonderada(): Double? = if (avaliacoes.isEmpty()) null else avaliacoes.sumOf { it.nota * it.peso } / avaliacoes.sumOf { it.peso }}
data class Avaliacao(val componente: String, val nota: Double, val peso: Double)Repare que o contexto pedagógico nem usa a palavra “estudante”. Os nomes diferentes são intencionais: deixam claro, no código, que são modelos diferentes. Nenhum dos dois conhece CPF de responsável ou nota de prova do outro.
Published Language entre matrícula e pedagógico#
O pedagógico precisa saber que alguém foi matriculado numa turma. Em vez de consultar o modelo da matrícula, ele recebe um evento com contrato estável:
package escola.matricula.api
import java.util.UUID
// Contrato publicado: mudanças aqui são versionadas, nunca quebradasdata class MatriculaConfirmada( val estudanteId: UUID, val turmaId: UUID, val anoLetivo: Int, val nomeSocial: String,)package escola.pedagogico
import escola.matricula.api.MatriculaConfirmada
class AoConfirmarMatricula(private val aprendizes: Aprendizes) { fun handle(evento: MatriculaConfirmada) { aprendizes.salvar( Aprendiz( id = AprendizId(evento.estudanteId), nomeSocial = evento.nomeSocial, avaliacoes = emptyList(), faltas = 0, ) ) }}Aprendizes é a porta de persistência do contexto pedagógico, uma interface com salvar. O pedagógico guarda a sua própria cópia do que precisa. Isso é duplicação proposital: troca consistência imediata por autonomia.
Anti-Corruption Layer para o gateway#
O gateway devolve status no vocabulário dele. A ACL traduz para o vocabulário do contexto de mensalidades, e nada do gateway passa daqui:
package escola.mensalidades.infra
import escola.mensalidades.SituacaoMensalidade
// Modelo do fornecedor, exatamente como chega no webhookdata class GatewayChargeWebhook(val id: String, val status: String, val paidAmountCents: Long?)
object GatewayAcl { fun traduzir(webhook: GatewayChargeWebhook): SituacaoMensalidade = when (webhook.status) { "PAID", "RECEIVED", "CONFIRMED" -> SituacaoMensalidade.Paga "OVERDUE" -> SituacaoMensalidade.Atrasada "REFUNDED", "CHARGEBACK" -> SituacaoMensalidade.Estornada "PENDING" -> SituacaoMensalidade.EmAberto else -> SituacaoMensalidade.Desconhecida(webhook.status) }}package escola.mensalidades
sealed interface SituacaoMensalidade { data object EmAberto : SituacaoMensalidade data object Paga : SituacaoMensalidade data object Atrasada : SituacaoMensalidade data object Estornada : SituacaoMensalidade data class Desconhecida(val statusOriginal: String) : SituacaoMensalidade}Se amanhã você trocar de gateway, só a ACL muda. O Desconhecida existe porque gateway adiciona status sem avisar, e é melhor registrar do que quebrar.
Testando#
package escola.mensalidades.infra
import escola.mensalidades.SituacaoMensalidadeimport kotlin.test.Testimport kotlin.test.assertEquals
class GatewayAclTest { @Test fun `status desconhecido nao quebra a traducao`() { val situacao = GatewayAcl.traduzir(GatewayChargeWebhook("ch_1", "AWAITING_RISK_ANALYSIS", null)) assertEquals(SituacaoMensalidade.Desconhecida("AWAITING_RISK_ANALYSIS"), situacao) }}./gradlew testNo repositório, o mesmo arquivo cobre os outros status do gateway, e um teste do AoConfirmarMatricula confere que o evento vira um Aprendiz com o mesmo id e sem histórico.
O teste mais importante do DDD estratégico, porém, não roda no Gradle: é mostrar o mapa de contexto para alguém de produto e perguntar “isso descreve o negócio?”. Se a resposta for “mais ou menos”, as fronteiras ainda não estão certas.
Armadilhas e trade-offs#
Resumo#
| Conceito | O que é | Pergunta que responde |
|---|---|---|
| Subdomínio | Área do negócio (problema) | O que o negócio faz? |
| Core / supporting / generic | Classificação estratégica | Onde investir? |
| Bounded context | Fronteira de um modelo (solução) | Onde esse modelo vale? |
| Linguagem ubíqua | Vocabulário dentro de um contexto | O que essa palavra significa aqui? |
| Mapa de contexto | Relações entre contextos | Quem depende de quem, e como? |
| ACL | Tradução na fronteira | Como não me contaminar com o modelo alheio? |
Conclusão#
DDD estratégico é menos sobre código e mais sobre conversa: descobrir subdomínios, perceber onde a linguagem muda e registrar como os contextos se relacionam. O código só materializa essas decisões.
Mas decisões de fronteira que vivem só no diagrama se perdem no primeiro prazo apertado. No próximo post da série, mostro como fazer o build quebrar quando alguém cruza uma fronteira, em Python, Java (Spring Modulith) e Go.
Código no GitHub#
O código deste artigo está no GitHubdev-danilocordeiro/monolito-modularReferências
- Domain-Driven Design — Eric Evans (2003) domainlanguage.com
- Implementing Domain-Driven Design — Vaughn Vernon (2013) informit.com
- Domain-Driven Design Distilled — Vaughn Vernon (2016) informit.com
- Learning Domain-Driven Design — Vlad Khononov (2021) oreilly.com
- DDD Crew — Context Mapping github.com
Encontrou um erro? Me avise por e-mail