NOVO Série Monolito Modular na Prática no ar

Avançado8 min de leituraSérie: Monolito Modular na Prática

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

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 problemaEspaço da solução
Domínio: o negócio inteiroBounded context: fronteira onde um modelo vale
Subdomínio: uma área de atuação do negócioMódulo / serviço: onde o bounded context vive no código
Você descobreVocê 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:

TipoCaracterísticaEstratégia
CoreDiferencia o negócio; complexo e mutávelModelagem rica, time mais forte, código próprio
SupportingNecessário, mas não diferencia; regras simplesCódigo próprio, mas simples (CRUD bem feito serve)
GenericProblema resolvido; todo mundo tem igualComprar, 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ínioTipoPor quê
Acompanhamento pedagógico (avaliações, frequência, evolução)CoreÉ o que vende o produto
Matrícula e turmasSupportingToda escola precisa; regras simples e estáveis
Comunicação com responsáveisSupportingImportante, mas não diferencia
Mensalidades e cobrançaGenericGateway de pagamento resolve
Identidade e acessoGenericKeycloak, Auth0, Cognito
Envio ao censo escolarSupportingObrigató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:

  1. 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.
  2. Siga a linguagem, não as entidades. Mudou o significado, mudou o contexto.
  3. Siga as invariantes. Regras que precisam ser verdade ao mesmo tempo (consistência forte) ficam no mesmo contexto.
  4. 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ó.
  5. 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ãoQuando usar
Customer/SupplierUm contexto consome o outro, e o fornecedor considera as necessidades do cliente
ConformistVocê 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 LanguageVocê expõe uma interface estável e documentada para vários consumidores
Shared KernelDois 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#

Terminal window
git clone https://github.com/dev-danilocordeiro/monolito-modular
cd monolito-modular/kotlin
./gradlew test

Um “aluno” por contexto#

matricula/Estudante.kt
package escola.matricula
import java.time.LocalDate
import 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)
pedagogico/Aprendiz.kt
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:

matricula/api/EventosDeMatricula.kt
package escola.matricula.api
import java.util.UUID
// Contrato publicado: mudanças aqui são versionadas, nunca quebradas
data class MatriculaConfirmada(
val estudanteId: UUID,
val turmaId: UUID,
val anoLetivo: Int,
val nomeSocial: String,
)
pedagogico/AoConfirmarMatricula.kt
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:

mensalidades/infra/GatewayAcl.kt
package escola.mensalidades.infra
import escola.mensalidades.SituacaoMensalidade
// Modelo do fornecedor, exatamente como chega no webhook
data 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)
}
}
mensalidades/SituacaoMensalidade.kt
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#

mensalidades/infra/GatewayAclTest.kt
package escola.mensalidades.infra
import escola.mensalidades.SituacaoMensalidade
import kotlin.test.Test
import 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)
}
}
Terminal window
./gradlew test

No 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#

ConceitoO que éPergunta que responde
SubdomínioÁrea do negócio (problema)O que o negócio faz?
Core / supporting / genericClassificação estratégicaOnde investir?
Bounded contextFronteira de um modelo (solução)Onde esse modelo vale?
Linguagem ubíquaVocabulário dentro de um contextoO que essa palavra significa aqui?
Mapa de contextoRelações entre contextosQuem depende de quem, e como?
ACLTradução na fronteiraComo 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-modular

Referências

  1. Domain-Driven Design — Eric Evans (2003) domainlanguage.com
  2. Implementing Domain-Driven Design — Vaughn Vernon (2013) informit.com
  3. Domain-Driven Design Distilled — Vaughn Vernon (2016) informit.com
  4. Learning Domain-Driven Design — Vlad Khononov (2021) oreilly.com
  5. DDD Crew — Context Mapping github.com

Encontrou um erro? Me avise por e-mail