Learnixo

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

C#
// 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

C#
// 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 Patient class across Clinical, Billing, and Pharmacy modules. When the Billing team added InsurancePolicyNumber and PaymentStatus to 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.