
Bounded Contexts in Practice - Strategic DDD for Clean System Design in .NET
Author - Abdul Rahman (Bhai)
DDD
22 Articles
Table of Contents
What we gonna do?
You have built a clean aggregate. Your entities carry behaviour. Your value objects are immutable. Everything looks good at the micro level - so why does the system still feel like a mess?
The answer is almost always strategic design. Tactical DDD (entities, value objects, aggregates) tells you how to model one concept. Strategic DDD tells you how to organise the whole system. And the most important strategic concept in Domain-Driven Design is the Bounded Context.
In this article you will learn what a Bounded Context is, why a single shared model always breaks down eventually, how to split one bloated model into intentional contexts, and how those contexts integrate with one another using domain events.
Why we gonna do?
The Single-Model Trap
Imagine a Customer class that every team in the company shares. Sales adds order history. Marketing adds campaign preferences. Billing adds invoice addresses. Shipping adds delivery options. Support adds ticket counts. Each team is just trying to move fast, but the class turns into a sprawling data bag with hundreds of fields.
// A real codebase horror story - one class serving everyone
public class Customer
{
// Sales
public List<Order> Orders { get; set; } = [];
public decimal TotalRevenue { get; set; }
public string Segment { get; set; } = string.Empty;
// Support
public List<Ticket> Tickets { get; set; } = [];
public int SatisfactionScore { get; set; }
public string SupportTier { get; set; } = string.Empty;
// Billing
public List<Invoice> Invoices { get; set; } = [];
public List<Payment> Payments { get; set; } = [];
public string BillingAddress { get; set; } = string.Empty;
// Shipping
public Address DefaultShippingAddress { get; set; } = default!;
public string PreferredCarrier { get; set; } = string.Empty;
// Marketing
public List<CampaignSubscription> Campaigns { get; set; } = [];
public bool EmailOptIn { get; set; }
// ... and growing every sprint
}
This approach fails for three concrete reasons:
Problem | Consequence
─────────────────────────────────────────────────────────────────────
One class does everything | Massive, fragile, hard to understand
Nobody owns the class end-to-end | Each team understands only their slice
Every change is a negotiation | Five teams argue about one field
─────────────────────────────────────────────────────────────────────
Result: hard-to-test code, conflicting requirements, big-bang deployments
Same Word, Different Meanings
The deeper problem is that the same business term means different things to different teams. This is not a communication failure - it is reality. Different sub-domains use language differently:
Term | Sales Context | Support Context
──────────────────────────────────────────────────────────
Customer | A buyer | A person needing help
Order | A purchase being made | A source of support tickets
Active | Has open orders | Has open tickets
──────────────────────────────────────────────────────────
Same word → different meaning → different rules → different models
When you force one class to represent all of these meanings you end up with null fields, boolean flags, and conditional logic that reads like "if sales, then... else if support, then...". That code is almost impossible to reason about.
How we gonna do?
Step 1: Define Each Bounded Context
A Bounded Context is a clear boundary where the domain model is defined, terms have a single consistent meaning, and one team owns decisions. Inside that boundary every word is precise. Outside it, the same word may mean something completely different.
Split the bloated Customer into focused, intentional models — one per context:
// Sales Context - only what sales cares about
namespace SalesContext;
public sealed class Customer
{
public CustomerId Id { get; private set; }
public IReadOnlyList<OrderId> Orders => _orders.AsReadOnly();
public decimal TotalRevenue { get; private set; }
public CustomerSegment Segment { get; private set; }
private readonly List<OrderId> _orders = [];
private Customer() { }
public static Customer Register(CustomerId id, CustomerSegment segment)
=> new() { Id = id, Segment = segment, TotalRevenue = 0 };
public void RecordOrder(OrderId orderId, decimal amount)
{
_orders.Add(orderId);
TotalRevenue += amount;
}
}
// Support Context - only what support cares about
namespace SupportContext;
public sealed class Customer
{
public CustomerId Id { get; private set; }
public IReadOnlyList<TicketId> Tickets => _tickets.AsReadOnly();
public int SatisfactionScore { get; private set; }
public SupportTier Tier { get; private set; }
private readonly List<TicketId> _tickets = [];
private Customer() { }
public static Customer Create(CustomerId id, SupportTier tier)
=> new() { Id = id, Tier = tier, SatisfactionScore = 100 };
public bool IsActive() => _tickets.Any();
}
// Billing / License Context - only what billing cares about
namespace LicenseContext;
public sealed class Account
{
public AccountId Id { get; private set; }
public CustomerId CustomerId { get; private set; } // reference only!
public IReadOnlyList<InvoiceId> Invoices => _invoices.AsReadOnly();
public IReadOnlyList<PaymentId> Payments => _payments.AsReadOnly();
private readonly List<InvoiceId> _invoices = [];
private readonly List<PaymentId> _payments = [];
private Account() { }
public static Account Open(AccountId id, CustomerId customerId)
=> new() { Id = id, CustomerId = customerId };
}
Notice that each context keeps only the data it needs. The Sales context knows nothing about tickets. The Support context knows nothing about invoices. The Billing context holds a CustomerId reference — it does not duplicate sales data.
Step 2: Design the Integration via Domain Events
Bounded Contexts must not share a database table or call each other's aggregates directly. The safe integration mechanism is domain events. Here is how the three contexts connect:
┌─────────────────────────────────────────────────────────────────┐
│ Integration Flow │
│ │
│ Sales Context │
│ Customer.Register() ──raises──▶ CustomerRegistered event │
│ │ │
│ ┌───────────────────┤ │
│ ▼ ▼ │
│ Billing Context Support Context │
│ Account.Open(customerId) Customer.Create(customerId) │
│ │
└─────────────────────────────────────────────────────────────────┘
// The event raised by the Sales context
namespace SalesContext.Events;
public sealed record CustomerRegistered(
Guid CustomerId,
string Segment,
DateTimeOffset OccurredAt);
// Billing context handler - reacts to the Sales event
namespace BillingContext.Handlers;
public sealed class CreateAccountOnCustomerRegistered
{
private readonly IAccountRepository _accounts;
public CreateAccountOnCustomerRegistered(IAccountRepository accounts)
=> _accounts = accounts;
public async Task HandleAsync(CustomerRegistered evt,
CancellationToken ct = default)
{
var accountId = new AccountId(Guid.NewGuid());
var customerId = new CustomerId(evt.CustomerId);
var account = Account.Open(accountId, customerId);
await _accounts.SaveAsync(account, ct);
}
}
// Support context handler - reacts to the same Sales event
namespace SupportContext.Handlers;
public sealed class CreateSupportCustomerOnRegistered
{
private readonly ISupportCustomerRepository _customers;
public CreateSupportCustomerOnRegistered(
ISupportCustomerRepository customers)
=> _customers = customers;
public async Task HandleAsync(CustomerRegistered evt,
CancellationToken ct = default)
{
var customerId = new CustomerId(evt.CustomerId);
var customer = Customer.Create(customerId, SupportTier.Standard);
await _customers.SaveAsync(customer, ct);
}
}
Step 3: Enforce the Boundary in Your Project Structure
A Bounded Context is not just a conceptual idea — it should be reflected in your project layout. Each context gets its own namespace and assembly. No cross-context type references are allowed, only shared identity value objects (like CustomerId) and event contracts:
src/
SalesContext/
Domain/
Customer.cs
Events/
CustomerRegistered.cs
Application/
RegisterCustomerCommand.cs
Infrastructure/
SqlCustomerRepository.cs
BillingContext/
Domain/
Account.cs
Application/
Handlers/
CreateAccountOnCustomerRegistered.cs
Infrastructure/
SqlAccountRepository.cs
SupportContext/
Domain/
Customer.cs ← different Customer than SalesContext!
Application/
Handlers/
CreateSupportCustomerOnRegistered.cs
The key insight: SalesContext.Customer and SupportContext.Customer are completely separate classes in separate namespaces. Having the same name is intentional — they model the same real-world entity but from completely different perspectives.
Summary
In this article, you learned the most important strategic concept in Domain-Driven Design:
- A Bounded Context is a clear boundary where a domain model is defined, terms have one meaning, and one team owns decisions.
- The single-model anti-pattern produces massive, fragile classes that nobody owns and everyone breaks.
- The same business word (Customer, Order, Active) means different things in different contexts — that difference belongs in code, not in comments.
- Each Bounded Context keeps only the data it needs, modelled using the language of that sub-domain.
- Contexts integrate through domain events, never through direct aggregate references across boundaries.
- The boundary should be enforced structurally using separate namespaces and assemblies.
With Bounded Contexts defined, the next step is to draw the relationships between them — that is the job of a Context Map, which is the topic of the next article.