Domain-Driven Design: Bounded Contexts in Practice
Learn how to implement bounded contexts in Domain-Driven Design. Practical guide covering context mapping, aggregates, domain events, and real examples from enterprise projects.
Introduction
Domain-Driven Design (DDD) remains one of the most powerful tools for tackling complexity in enterprise software. Yet, the concept of Bounded Contexts—arguably DDD's most valuable contribution—is frequently misunderstood or poorly implemented.
After applying DDD principles across healthcare software systems, government service platforms, and enterprise logistics systems, I've developed a practical approach to bounded context design that balances theoretical purity with real-world constraints.
What is a Bounded Context?
A Bounded Context is a semantic boundary within which a particular domain model applies. Inside this boundary, terms have specific, unambiguous meanings. Outside, the same terms might mean something completely different.
Consider the term "Patient" in a hospital system:
- In the Clinical Context, a Patient has medical history, diagnoses, and treatment plans
- In the Billing Context, a Patient has insurance information, payment history, and outstanding balances
- In the Scheduling Context, a Patient has appointment preferences and availability constraints
These aren't three views of the same model—they're three distinct models that happen to share a name.
Identifying Bounded Contexts
The hardest part of DDD is knowing where to draw the boundaries. Here's my battle-tested process:
Step 1: Event Storming
Gather domain experts and developers. Map out the domain events (things that happen) on a timeline.
Timeline of Healthcare Visit:
[Patient Registered] → [Appointment Scheduled] → [Patient Checked In] →
[Vitals Recorded] → [Consultation Started] → [Diagnosis Made] →
[Prescription Written] → [Visit Completed] → [Invoice Generated] →
[Payment Received]
Step 2: Identify Language Clusters
Group events by the language used to describe them. When domain experts start using different terminology for similar concepts, you've likely found a boundary.
// Clinical Language Cluster interface ClinicalPatient { medicalRecordNumber: string; allergies: Allergy[]; currentMedications: Medication[]; problemList: Diagnosis[]; } // Billing Language Cluster interface BillingPatient { accountNumber: string; insurancePolicies: InsurancePolicy[]; paymentMethods: PaymentMethod[]; outstandingBalance: Money; }
Step 3: Map Context Relationships
Once you've identified contexts, map how they relate:
┌─────────────────┐ ┌─────────────────┐
│ Clinical │────>│ Billing │
│ Context │ ACL │ Context │
└─────────────────┘ └─────────────────┘
│ │
│ Shared │ Conformist
│ Kernel │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Laboratory │ │ Insurance │
│ Context │ │ Gateway │
└─────────────────┘ └─────────────────┘
Context Mapping Patterns
Anti-Corruption Layer (ACL)
When integrating with legacy systems or external services that don't follow your domain model:
// Anti-Corruption Layer for legacy billing system class BillingAntiCorruptionLayer { constructor(private legacyBillingClient: LegacyBillingClient) {} async createInvoice(clinicalVisit: ClinicalVisit): Promise<Invoice> { // Translate from our domain model to legacy format const legacyRequest = { PTNT_ID: clinicalVisit.patientId, VST_DT: this.formatLegacyDate(clinicalVisit.date), CHRG_CDS: clinicalVisit.procedures.map(p => this.mapToBillingCode(p)), DIAG_CDS: clinicalVisit.diagnoses.map(d => d.icd10Code) }; const legacyResponse = await this.legacyBillingClient.createCharge(legacyRequest); // Translate back to our domain model return new Invoice({ id: InvoiceId.fromLegacy(legacyResponse.CHRG_NBR), patientId: clinicalVisit.patientId, lineItems: this.translateLineItems(legacyResponse.LN_ITMS), totalAmount: Money.fromCents(legacyResponse.TOT_AMT) }); } private mapToBillingCode(procedure: ClinicalProcedure): string { // Complex translation logic const mapping = this.billingCodeMappings.get(procedure.cptCode); if (!mapping) { throw new UnmappedProcedureError(procedure); } return mapping.legacyCode; } }
Shared Kernel
When two contexts genuinely need to share a small subset of the domain model:
// Shared Kernel: Common types used by Clinical and Laboratory contexts // This code lives in a separate, carefully versioned package export class PatientIdentifier { private constructor( public readonly medicalRecordNumber: string, public readonly facilityCode: string ) { this.validate(); } static create(mrn: string, facility: string): PatientIdentifier { return new PatientIdentifier(mrn, facility); } private validate(): void { if (!/^[A-Z]{2}\d{8}$/.test(this.medicalRecordNumber)) { throw new InvalidMRNError(this.medicalRecordNumber); } } equals(other: PatientIdentifier): boolean { return this.medicalRecordNumber === other.medicalRecordNumber && this.facilityCode === other.facilityCode; } } export interface LabOrderable { orderableId: string; loincCode: string; displayName: string; }
Published Language
When you need a standardized format for cross-context communication:
// Published Language: Event schemas for inter-context communication // Using JSON Schema for documentation and validation const PatientAdmittedEvent = { $schema: 'http://json-schema.org/draft-07/schema#', $id: 'https://hospital.com/events/patient-admitted/v1', type: 'object', required: ['eventId', 'timestamp', 'patientId', 'admissionType'], properties: { eventId: { type: 'string', format: 'uuid' }, timestamp: { type: 'string', format: 'date-time' }, patientId: { type: 'string', pattern: '^[A-Z]{2}\\d{8}$' }, admissionType: { type: 'string', enum: ['EMERGENCY', 'ELECTIVE', 'TRANSFER', 'NEWBORN'] }, admittingPhysicianId: { type: 'string' }, primaryDiagnosis: { type: 'object', properties: { icd10Code: { type: 'string' }, description: { type: 'string' } } } } };
Implementing Bounded Contexts
Module Structure
Each bounded context should be a self-contained module:
src/
├── clinical/
│ ├── domain/
│ │ ├── entities/
│ │ │ ├── Patient.ts
│ │ │ ├── Visit.ts
│ │ │ └── Diagnosis.ts
│ │ ├── value-objects/
│ │ │ ├── VitalSigns.ts
│ │ │ └── MedicalRecordNumber.ts
│ │ ├── aggregates/
│ │ │ └── ClinicalEncounter.ts
│ │ ├── repositories/
│ │ │ └── PatientRepository.ts
│ │ └── services/
│ │ └── DiagnosisService.ts
│ ├── application/
│ │ ├── commands/
│ │ │ └── RecordVitals.ts
│ │ ├── queries/
│ │ │ └── GetPatientHistory.ts
│ │ └── handlers/
│ │ └── RecordVitalsHandler.ts
│ ├── infrastructure/
│ │ ├── persistence/
│ │ │ └── PostgresPatientRepository.ts
│ │ └── messaging/
│ │ └── ClinicalEventPublisher.ts
│ └── api/
│ ├── rest/
│ │ └── ClinicalController.ts
│ └── graphql/
│ └── ClinicalResolvers.ts
├── billing/
│ └── ... (same structure)
└── shared-kernel/
└── PatientIdentifier.ts
Aggregate Design
Aggregates enforce consistency boundaries within a bounded context:
// Clinical Encounter Aggregate class ClinicalEncounter { private readonly events: DomainEvent[] = []; private constructor( public readonly id: EncounterId, public readonly patientId: PatientIdentifier, private status: EncounterStatus, private vitalSigns: VitalSigns | null, private diagnoses: Diagnosis[], private procedures: Procedure[] ) {} static create(patientId: PatientIdentifier): ClinicalEncounter { const encounter = new ClinicalEncounter( EncounterId.generate(), patientId, EncounterStatus.CREATED, null, [], [] ); encounter.addEvent(new EncounterCreated(encounter.id, patientId)); return encounter; } recordVitals(vitals: VitalSigns): void { this.ensureStatus(EncounterStatus.IN_PROGRESS); // Business rule: vital signs can be updated, but history is preserved const previousVitals = this.vitalSigns; this.vitalSigns = vitals; this.addEvent(new VitalsRecorded(this.id, vitals, previousVitals)); // Check for critical values if (vitals.isCritical()) { this.addEvent(new CriticalVitalsDetected(this.id, vitals)); } } addDiagnosis(diagnosis: Diagnosis): void { this.ensureStatus(EncounterStatus.IN_PROGRESS); // Business rule: no duplicate diagnoses if (this.diagnoses.some(d => d.code.equals(diagnosis.code))) { throw new DuplicateDiagnosisError(diagnosis.code); } this.diagnoses.push(diagnosis); this.addEvent(new DiagnosisAdded(this.id, diagnosis)); } complete(): void { // Business rule: must have at least one diagnosis to complete if (this.diagnoses.length === 0) { throw new EncounterCannotBeCompletedError('At least one diagnosis required'); } this.status = EncounterStatus.COMPLETED; this.addEvent(new EncounterCompleted(this.id, this.diagnoses, this.procedures)); } private ensureStatus(expected: EncounterStatus): void { if (this.status !== expected) { throw new InvalidEncounterStateError(this.status, expected); } } private addEvent(event: DomainEvent): void { this.events.push(event); } pullEvents(): DomainEvent[] { const events = [...this.events]; this.events.length = 0; return events; } }
Cross-Context Communication
Synchronous: Context APIs
// Billing Context needs patient demographics from Clinical Context class BillingService { constructor( private clinicalContextApi: ClinicalContextApi, private billingRepository: BillingAccountRepository ) {} async createAccount(patientId: PatientIdentifier): Promise<BillingAccount> { // Call Clinical Context through its public API const patientDemographics = await this.clinicalContextApi.getPatientDemographics(patientId); // Translate to Billing Context's model const account = BillingAccount.create({ patientId, name: patientDemographics.fullName, dateOfBirth: patientDemographics.dateOfBirth, // Note: We don't copy medical information - that stays in Clinical Context }); await this.billingRepository.save(account); return account; } }
Asynchronous: Domain Events
// Clinical Context publishes events class EncounterCompletedHandler { constructor(private eventPublisher: EventPublisher) {} async handle(encounter: ClinicalEncounter): Promise<void> { const events = encounter.pullEvents(); for (const event of events) { if (event instanceof EncounterCompleted) { // Publish to message broker for other contexts await this.eventPublisher.publish('clinical.encounter.completed', { encounterId: event.encounterId.toString(), patientId: event.patientId.toString(), diagnoses: event.diagnoses.map(d => ({ code: d.code.toString(), description: d.description })), procedures: event.procedures.map(p => ({ code: p.code.toString(), description: p.description })), completedAt: event.timestamp.toISOString() }); } } } } // Billing Context subscribes to Clinical events class BillingEventHandler { constructor( private billingService: BillingService, private acl: ClinicalToBillingACL ) {} @Subscribe('clinical.encounter.completed') async onEncounterCompleted(event: EncounterCompletedEvent): Promise<void> { // Use ACL to translate Clinical concepts to Billing concepts const billableItems = this.acl.translateToBillableItems( event.diagnoses, event.procedures ); await this.billingService.createInvoice({ patientId: event.patientId, encounterId: event.encounterId, lineItems: billableItems }); } }
Common Mistakes and How to Avoid Them
Mistake 1: Bounded Context = Microservice
A bounded context is a logical boundary, not a deployment unit. You might have multiple bounded contexts in a modular monolith, or split a single context across multiple services.
Mistake 2: Sharing Databases Across Contexts
Each context should own its data. If two contexts share a database, you've effectively merged them.
Mistake 3: Anemic Domain Models
Putting all logic in services while entities are just data containers defeats the purpose of DDD.
Mistake 4: Ignoring the Ubiquitous Language
If developers use different terms than domain experts, miscommunication and bugs are inevitable.
Conclusion
Bounded contexts are not about creating silos—they're about explicitly managing the relationships between different parts of your domain. The investment in proper context design pays dividends as your system grows.
Start by talking to your domain experts. Map the language they use. Look for where terms change meaning. Those are your boundaries.
Related Articles
Software Architecture16 min read
Microservices vs Monolith: A Practitioner's Decision Framework
Microservices vs monolith comparison with real decision criteria. Learn when microservices add value vs unnecessary complexity, with examples from production systems.
Software Architecture20 min read
CQRS and Event Sourcing: When and Why to Use Them
Complete CQRS and Event Sourcing implementation guide with TypeScript and Node.js. Covers event stores, projections, snapshots, and when these patterns are worth the complexity.
Backend Design19 min read
API Design: Choosing Between REST, GraphQL, and gRPC
Compare REST, GraphQL, and gRPC APIs with performance benchmarks and use cases. Learn which API style fits your project based on real production experience.
Software Architecture18 min read
Event-Driven Architecture in Enterprise Systems: Patterns and Trade-offs
A practitioner's guide to implementing event-driven architecture at scale. Covers message broker selection, event schema design, eventual consistency patterns, and lessons from production systems.
Backend Design18 min read
Laravel at Scale: Enterprise Patterns Beyond MVC
Build enterprise Laravel applications with repository pattern, service layer, and DDD principles. Production patterns from government and healthcare systems.