Domain-Driven Design: Bounded Contexts and Aggregates

Eric Evans introduced Domain-Driven Design in 2003, and two decades later, it remains one of the most misunderstood approaches in software architecture. The core philosophy is simple: your code...

Key Insights

  • Bounded contexts define explicit boundaries where a domain model applies, allowing the same business concept (like “Product”) to have different meanings and implementations in different parts of your system.
  • Aggregates are consistency boundaries, not data containers—design them to protect invariants within a single transaction, and keep them as small as possible.
  • Cross-boundary communication should happen through domain events and anti-corruption layers, never through shared databases or direct object references.

Introduction: Why DDD Matters for Complex Domains

Eric Evans introduced Domain-Driven Design in 2003, and two decades later, it remains one of the most misunderstood approaches in software architecture. The core philosophy is simple: your code should reflect the business domain it serves. The implementation, however, requires discipline.

DDD provides two categories of patterns. Strategic patterns help you decompose large systems into manageable pieces. Tactical patterns give you building blocks for implementing domain logic within those pieces. Most developers jump straight to tactical patterns—entities, value objects, repositories—without understanding the strategic context that makes them valuable.

Here’s the uncomfortable truth: DDD is overkill for most applications. If you’re building a CRUD app with straightforward business rules, you don’t need aggregates and bounded contexts. You need a well-structured MVC application. DDD shines when your domain has complex business rules, when multiple teams work on the same system, or when the cost of getting the model wrong is high. A trading platform, a healthcare system, an e-commerce operation with sophisticated pricing rules—these benefit from DDD. A blog or a simple inventory tracker probably doesn’t.

Strategic Design: Understanding Bounded Contexts

A bounded context is an explicit boundary within which a domain model applies. Inside that boundary, every term has a precise, unambiguous meaning. This is what Evans calls the “ubiquitous language”—the shared vocabulary between developers and domain experts.

The same real-world concept often means different things in different parts of your business. Consider “Product” in an e-commerce company. To the catalog team, a product has descriptions, images, and categories. To the inventory team, it has stock levels, warehouse locations, and reorder points. To the pricing team, it has cost basis, margin rules, and promotional eligibility. These aren’t the same model with different views—they’re fundamentally different models serving different purposes.

// Catalog Context
namespace Catalog.Domain
{
    public class Product
    {
        public ProductId Id { get; private set; }
        public string Name { get; private set; }
        public string Description { get; private set; }
        public List<Category> Categories { get; private set; }
        public List<ProductImage> Images { get; private set; }
        
        public void UpdateDescription(string description, string seoKeywords)
        {
            // Catalog-specific validation
            if (description.Length < 50)
                throw new InvalidDescriptionException("Description must be at least 50 characters for SEO");
            
            Description = description;
        }
    }
}

// Inventory Context
namespace Inventory.Domain
{
    public class StockItem
    {
        public StockItemId Id { get; private set; }
        public string Sku { get; private set; }  // Note: different identifier
        public int QuantityOnHand { get; private set; }
        public int ReorderPoint { get; private set; }
        public WarehouseLocation Location { get; private set; }
        
        public void AdjustStock(int adjustment, string reason)
        {
            var newQuantity = QuantityOnHand + adjustment;
            if (newQuantity < 0)
                throw new InsufficientStockException(Sku, QuantityOnHand, adjustment);
            
            QuantityOnHand = newQuantity;
            AddDomainEvent(new StockAdjusted(Id, adjustment, reason));
        }
    }
}

Notice how these models share no code. They don’t inherit from a common base. They use different identifiers (ProductId vs. Sku). This is intentional. Forcing a shared model creates coupling that makes both contexts harder to evolve.

Context mapping patterns define how bounded contexts relate to each other. An Anti-Corruption Layer (ACL) translates between contexts, protecting your model from external concepts. A Shared Kernel represents code genuinely shared between contexts—use sparingly, as it creates coupling. Customer-Supplier relationships establish which team’s needs take priority when conflicts arise.

Tactical Design: Aggregates as Consistency Boundaries

An aggregate is a cluster of domain objects treated as a single unit for data changes. The aggregate root is the only entry point—external code cannot reach inside and modify child entities directly. This isn’t about data organization; it’s about protecting business invariants.

public class Order
{
    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyList<OrderLine> Lines => _lines.AsReadOnly();
    
    public Money Total => _lines
        .Select(l => l.LineTotal)
        .Aggregate(Money.Zero, (acc, m) => acc.Add(m));
    
    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new OrderModificationException("Cannot modify a submitted order");
        
        if (quantity <= 0)
            throw new InvalidQuantityException(quantity);
        
        var existingLine = _lines.FirstOrDefault(l => l.ProductId == productId);
        if (existingLine != null)
        {
            existingLine.IncreaseQuantity(quantity);
        }
        else
        {
            _lines.Add(new OrderLine(productId, quantity, unitPrice));
        }
    }
    
    public void Submit()
    {
        if (!_lines.Any())
            throw new EmptyOrderException("Cannot submit an order with no items");
        
        if (Total.Amount > 10000 && !HasApproval)
            throw new ApprovalRequiredException(Total);
        
        Status = OrderStatus.Submitted;
        AddDomainEvent(new OrderSubmitted(Id, CustomerId, Total));
    }
}

public class OrderLine
{
    public ProductId ProductId { get; private set; }
    public int Quantity { get; private set; }
    public Money UnitPrice { get; private set; }
    public Money LineTotal => UnitPrice.Multiply(Quantity);
    
    internal void IncreaseQuantity(int additional)
    {
        Quantity += additional;
    }
}

public record Money(decimal Amount, string Currency)
{
    public static Money Zero => new(0, "USD");
    
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new CurrencyMismatchException(Currency, other.Currency);
        return new Money(Amount + other.Amount, Currency);
    }
    
    public Money Multiply(int factor) => new(Amount * factor, Currency);
}

The critical rule: one aggregate, one transaction. When you save an Order, you save its OrderLines in the same database transaction. You never save an Order and update a Customer in the same transaction—that would couple two aggregates.

Aggregate Design Heuristics

Most aggregate design problems stem from making aggregates too large. A bloated aggregate creates contention (multiple users can’t edit different parts simultaneously), memory issues, and unclear boundaries.

// BAD: Bloated Customer aggregate
public class Customer
{
    public CustomerId Id { get; private set; }
    public string Name { get; private set; }
    public List<Address> Addresses { get; private set; }
    public List<Order> Orders { get; private set; }  // Dangerous!
    public List<PaymentMethod> PaymentMethods { get; private set; }
    public List<SupportTicket> SupportTickets { get; private set; }
    public LoyaltyAccount Loyalty { get; private set; }
}

// BETTER: Separate aggregates connected by ID
public class Customer
{
    public CustomerId Id { get; private set; }
    public string Name { get; private set; }
    public Email Email { get; private set; }
}

public class CustomerAddressBook
{
    public CustomerId CustomerId { get; private set; }  // Reference by ID
    private readonly List<Address> _addresses = new();
    
    public void AddAddress(Address address) { /* ... */ }
}

public class LoyaltyAccount
{
    public CustomerId CustomerId { get; private set; }  // Reference by ID
    public int Points { get; private set; }
    
    public void EarnPoints(OrderId orderId, Money orderTotal)
    {
        var earned = CalculatePoints(orderTotal);
        Points += earned;
        AddDomainEvent(new PointsEarned(CustomerId, orderId, earned));
    }
}

Reference other aggregates by ID, never by direct object reference. This prevents accidentally loading massive object graphs and makes aggregate boundaries explicit in your code.

Communication Across Boundaries

Domain events enable loose coupling between aggregates. When an Order is submitted, it publishes an OrderSubmitted event. The Inventory context subscribes and reserves stock. Neither context knows implementation details of the other.

// Orders Context - Publishing
public class OrderSubmittedHandler : IHandle<OrderSubmitted>
{
    private readonly IEventBus _eventBus;
    
    public async Task Handle(OrderSubmitted @event)
    {
        // Publish integration event for other contexts
        await _eventBus.Publish(new IntegrationEvents.OrderPlacedEvent
        {
            OrderId = @event.OrderId.Value,
            Items = @event.Lines.Select(l => new OrderItemDto
            {
                Sku = l.ProductId.Value,  // Translate to inventory's language
                Quantity = l.Quantity
            }).ToList()
        });
    }
}

// Inventory Context - Anti-Corruption Layer
public class OrderPlacedEventHandler : IHandle<OrderPlacedEvent>
{
    private readonly IStockReservationService _reservationService;
    private readonly IOrderEventTranslator _translator;  // ACL
    
    public async Task Handle(OrderPlacedEvent externalEvent)
    {
        // Translate external event to internal command
        var command = _translator.ToReserveStockCommand(externalEvent);
        await _reservationService.Reserve(command);
    }
}

public class OrderEventTranslator : IOrderEventTranslator
{
    private readonly ISkuMappingRepository _skuMapping;
    
    public ReserveStockCommand ToReserveStockCommand(OrderPlacedEvent external)
    {
        return new ReserveStockCommand
        {
            ExternalOrderReference = external.OrderId,
            Reservations = external.Items.Select(item => new StockReservation
            {
                StockItemId = _skuMapping.GetStockItemId(item.Sku),
                Quantity = item.Quantity
            }).ToList()
        };
    }
}

The Anti-Corruption Layer is crucial. It translates between the Orders context’s language and the Inventory context’s language, preventing external concepts from polluting your domain model.

Common Pitfalls and How to Avoid Them

The anemic domain model is the most common DDD failure. Your entities become data bags, and business logic scatters across services.

// ANEMIC: Logic in service, entity is just data
public class OrderService
{
    public void AddLineToOrder(Order order, Product product, int quantity)
    {
        if (order.Status != "Draft")
            throw new Exception("Cannot modify");
        
        var line = new OrderLine();
        line.ProductId = product.Id;
        line.Quantity = quantity;
        line.UnitPrice = product.Price;
        order.Lines.Add(line);
        order.Total = order.Lines.Sum(l => l.Quantity * l.UnitPrice);
    }
}

// RICH: Logic in the aggregate where it belongs
public class Order
{
    public void AddLine(ProductId productId, int quantity, Money unitPrice)
    {
        GuardAgainstModificationWhenNotDraft();
        GuardAgainstInvalidQuantity(quantity);
        
        var line = FindOrCreateLine(productId, unitPrice);
        line.IncreaseQuantity(quantity);
    }
}

The rich model encapsulates business rules. You can’t create an invalid Order because the Order itself prevents it. Testing becomes straightforward—test the aggregate, not a service that manipulates data.

Conclusion: Evolving Your Domain Model

Your first bounded context boundaries will be wrong. That’s expected. Start with your best understanding, then refine as you learn more about the domain. Watch for signs of misalignment: frequent cross-context transactions, teams stepping on each other’s code, or concepts that don’t quite fit their current home.

DDD is a journey, not a destination. Evans’ original book remains essential reading. Vaughn Vernon’s “Implementing Domain-Driven Design” provides practical implementation guidance. For strategic patterns specifically, explore “Domain-Driven Design Distilled” for a condensed overview.

The investment in proper domain modeling pays dividends in systems that remain maintainable as complexity grows. But remember: apply these patterns where they solve real problems, not as architectural decoration.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.