Software Architecture

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.

Khalid Aboubakr
22 min read
DddBounded ContextMicroservicesDomain ModelingUbiquitous LanguageAggregates

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

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.