Splitting domains with strategic DDD: subdomains, bounded contexts and the context map
Before you think about modules or services, you need to know where the boundaries are. A practical guide using Evans, Vernon and Khononov, applied to a school platform.
Published · Ler em português
- #architecture
- #ddd
- #modular-monolith
- #kotlin
In this post
- 1Introduction
- 2Prerequisites
- 3Concept
- 3.1Problem space vs. solution space
- 3.2Types of subdomain
- 3.3Applying it to the school platform
- 3.4The language gives the boundary away
- 3.5Heuristics for drawing boundaries
- 3.6The context map
- 4Hands-on
- 4.1Setup
- 4.2One “student” per context
- 4.3Published Language between enrollment and pedagogical tracking
- 4.4Anti-Corruption Layer for the gateway
- 5Testing
- 6Pitfalls and trade-offs
- 7Summary
- 8Conclusion
- 9Code on GitHub
TL;DR
- A subdomain is a piece of the business problem; a bounded context is the boundary of a model in the solution. Mixing the two up is the most common mistake of people who “apply DDD”.
- Classify subdomains as core, supporting and generic: that decides where you invest in rich modeling and where you buy off the shelf.
- The right boundary shows up in the language: when the same word changes meaning, you’ve crossed a boundary. The context map records how the two sides relate.
Introduction#
In the previous post I pulled apart monorepo, modular monolith and microservices. But I left a question open: where do you draw the module boundaries?
The most common answer is “by entity”: a Student module, a Class module, a Payment module. That almost always goes wrong, because entities cut across the whole business. The “Student” of the school office, the one in the classroom and the one in billing are three different things with the same name.
Strategic DDD exists to solve exactly that. And it’s the part of DDD that matters most and gets practiced least: plenty of people know Entity, Value Object and Repository (the tactical part) and have never drawn a context map.
I’ll use three books as the foundation: Evans (2003), who created the concepts; Vernon (Implementing DDD and DDD Distilled), who made them practical; and Khononov (Learning Domain-Driven Design), who brings the most objective heuristics for deciding boundaries. The example domain is a multi-tenant school management platform.
Prerequisites#
- Having read the previous post in the series, or being comfortable with the idea of a modular monolith
- Some notion of tactical DDD (entity, aggregate) helps, but isn’t required
- JDK 21+ to run the Kotlin examples (Gradle comes through the wrapper)
Concept#
Problem space vs. solution space#
This split comes from Vernon and organizes everything else:
| Problem space | Solution space |
|---|---|
| Domain: the whole business | Bounded context: the boundary where a model holds |
| Subdomain: one area the business operates in | Module / service: where the bounded context lives in code |
| You discover it | You decide it |
Subdomains exist whether you model them or not. Bounded contexts are a design decision. Ideally one bounded context covers one subdomain, but that’s not a rule: one context can cover several small subdomains, and a large subdomain may need more than one context.
Types of subdomain#
Evans already talked about the core domain. Khononov made the classification more operational:
| Type | Characteristics | Strategy |
|---|---|---|
| Core | Sets the business apart; complex and changing | Rich modeling, strongest team, in-house code |
| Supporting | Necessary, but doesn’t set you apart; simple rules | In-house code, but simple (well-done CRUD is fine) |
| Generic | Solved problem; everyone has the same | Buy it, use a SaaS or a library |
The point many people miss: the classification depends on the business strategy, not on the technology. Billing is generic for most companies and core for a fintech.
Applying it to the school platform#
Suppose the product’s value proposition is pedagogical tracking: teachers record assessments and attendance, and the platform shows each student’s progress to the school’s coordinators and to families.
| Subdomain | Type | Why |
|---|---|---|
| Pedagogical tracking (assessments, attendance, progress) | Core | It’s what sells the product |
| Enrollment and classes | Supporting | Every school needs it; simple, stable rules |
| Communication with parents | Supporting | Important, but doesn’t set you apart |
| Tuition and billing | Generic | A payment gateway solves it |
| Identity and access | Generic | Keycloak, Auth0, Cognito |
| School census submission | Supporting | Mandatory, with rules imposed from outside |
The school census (Censo Escolar) is the yearly data report every Brazilian school must send to the federal government, in a layout the government defines. If the product changed strategy and started competing on schools’ financial management, “tuition” would become core and the whole table would change. That’s why this conversation has to happen with product, not just engineering.
The language gives the boundary away#
Evans’s ubiquitous language holds inside a bounded context. When the same word changes meaning, you’ve crossed a boundary. Take “student”:
- In enrollment, a student is someone with documents, legal guardians and an enrollment in a school year.
- In pedagogical tracking, a student is someone with grades, absences and a learning path.
- In tuition, the “student” barely exists: the one who matters is the financial guardian, who pays and may have several students.
A single Student model trying to serve all three turns into that class with 40 fields, half of them null depending on who’s calling. Three models, one per context, linked by an id, is the right design.
Heuristics for drawing boundaries#
From Khononov and from practice:
- Start wide and narrow later. Wrong wide boundaries are cheap to fix; wrong narrow boundaries turn into network calls and sagas. This applies especially to the core, where the understanding is still shifting.
- Follow the language, not the entities. The meaning changed, the context changed.
- Follow the invariants. Rules that have to be true at the same time (strong consistency) stay in the same context.
- Look at what changes together. If every change in A requires a change in B, A and B are probably one context.
- Consider the teams. Conway’s law isn’t a suggestion: a context with two owning teams becomes a battlefield.
The context map#
Once the boundaries are drawn, Evans’s context map describes how the contexts relate. The most useful patterns:
| Pattern | When to use it |
|---|---|
| Customer/Supplier | One context consumes the other, and the supplier takes the customer’s needs into account |
| Conformist | You consume and have no leverage to negotiate; you accept the other model as it is |
| Anti-Corruption Layer (ACL) | You consume a bad or foreign model and translate at the boundary so it doesn’t contaminate yours |
| Open Host Service / Published Language | You expose a stable, documented interface for several consumers |
| Shared Kernel | Two contexts share a small piece of model; use it very sparingly |
Two details in this map: the census submission is conformist because nobody negotiates a layout with the government; and tuition uses an ACL because the gateway’s model (charges, transactions, webhooks) shouldn’t leak into the context.
Hands-on#
The code is in Kotlin, in a Gradle project with one package per context. The goal here is to show the effect of the boundary on the model, not a complete system.
The identifiers are in Portuguese, as in the repository. The ones that matter here: matricula is enrollment, pedagogico is pedagogical tracking, mensalidades is tuition, Estudante is student, Aprendiz is learner, Avaliacao is assessment and SituacaoMensalidade is the tuition payment status.
Setup#
git clone https://github.com/dev-danilocordeiro/monolito-modularcd monolito-modular/kotlin./gradlew testOne “student” per context#
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)Notice that the pedagogical context doesn’t even use the word “student” (Estudante); it says “learner” (Aprendiz). The different names are intentional: they make it clear, in the code, that these are different models. Neither knows about the other’s data: the guardian’s CPF (the Brazilian individual taxpayer ID) stays in enrollment, and test grades stay in pedagogical tracking.
Published Language between enrollment and pedagogical tracking#
Pedagogical tracking needs to know that someone was enrolled in a class. Instead of querying the enrollment model, it receives an event with a stable contract:
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 is the pedagogical context’s persistence port, an interface with salvar (save). Pedagogical tracking keeps its own copy of what it needs. That’s deliberate duplication: it trades immediate consistency for autonomy.
Anti-Corruption Layer for the gateway#
The gateway returns statuses in its own vocabulary. The ACL translates them into the tuition context’s vocabulary, and nothing from the gateway gets past it:
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}If you switch gateways tomorrow, only the ACL changes. Desconhecida (unknown) exists because gateways add statuses without warning, and it’s better to record one than to break.
Testing#
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 testIn the repository, the same file covers the gateway’s other statuses, and a test for AoConfirmarMatricula checks that the event becomes an Aprendiz with the same id and no history.
The most important test of strategic DDD, though, doesn’t run in Gradle: it’s showing the context map to someone from product and asking “does this describe the business?”. If the answer is “sort of”, the boundaries aren’t right yet.
Pitfalls and trade-offs#
Summary#
| Concept | What it is | Question it answers |
|---|---|---|
| Subdomain | An area of the business (problem) | What does the business do? |
| Core / supporting / generic | Strategic classification | Where do we invest? |
| Bounded context | Boundary of a model (solution) | Where does this model hold? |
| Ubiquitous language | Vocabulary inside a context | What does this word mean here? |
| Context map | Relationships between contexts | Who depends on whom, and how? |
| ACL | Translation at the boundary | How do I keep someone else’s model out of mine? |
Conclusion#
Strategic DDD is less about code and more about conversation: discovering subdomains, noticing where the language changes and recording how the contexts relate. The code just materializes those decisions.
But boundary decisions that only live in a diagram get lost at the first tight deadline. In the next post in the series, I show how to make the build break when someone crosses a boundary, in Python, Java (Spring Modulith) and Go.
Code on GitHub#
The code for this post is on GitHubdev-danilocordeiro/monolito-modularReferences
- 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
Found a mistake? Let me know by e-mail