11 min read
Domain Design2,410 words

DDD Strategic Design & Bounded Contexts

What this is: a senior/staff-level guide to the most important part of Domain-Driven Design — strategic design (ubiquitous language, subdomains, bounded contexts, context mapping) — and how to actually find and enforce boundaries, with Spring Boot code in Kotlin and Java.


0. Mental model#

One model per boundary; language defines the boundary.

A single unified model of a whole business is impossible — "Product" means different things to Sales, Inventory, and Shipping. Forcing one shared model produces the big ball of mud: bloated classes, nullable fields, conflicting invariants. DDD's answer: split the system into bounded contexts, each with its own model that is internally consistent and expressed in a ubiquitous language where every term has exactly one meaning. Duplication of concepts across contexts is deliberate and healthy; ambiguity inside a context is the disease.

Everything else in DDD derives from this: tactical patterns (aggregates, entities, value objects) only make sense inside a context; context maps and anti-corruption layers manage the edges. Evans has said that if he rewrote the 2003 book he'd put strategic design first — get boundaries wrong and no aggregate design saves you.


1. Problem space vs. solution space#

Interviewers love this distinction; most candidates blur it.

ConceptSpaceWhat it isDecided by
SubdomainProblemAn area of the business that simply exists (pricing, fraud, onboarding)The business — you discover it
Bounded contextSolutionA boundary you design, inside which one model + one language holdsYou — an engineering decision

Subdomain types drive investment strategy:

TypeDefinitionStrategyExample (e-commerce)
CoreWhere the company competes and differentiatesBest engineers, deepest modeling, build in-houseRecommendation engine, dynamic pricing
SupportingNecessary, business-specific, not differentiatingBuild simply, don't gold-plate; CRUD is fineVendor onboarding
GenericSolved problems, same for everyoneBuy or adopt off-the-shelfAuth, invoicing, email delivery

Ideal: one bounded context per subdomain — but 1:1 is a goal, not a law. Legacy systems often have one context spanning several subdomains (a known smell) or a subdomain split across contexts.

Interview trap: "microservice = bounded context" is wrong by default. A bounded context is a model boundary; a microservice is a deployment boundary. One context can be one service, several services, or a module in a monolith (modular monolith / Spring Modulith). A context is the largest sensible service boundary — never split a context mid-model just because a service "feels big."


2. Ubiquitous language#

The shared, rigorous vocabulary developed jointly by developers and domain experts, used in speech, docs, tests, and code identifiers. It is scoped to one context — there is no company-wide ubiquitous language.

Practical rules:

  • If the expert says "policy is bound," the code says bindPolicy(), not updateStatus(7).
  • Two meanings for one word → two contexts trying to happen. Two words for one meaning → merge them or find the hidden distinction.
  • The language is discovered iteratively; when the model and the experts' language diverge, refactor the code to follow the language (Evans calls this the model and implementation staying "bound").
  • Watch for translation fatigue in meetings — if devs mentally translate expert terms into "our entities," the language has died.

Kotlin

kotlin
// Catalog context — "Product" is marketing copy + price class Product( val sku: Sku, var displayName: String, var listPrice: Money, ) { fun applyPromotion(promo: Promotion): DiscountedPrice = promo.applyTo(listPrice) }

Java

java
// Fulfillment context — "Product" is a thing with mass in a warehouse public class Product { private final Sku sku; private final Weight weight; private final Dimensions dimensions; public PackingRequirement packingRequirement() { return PackingRequirement.forFragility(dimensions, weight); } }

Same word, two contexts, two deliberately different models — neither has the other's fields. That is the point.


3. What a bounded context owns#

A well-formed context has:

  • One model, one language — internally unambiguous.
  • One owning team — a context ownable by exactly one team (Conway alignment). Two teams inside one context = constant merge conflicts and model fights.
  • Its own persistence — no other context reads/writes its tables. Shared database = shared model = boundary is fiction.
  • An explicit contract at the edge — published API, event schema, or both. Internals are free to change; the contract is versioned and stable.
  • Its own consistency rules — transactions live inside a context; across contexts you get eventual consistency (sagas, outbox → see saga-pattern-study-guide.md, inbox-outbox-pattern.md).

4. How to find the boundaries#

No algorithm exists; these heuristics converge. Use several, expect them to agree.

4.1 Follow the language (primary signal)#

Sit with domain experts. Fault lines appear as: the same word with different meanings per department ("account" in fraud vs. billing), different words for the same thing, or a term one group uses that another has never heard.

4.2 Event Storming (the workshop that operationalizes it)#

Alberto Brandolini's technique — the de-facto standard for boundary discovery:

  1. Everyone (experts + devs) puts domain events (orange stickies, past tense: OrderPlaced, PaymentCaptured, ShipmentDispatched) on a long wall, in rough timeline order.
  2. Add commands (blue: PlaceOrder), actors, policies ("whenever X then Y"), read models, and hotspots (red: conflicts/unknowns).
  3. Boundaries emerge visually:
    • Pivotal events — points where the vocabulary shifts ("Order" talk becomes "Shipment" talk) mark context borders.
    • Clusters of events sharing language and actors = candidate contexts.
    • Hotspot pileups often mean two contexts are fighting inside one model.
  4. Group event clusters into candidate bounded contexts; sanity-check with the other heuristics below.

4.3 Business capabilities#

Boundaries around what the business does (pricing, risk assessment, fulfillment), not around data ("Order service" that everyone writes to recreates the shared model with HTTP in the middle).

4.4 Conway's Law / Team Topologies#

One context ↔ one stream-aligned team. If every change inside a "context" needs two teams to coordinate, the boundary is wrong. Modern practice (Team Topologies, Skelton & Pais 2019) deliberately shapes teams and contexts together.

4.5 Consistency & change pressure#

Things that must be transactionally consistent and change together belong together. Areas evolving at different speeds for different reasons (regulatory vs. UX experimentation) should be separated even if they share nouns.

4.6 Classify subdomains first#

Knowing what's core tells you where finer-grained, carefully modeled contexts pay off, and where one coarse supporting context is fine.

Litmus tests for a proposed boundary

  • Can one team own it end to end?
  • Can you state its contract in one sentence?
  • Does any word inside it have two meanings? (Fail → split.)
  • Would two of them share a database table? (Fail → merge or redesign.)
  • Do most use cases complete inside it without synchronous calls to another context? (Chatty sync edges → boundary probably cuts through a use case.)

5. Context mapping — relationships between contexts#

Boundaries are half the job; the map of relationships is the other half. Power dynamics (who conforms to whom) matter more than the arrows.

PatternRelationshipUse whenWatch out
PartnershipTwo teams succeed/fail together, coordinate releasesGenuinely intertwined outcomesExpensive; needs real mutual commitment
Shared KernelSmall shared model/code owned jointlyTiny, stable overlap (e.g., shared Money)Classic trap — grows until contexts re-fuse; keep minimal or avoid
Customer–SupplierUpstream plans around downstream's needsDownstream has leverage (internal teams)Needs real negotiation channels
ConformistDownstream adopts upstream model as-isUpstream won't budge (big vendor) and its model is good enoughYou import their language debt
Anti-Corruption Layer (ACL)Downstream translates upstream model at the edgeUpstream is legacy/messy/foreign — default defensive stanceTranslation code is real work; budget it
Open Host Service (OHS)Upstream offers a stable public protocolMany downstream consumers
Published LanguageShared, well-documented interchange schema (often with OHS)Ecosystem integration (e.g., industry schemas)Schema governance
Separate WaysNo integration at allIntegration cost > valueRevisit periodically

The ACL deserves emphasis — it's the pattern interviewers probe. Rule: never let a foreign model leak into your domain; translate at the edge.

Kotlin

kotlin
// ACL: legacy billing system's model stops here @Component class LegacyBillingAdapter( private val client: LegacyBillingClient, ) : PaymentStatusPort { // port defined by OUR domain override fun statusOf(orderId: OrderId): PaymentStatus { val dto = client.fetchRecord(orderId.value) // their shape return when (dto.stateCode) { // translate → our language "OK", "SETTLED_PARTIAL" -> PaymentStatus.CAPTURED "PEND" -> PaymentStatus.PENDING else -> PaymentStatus.FAILED } } }

Java

java
@Component public class LegacyBillingAdapter implements PaymentStatusPort { private final LegacyBillingClient client; public LegacyBillingAdapter(LegacyBillingClient client) { this.client = client; } @Override public PaymentStatus statusOf(OrderId orderId) { LegacyBillingRecordDto dto = client.fetchRecord(orderId.value()); return switch (dto.stateCode()) { // switch expressions: Java 14+ case "OK", "SETTLED_PARTIAL" -> PaymentStatus.CAPTURED; case "PEND" -> PaymentStatus.PENDING; default -> PaymentStatus.FAILED; }; } }

Ports-and-adapters (hexagonal) is the natural implementation vehicle: the domain defines the port; the ACL is just an adapter that translates.


6. Enforcing boundaries in Spring Boot#

A boundary that isn't enforced erodes. Options, monolith → distributed:

Modular monolith (often the right start). One deployable, one context per top-level package/module. Spring Modulith (1.x, GA since 2023 — verify current version) verifies module boundaries at test time and publishes application events between modules:

Kotlin

kotlin
// build: org.springframework.modulith:spring-modulith-starter-core // packages: com.shop.catalog, com.shop.orders, com.shop.fulfillment ← each = a context @ApplicationModuleTest class ModularityTests { @Test fun verifiesModuleStructure() = ApplicationModules.of(ShopApplication::class.java).verify() // fails build on illegal cross-module access }

Java

java
@Test void verifiesModuleStructure() { ApplicationModules.of(ShopApplication.class).verify(); }

Without Modulith, ArchUnit rules ("no class in orders.. may depend on fulfillment.internal..") do the same job.

Cross-context communication — prefer events. Inside a monolith: application events + Modulith's event publication log (transactional, replayable). Across services: integration events on Kafka with a schema-registry contract, via the outbox pattern (see inbox-outbox-pattern.md). Key nuance: the integration event is a published contract, not your internal domain event — translate before publishing (an outbound ACL), or your internals become everyone's dependency.

Kotlin

kotlin
// Orders context publishes; Fulfillment context listens — no direct code dependency @Service class OrderService( private val orderRepository: OrderRepository, private val events: ApplicationEventPublisher, ) { @Transactional fun place(order: Order) { orderRepository.save(order) events.publishEvent(OrderPlaced(order.id.value, order.lines.map { it.toContract() })) } } // in fulfillment module @Component class OrderPlacedHandler { @ApplicationModuleListener // Modulith: async, transactional, persisted fun on(event: OrderPlaced) { /* start fulfillment in OUR model */ } }

Java

java
@Service public class OrderService { private final ApplicationEventPublisher events; // ... @Transactional public void place(Order order) { orderRepository.save(order); events.publishEvent(new OrderPlaced(order.id().value(), toContractLines(order))); } } @Component class OrderPlacedHandler { @ApplicationModuleListener void on(OrderPlaced event) { /* start fulfillment in OUR model */ } }

Separate services. Each context = deployable with its own database/schema; contracts via OHS (REST/gRPC + versioned API) and/or published event schemas. Extract from the modular monolith only when a context needs independent scaling, deployment cadence, or team autonomy — extraction is cheap precisely because the boundary already exists.


7. Problems & how to solve them#

Problem: "entity services" — UserService, OrderService, ProductService, everyone calls everyone. Root cause: boundaries drawn around nouns/data instead of capabilities; the shared model survived, now with network latency. Fix: re-cut around business capabilities via Event Storming; each context gets its own view of User/Order/Product.

Problem: two services, one database. Root cause: extraction stopped at the code; the model is still fused. Fix: split schemas, give each context private tables, integrate via events/APIs; until then admit it's one context and drop the operational cost of pretending otherwise.

Problem: shared "domain" library (common-model.jar) used by all services. Root cause: DRY instinct applied across contexts — a Shared Kernel nobody agreed to. Fix: shrink it to truly context-free values (Money, Sku at most); each context re-models its own concepts. Duplication across contexts is cheaper than coupling.

Problem: enterprise-wide "canonical data model" mandate. Root cause: belief that one master model reduces integration cost; in practice it's the big ball of mud with governance. Fix: published languages per integration surface + ACLs per consumer; canonical models only for narrow interchange (e.g., regulatory schemas).

Problem: microservice-per-noun, 40 nanoservices, every use case is a distributed transaction. Root cause: context ≠ service confusion; boundaries cut through use cases. Fix: merge back to context-sized services (or a modular monolith); a use case should mostly complete inside one context.

Problem: model rots — code terms drift from what experts say. Root cause: language treated as a kickoff artifact, not a living one. Fix: continuous collaboration (regular expert contact, glossary in the repo, rename refactorings when language shifts); tests written in domain terms act as tripwires.

Problem: legacy system's concepts keep leaking into the new model. Root cause: no ACL — "we'll just reuse their DTOs for now." Fix: hard rule — foreign models die at the adapter. Budget ACL work explicitly during any strangler-fig migration.

Problem: boundaries "agreed on the whiteboard" but any class can import any other. Root cause: no enforcement. Fix: Spring Modulith verify() or ArchUnit in CI; module-private packages; separate Gradle/Maven modules per context.


8. Interview framing — how to sound senior#

  • Lead with the asymmetry: "Strategic beats tactical — you can survive mediocre aggregates inside right boundaries, but not perfect aggregates inside wrong ones."
  • Define crisply: "A bounded context is the boundary within which a model and its language are consistent — it's a solution-space decision, distinct from subdomains, which are problem-space facts."
  • Name your discovery method: "I'd run Event Storming and look for pivotal events and language breaks, then sanity-check against team ownership and consistency requirements."
  • Decouple contexts from deployment: "Bounded context is a model boundary; whether it's a module or a microservice is a separate, later decision. I'd start with a modular monolith and enforce boundaries with Spring Modulith."
  • Show edge discipline: "Any foreign model gets translated at an ACL; internally we publish domain events, externally versioned integration events — the contract, not the internals."
  • Defend duplication: "Sales' Product and Fulfillment's Product sharing a class is coupling, not reuse. Duplication across contexts is a feature."
  • Know the map patterns and the power dynamics (who conforms to whom) — that's what distinguishes reading the book from having lived it.

9. Cheat-sheet#

TermOne-liner
Strategic designThe "where are the boundaries + how do they relate" half of DDD; the important half
Ubiquitous languageShared expert+dev vocabulary, one meaning per term, used in code — scoped to one context
SubdomainProblem-space area of the business (core / supporting / generic)
Bounded contextSolution-space boundary where one model + language holds; unit of team + data ownership
Core domainWhere you differentiate → deepest modeling, best people
Event StormingWall-of-stickies workshop; boundaries appear at pivotal events / language breaks
Context mapDiagram of contexts + relationship patterns (incl. power direction)
ACLAdapter translating a foreign model at your edge; default when consuming legacy
OHS / Published LanguageStable public protocol + schema for many consumers
Shared KernelJointly owned shared model — keep tiny or avoid
ConformistAdopt upstream's model wholesale when it won't budge
Modular monolithOne deployable, context-per-module; Spring Modulith/ArchUnit enforce edges
Context ≠ microserviceModel boundary vs. deployment boundary; 1:1 optional, splitting a context is wrong

Lineage: Evans, Domain-Driven Design (2003); Vernon, Implementing DDD (2013); Brandolini, Introducing EventStorming; Khononov, Learning DDD (2021); Skelton & Pais, Team Topologies (2019).


10. Self-test#

  1. Subdomain vs. bounded context — which is problem space, which is solution space, and who decides each?
  2. Sales and Warehouse both need "Product." One class or two? Defend the duplication to a DRY-zealot reviewer.
  3. In an Event Storming session, what visual signals mark a context boundary? What's a pivotal event?
  4. Your team consumes a 15-year-old ERP's SOAP API. Which context-map pattern, and what does its Spring implementation look like?
  5. When is Conformist the right choice over an ACL?
  6. Why is a shared common-domain library across services an anti-pattern, and what's the acceptable residue of one?
  7. Two candidate contexts would need a synchronous call in the middle of every checkout. What does that tell you, and what are your options?
  8. How do you enforce module boundaries in a Spring Boot modular monolith — name two mechanisms.
  9. Your internal OrderPlaced domain event has 30 fields; downstream teams want it. What do you publish and why not the event itself?
  10. Argue both sides: "every bounded context should be its own microservice."