Domain-Driven Design in .NET · Lesson 6 of 7
Bounded Contexts and Context Mapping
What a Bounded Context Is
Bounded Context: a boundary within which a domain model is valid.
Different contexts can use the same word with different meanings.
"Patient" in different bounded contexts:
Clinical Context: Patient has admissions, diagnoses, observations, prescriptions
Billing Context: Patient has insurance, invoices, payment history
Scheduling Context: Patient has appointments, visit history
Pharmacy Context: Patient has active prescriptions, allergies, dispensing history
The word "Patient" in each context refers to different data, different behavior.
Clinical: PatientId, WardId, DischargeDate
Billing: PatientId, InsurancePolicyNumber, BillingAddress
Each is a separate domain model — they do not share the same entity class.Ubiquitous Language Per Context
Ubiquitous Language: the shared vocabulary of a bounded context.
Use the same terms in code, conversations, documentation, and database.
If the business calls it a "script", the code calls it Script — not Prescription.
If the pharmacy calls it "dispensing", the code says Dispense() — not "issue" or "send".
Clinical Context vocabulary:
Patient, Clinician, Ward, Admission, Discharge, Prescription, Observation, Alert
Billing Context vocabulary:
Patient (billing), Invoice, InsuranceClaim, PaymentTransaction, PolicyNumber
Pharmacy Context vocabulary:
Patient (pharmacy), PrescriptionLabel, DispensingRecord, StockBatch, SubstitutionPolicy
Each context has its own language — translation happens at the context boundary.Context Mapping Patterns
How bounded contexts relate to each other:
Shared Kernel:
Two contexts share a subset of the domain model.
Both teams must agree on changes.
High coupling — use sparingly.
Example: PatientId shared between Clinical and Billing — never the full entity.
Customer-Supplier (Upstream/Downstream):
The upstream context produces; the downstream context consumes.
The upstream team must consider downstream needs.
Example: Clinical produces PatientAdmitted events; Billing consumes them.
Conformist:
The downstream accepts the upstream's model without modification.
Downstream conforms to whatever the upstream produces.
Common with third-party APIs (HL7 FHIR standard — you conform).
Anti-Corruption Layer (ACL):
A translation layer protects the downstream domain model from the upstream model.
The downstream translates upstream concepts to its own language.
Example: FHIR Patient → Clinical PatientDto (the ACL does the translation).
Published Language:
A well-documented shared format for cross-context communication.
Example: FHIR (HL7) for healthcare data exchange.Anti-Corruption Layer in .NET
// Upstream: external FHIR API — uses its own Patient model
// Downstream: our Clinical context — uses our Patient aggregate
// ACL: translates FHIR Patient to Clinical Patient
public sealed class FhirPatientTranslator
{
public CreatePatientCommand Translate(FhirPatient fhirPatient)
{
// FHIR uses HumanName arrays and date strings
var name = fhirPatient.Name?.FirstOrDefault();
var firstName = name?.Given?.FirstOrDefault() ?? string.Empty;
var lastName = name?.Family ?? string.Empty;
// FHIR uses ISO8601 string dates
var dob = DateTime.TryParse(fhirPatient.BirthDate, out var date)
? date
: throw new InvalidOperationException("Invalid FHIR birth date format.");
// FHIR identifier system for MRN
var mrn = fhirPatient.Identifier
?.FirstOrDefault(i => i.System == "https://nhs.uk/id/mrn")
?.Value
?? throw new InvalidOperationException("MRN identifier not found in FHIR patient.");
// Returns our domain's command — no FHIR concepts leak into the domain
return new CreatePatientCommand(mrn, firstName, lastName, dob);
}
}
// The Clinical context never knows about FHIR.
// The ACL absorbs the complexity of the external model.Context Boundary as a Module/Project
For a modular monolith:
SystemForge.Clinical/
Patients/
Prescriptions/
Admissions/
SystemForge.Billing/
Invoices/
InsuranceClaims/
SystemForge.Pharmacy/
DispensingRecords/
StockBatches/
For microservices:
clinical-service/ — owns the Clinical bounded context
billing-service/ — owns the Billing bounded context
pharmacy-service/ — owns the Pharmacy bounded context
Context boundaries enforce:
→ No direct database access across contexts
→ No shared domain entities (only shared IDs + integration events)
→ Translation at the boundary (ACL or Published Language)Integration Event for Cross-Context Communication
// Clinical context: patient discharged domain event → integration event
public sealed class PatientDischargedIntegrationEvent
{
public Guid PatientId { get; init; }
public string MedicalRecordNumber { get; init; } = default!;
public DateTime DischargedAt { get; init; }
public string WardCode { get; init; } = default!;
}
// Billing context: consumes PatientDischargedIntegrationEvent
// and translates to its own model
public sealed class HandlePatientDischargedForBilling
: IIntegrationEventHandler<PatientDischargedIntegrationEvent>
{
private readonly IBillingPatientRepository _repo;
public async Task Handle(PatientDischargedIntegrationEvent @event, CancellationToken ct)
{
// Translate: FHIR-style event → Billing context language
var billingPatient = await _repo.GetByPatientIdAsync(@event.PatientId, ct);
if (billingPatient is null) return;
// Billing's discharge processing — no Clinical domain knowledge needed
await billingPatient.RecordDischargeForBillingAsync(@event.DischargedAt, ct);
}
}Production issue I've seen: A team sharing a single
Patientclass across Clinical, Billing, and Pharmacy modules. When the Billing team addedInsurancePolicyNumberandPaymentStatusto the Patient class, the Clinical team's queries started joining against billing tables they didn't own. Schema changes by the Billing team broke Clinical queries. Separating into bounded contexts — each with its own Patient projection containing only the data it needs — eliminated the cross-context coupling.
Key Takeaway
Bounded contexts are the fundamental unit of DDD at scale. Each context owns its own domain model, ubiquitous language, and database schema. Contexts communicate via integration events (cross-process) or ACL translators (same process). Share IDs across contexts, never share entities. The Anti-Corruption Layer protects your domain model from external models — especially third-party APIs like FHIR. Context boundaries in code = separate modules or services.