Advanced8 min readSeries: Modular Monolith in Practice

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

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 spaceSolution space
Domain: the whole businessBounded context: the boundary where a model holds
Subdomain: one area the business operates inModule / service: where the bounded context lives in code
You discover itYou 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:

TypeCharacteristicsStrategy
CoreSets the business apart; complex and changingRich modeling, strongest team, in-house code
SupportingNecessary, but doesn’t set you apart; simple rulesIn-house code, but simple (well-done CRUD is fine)
GenericSolved problem; everyone has the sameBuy 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.

SubdomainTypeWhy
Pedagogical tracking (assessments, attendance, progress)CoreIt’s what sells the product
Enrollment and classesSupportingEvery school needs it; simple, stable rules
Communication with parentsSupportingImportant, but doesn’t set you apart
Tuition and billingGenericA payment gateway solves it
Identity and accessGenericKeycloak, Auth0, Cognito
School census submissionSupportingMandatory, 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:

  1. 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.
  2. Follow the language, not the entities. The meaning changed, the context changed.
  3. Follow the invariants. Rules that have to be true at the same time (strong consistency) stay in the same context.
  4. Look at what changes together. If every change in A requires a change in B, A and B are probably one context.
  5. 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:

PatternWhen to use it
Customer/SupplierOne context consumes the other, and the supplier takes the customer’s needs into account
ConformistYou 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 LanguageYou expose a stable, documented interface for several consumers
Shared KernelTwo 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#

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

One “student” per context#

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)

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:

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 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:

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
}

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#

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

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

ConceptWhat it isQuestion it answers
SubdomainAn area of the business (problem)What does the business do?
Core / supporting / genericStrategic classificationWhere do we invest?
Bounded contextBoundary of a model (solution)Where does this model hold?
Ubiquitous languageVocabulary inside a contextWhat does this word mean here?
Context mapRelationships between contextsWho depends on whom, and how?
ACLTranslation at the boundaryHow 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-modular

References

  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

Found a mistake? Let me know by e-mail