Bounded Context in DDD Explained With Examples

ddddotnetmodular-monolithsoftware-architecture
Bounded Context in DDD Explained With Examples

A bounded context defines where one domain model and vocabulary apply. Ordering and Fulfillment can each have an Order with different data and rules. In .NET, express those boundaries through separate modules or services, keep model ownership explicit, and translate between contexts through agreed contracts instead of sharing domain entities.

Most teams instead try to build one unified domain model, and it slowly turns into a class with 50 properties that nobody owns. Bounded Contexts prevent this by giving each part of the business its own focused model, with explicit boundaries and explicit integration points.

What Is a Bounded Context?

A Bounded Context is a boundary within which a specific domain model applies. Inside a Bounded Context, every term has a precise, unambiguous meaning.

The word "Order" means different things to different parts of a business:

  • In Ordering, an Order is a customer's purchase request with line items and pricing
  • In Fulfillment, an Order is a picking list with warehouse locations and shipping labels
  • In Billing, an Order is an invoice with payment terms and tax calculations

Each of these is a different model of "Order" - with different properties, different behaviors, and different rules.

A Bounded Context makes this explicit: each context has its own Order class, and they don't need to match. Martin Fowler's bounded-context explanation describes why unifying those models can create contradictions. The DDD getting-started guide connects this boundary decision to the implementation patterns.

Why Bounded Contexts Matter

Without Bounded Contexts, teams try to build a single unified model - one Order class that serves Ordering, Fulfillment, and Billing.

This "God Model" approach creates:

  • Classes with 50+ properties - most irrelevant to any single use case
  • Coupling - a change for Billing breaks Fulfillment
  • Unclear ownership - who owns the Order class?
  • Slow development - every team coordinates on the same model

Bounded Contexts solve this by giving each team their own model. Models are simpler, focused, and independently evolvable.

Bounded Contexts vs Subdomains

These are related but different:

  • A Subdomain is a problem space concept - a natural division of the business domain
  • A Bounded Context is a solution space concept - a boundary you define around a model

Ideally, each Bounded Context aligns with one subdomain. In practice, one subdomain might span multiple Bounded Contexts, or you might start with a single Bounded Context covering multiple subdomains and split later.

Identifying Bounded Contexts

Look for these signals:

Different language. If the Sales team and the Fulfillment team use the same word to mean different things, there's a boundary. This is where the Ubiquitous Language earns its keep.

Different data. If two teams need different properties for the same concept, they need different models.

Different rules. If the same operation has different validation rules in different parts of the system, those parts are different contexts.

Organizational boundaries. Teams that work independently usually represent different Bounded Contexts.

For a typical e-commerce system, you might identify:

An e-commerce domain split into five bounded contexts: Sales for catalog and pricing, Ordering for cart and placement, Fulfillment for inventory and shipping, Billing for invoicing and payments, and Customer for registration and profiles

Bounded Contexts in Code

In a Modular Monolith, a module can make a bounded context explicit. This is a useful design choice, not a rule that every folder or deployment must be a separate context:

Modules/
  Sales/
    Domain/
      Product.cs      ← Sales-specific product (pricing, promotions)
    Application/
    Infrastructure/
  Ordering/
    Domain/
      Order.cs         ← Ordering-specific order (line items, status)
      Product.cs       ← Just ProductId + Name + Price, not the full catalog
    Application/
    Infrastructure/
  Fulfillment/
    Domain/
      Order.cs         ← Fulfillment-specific order (warehouse, shipping)
    Application/
    Infrastructure/

Each module has its own Product and Order - different classes, different properties, different rules.

The Sales Context Product

These simplified read models show the data each context needs; aggregate behavior is separate. Sales represents the product as an offer:

namespace Sales.Domain
{
    public sealed record Product(
        Guid Id, string Name, decimal BasePrice, string Currency, bool IsActive);
}

The Fulfillment Context Product

namespace Fulfillment.Domain
{
    public sealed record Product(
        Guid Id, string Sku, string WarehouseLocation, decimal WeightInKg,
        int QuantityInStock);
}

Same real-world concept. Completely different models. This is intentional.

Communication Between Bounded Contexts

Contexts usually communicate through well-defined integration points instead of sharing their entire models. Those relationships belong on a context map, where the technical integration and the team dependency are both explicit.

The Ordering Context publishing an OrderPlaced integration event that passes through an Anti-Corruption Layer, which translates it into the Fulfillment Context's own FulfillmentOrder model and local copy

Integration Events

The most common approach: publish events that other contexts consume.

public sealed record OrderItemDto(Guid ProductId, int Quantity);

public sealed record OrderPlacedIntegrationEvent(
    Guid EventId,
    Guid OrderId,
    Guid CustomerId,
    IReadOnlyList<OrderItemDto> Items,
    decimal TotalAmount,
    string Currency);

public sealed class CreateFulfillmentOrderHandler(
    OrderPlacedTranslator translator,
    IFulfillmentOrders orders)
{
    public Task Handle(OrderPlacedIntegrationEvent message, CancellationToken ct)
    {
        var order = translator.Translate(message);
        return orders.SaveIfAbsentAsync(order, ct);
    }
}

public interface IFulfillmentOrders
{
    // Atomically insert once per OrderingOrderId, using a database unique key.
    Task SaveIfAbsentAsync(FulfillmentOrder order, CancellationToken ct);
}

The integration event is a contract - a shared schema that both contexts agree on. It's not a domain event (which is internal to a context).

For reliable delivery, use the Outbox pattern. The handler above is the consumer body, called by your message transport. Its repository implementation must make the insert idempotent with a unique source-order key, because delivery can happen more than once. A separate existence check followed by an insert is not sufficient under concurrent delivery.

Anti-Corruption Layer

When consuming data from another context, use an Anti-Corruption Layer to translate external models into your context's language:

public sealed record PickingItem(Guid ProductId, int Quantity, string WarehouseLocation);

public sealed record FulfillmentOrder(
    Guid OrderingOrderId, IReadOnlyList<PickingItem> Items);

public interface IWarehouseLocations
{
    string GetLocation(Guid productId);
}

public sealed class OrderPlacedTranslator(IWarehouseLocations locations)
{
    public FulfillmentOrder Translate(OrderPlacedIntegrationEvent message)
    {
        if (message.OrderId == Guid.Empty || message.Items.Count == 0)
            throw new ArgumentException("An order ID and items are required.");

        var pickingItems = new List<PickingItem>();
        foreach (var item in message.Items)
        {
            if (item.ProductId == Guid.Empty || item.Quantity <= 0)
                throw new ArgumentException("Invalid order item.");

            var location = locations.GetLocation(item.ProductId);
            ArgumentException.ThrowIfNullOrWhiteSpace(location);
            pickingItems.Add(new PickingItem(item.ProductId, item.Quantity, location));
        }

        return new FulfillmentOrder(message.OrderId, pickingItems.AsReadOnly());
    }
}

The ACL translates Ordering data into Fulfillment terms and enriches it from a local warehouse lookup. The records keep this example focused on translation; production aggregate constructors should enforce the remaining fulfillment rules. A missing warehouse location must follow an explicit retry or review policy in the consumer, rather than silently creating an unpickable order.

Data Isolation

Each Bounded Context should own its data:

  • Separate schemas - sales.products, fulfillment.products
  • Separate databases - in microservices architectures
  • No direct dependency on another context's operational tables - use its contract or a separately owned reporting model

When one context needs data from another, one option is a local copy updated through integration events. A synchronous API is another option when the caller needs current data and can accept the availability dependency. The Fulfillment Context doesn't query the Sales database for product details - it maintains its own product data, synced through events.

This is the Database per Module pattern.

Context Mapping

Context Mapping describes the relationships between Bounded Contexts:

  • Partnership - two teams cooperate and evolve together
  • Customer-Supplier - one team provides, the other consumes
  • Conformist - the consuming team accepts the supplier's model as-is
  • Anti-Corruption Layer - the consuming team translates the supplier's model
  • Shared Kernel - two contexts share a small, co-owned model
  • Separate Ways - no integration, contexts are fully independent

For a deeper dive into relationships between modules, see my article on module communication patterns.

Common Mistakes

1. Single shared model across contexts. The "God Model" anti-pattern. If every context uses the same Order class, you have coupling, not boundaries.

2. Too many contexts. Each context has overhead (separate data store, integration events, mapping). Start with fewer, larger contexts and split when needed.

3. Contexts aligned to technical layers. "Database Context" and "API Context" are not Bounded Contexts. Contexts align to business capabilities.

4. Uncoordinated table sharing. If either context changes shared tables without agreement, it can break the other's model. A deliberate shared kernel is an exception: the teams jointly own a small shared model and its associated storage, and coordinate changes to it. Keep that shared portion explicit and small, as described in Evans's DDD Reference.

Summary

Bounded Contexts are the foundation of strategic Domain-Driven Design. They define clear boundaries where specific domain models apply, giving teams the freedom to model their part of the business independently.

Start by listening to the language. When the same word means different things to different people, you've found a boundary.

Thanks for reading.

And stay awesome!


Frequently Asked Questions

What is a bounded context in DDD?

A bounded context is a boundary within which a specific domain model applies. Inside the boundary, every term has one precise meaning. The same real-world concept, like Order, can have a completely different model in each context.

What is the difference between a bounded context and a subdomain?

A subdomain is a problem space concept, a natural division of the business. A bounded context is a solution space concept, a boundary you draw around a model. Ideally they align one to one, but in practice they can diverge.

How do you identify bounded contexts?

Listen to the language. When the same word means different things to different teams, or two teams need different data and rules for the same concept, you have found a boundary. Organizational boundaries are another strong signal.

Can two bounded contexts share a database?

Yes. Contexts can use the same database while owning separate schemas or tables. A deliberate shared kernel can also include jointly managed storage, but changes to that shared part require coordination between the teams.

How many bounded contexts should a system have?

As few as you can justify. Each context adds overhead: separate data, integration events, and translation. Start with fewer, larger contexts and split when the language or the team structure forces it.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.