Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Domain-Driven Design: Building Systems That Reflect Business Reality

Domain-Driven Design (DDD) is not merely a technical architecture pattern -- it is a modeling discipline. At its core, DDD is about building an accurate model of the business domain first, and only then deciding how infrastructure (databases, APIs, frameworks) serves that model. The domain model is the source of truth. Everything else is plumbing.

This puts DDD squarely within the broader field of Model-Driven Engineering (MDE). Where MDE provides the structural framework -- meta-metamodels, metamodels, models, and instances (M3 through M0) -- DDD provides the semantic content: the ubiquitous language, the aggregate boundaries, the invariants that encode business reality. In MDE terms, DDD's tactical patterns (Entity, Value Object, Aggregate, Repository) are M2 metamodel concepts, and a specific domain model (Order, Customer, Inventory) is an M1 model that conforms to those concepts.

The key insight shared by both disciplines: the model is not documentation. The model is the system.

Why Domain-Driven Design Matters

Too often, software projects fail not because of technical limitations, but because developers start with infrastructure and work backwards. They design database schemas first, then generate entities from tables, then wrap those entities in services. The resulting code models the database, not the business. When the business changes, the database-centric design resists every modification.

DDD inverts this. Model the business first. Let the domain dictate the structure. Infrastructure adapts to the model, not the other way around.

This is the same principle that drives meta-metamodeling: define what things mean (M2/M3), then derive what things do (code generation, validation, persistence). DDD applies this principle at the domain level:

  • The domain is supreme: Business rules live in the domain model, not scattered across application services or database triggers
  • Language matters: The code speaks the same language as the business -- the Ubiquitous Language is the model's vocabulary, just as M2 concepts define a DSL's vocabulary
  • Boundaries are explicit: Bounded Contexts separate domain models, just as separate M2 metamodels define distinct DSL languages

This approach reduces bugs at the source, makes systems easier to modify as requirements evolve, and enables non-technical stakeholders to understand core logic in the code.


Core Principles of Domain-Driven Design

1. The Ubiquitous Language

The Ubiquitous Language is a shared vocabulary between domain experts, product managers, and developers. The same terms appear in requirements documents, code, tests, and conversations.

Why it matters: When a developer and a domain expert speak different languages, misinterpretation becomes inevitable. Ubiquitous language eliminates this friction.

In practice:

  • If the business calls something an "Order", it's not a "Purchase" or "CartOrder" elsewhere in the codebase
  • Domain class names match business terminology exactly
  • Method names express business intent: PlaceOrder(), not Save()
  • Specification documents reference the same terms as the code

Example:

// ✓ Domain language aligned with business
public class Order
{
    public void Place(DeliveryAddress address) => /* ... */
}

// ✗ Inconsistent with business terminology
public class CartItem
{
    public void Save() => /* ... */
}

2. Domain Invariants and Business Rules

An invariant is a rule that must always be true about your domain. Business logic that enforces these invariants belongs entirely within the domain layer.

The principle: If a rule belongs to the internal state of an entity or aggregate, the code goes into the domain—never in application services or database triggers.

Example: An inventory system must never allow negative stock when fulfilling an order.

// Domain layer - where the rule lives
public class Inventory
{
    private int _quantity;

    public Result<int, InsufficientInventoryException> FulfillOrder(int orderedQuantity)
    {
        if (orderedQuantity > _quantity)
            return Result<int, InsufficientInventoryException>.Failure(
                new InsufficientInventoryException($"Only {_quantity} units available"));

        _quantity -= orderedQuantity;
        return Result<int, InsufficientInventoryException>.Success(_quantity);
    }
}

// ✗ Wrong: rule enforced outside the domain
public class FulfillOrderService
{
    public void Fulfill(Order order, Inventory inventory)
    {
        if (order.Quantity > inventory.Quantity) // Rule leaked to application layer!
            throw new Exception();
        inventory.Quantity -= order.Quantity;
    }
}

By placing the rule in the domain and returning a Result<TResult, TException> instead of throwing, you ensure the rule is enforced everywhere the entity is used, testable in isolation, and the caller is forced to handle the outcome. The typed exception in TException preserves full error context -- stack traces, domain-specific exception types -- without using exceptions for control flow.

3. Immutability and Value Objects

Immutable objects are predictable and thread-safe. Value Objects—objects identified by their values rather than identity—should be immutable by design.

Pattern: Replace primitive types with Value Objects, make them immutable, and validate in the constructor.

// ✓ Immutable Value Object
public sealed record Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new InvalidOperationException("Money cannot be negative");
        if (string.IsNullOrEmpty(currency))
            throw new InvalidOperationException("Currency is required");

        Amount = amount;
        Currency = currency;
    }

    public Result<Money, InvalidOperationException> Add(Money other)
    {
        if (other.Currency != Currency)
            return Result<Money, InvalidOperationException>.Failure(
                new InvalidOperationException("Cannot add different currencies"));

        return Result<Money, InvalidOperationException>.Success(
            new Money(Amount + other.Amount, Currency));
    }
}

// Usage
var price = new Money(19.99m, "USD");
var newPrice = price.Add(new Money(5m, "USD")); // Creates new instance
// price is unchanged ✓

4. Aggregates and Aggregate Roots

An Aggregate is a cluster of related entities and value objects that are treated as a single unit. The Aggregate Root is the entity that controls access to the entire aggregate.

Why boundaries matter:

  • Consistency: All business rules within the aggregate are enforced together
  • Performance: Load the entire aggregate, modify it, save it—in one transaction
  • Simplicity: Queries reference the aggregate root, not internal entities

Guidelines:

  • Keep aggregates small (ideally <5 entities)
  • Enforce rules at the aggregate root
  • Never query internal entities directly—always through the root
  • Use IDs to reference other aggregates, never direct object references
// ✓ Proper aggregate with clear boundary
public class Order // Aggregate Root
{
    public OrderId Id { get; }
    private readonly List<OrderLineItem> _lineItems = new();
    private OrderStatus _status;

    public IReadOnlyList<OrderLineItem> LineItems => _lineItems.AsReadOnly();

    public Result<OrderLineItem, InvalidOperationException> AddLineItem(
        ProductId productId, int quantity, Money unitPrice)
    {
        if (_status != OrderStatus.Draft)
            return Result<OrderLineItem, InvalidOperationException>.Failure(
                new InvalidOperationException("Cannot modify non-draft order"));

        var item = new OrderLineItem(productId, quantity, unitPrice);
        _lineItems.Add(item);
        return Result<OrderLineItem, InvalidOperationException>.Success(item);
    }

    public Result<Order, InvalidOperationException> Place()
    {
        if (!_lineItems.Any())
            return Result<Order, InvalidOperationException>.Failure(
                new InvalidOperationException("Order must have at least one item"));

        _status = OrderStatus.Placed;
        return Result<Order, InvalidOperationException>.Success(this);
    }
}

public class OrderLineItem // Internal entity
{
    public ProductId ProductId { get; }
    public int Quantity { get; }
    public Money UnitPrice { get; }
}

// ✗ Wrong: accessing internal entity directly
var lineItem = order.LineItems.FirstOrDefault();
lineItem.Quantity = 0; // No validation!

// ✓ Right: always modify through aggregate root
order.AddLineItem(productId, newQuantity, unitPrice);

Architectural Layers

DDD applications follow a clear, layered architecture with unidirectional dependencies:

┌─────────────────────────────────┐
│      User Interface             │
└──────────────┬──────────────────┘
               │
┌──────────────▼──────────────────┐
│   Application Services          │
│   (Use Case Orchestration)      │
└──────────────┬──────────────────┘
               │
┌──────────────▼──────────────────┐
│  Domain (Business Rules)        │
│  (Entities, Aggregates, VOs)    │
└──────────────┬──────────────────┘
               │
┌──────────────▼──────────────────┐
│   Infrastructure                │
│   (Data, External Services)     │
└─────────────────────────────────┘

The rule: Never depend upward; dependencies flow downward. The domain never knows about infrastructure details.

Layer Responsibilities

Domain Layer:

  • Entities, Aggregate Roots, Value Objects
  • Business rule logic and invariant enforcement
  • Domain Services (stateless operations across aggregates)
  • Zero knowledge of HTTP, databases, or frameworks

Application Layer:

  • Use case orchestration (Commands and Queries)
  • Transaction management
  • Repository interfaces (implementations in Infrastructure)
  • Data Transfer Objects (DTOs) for responses
  • Application Services that coordinate domain objects

Infrastructure Layer:

  • Repository implementations
  • Entity Framework mappings
  • External API integrations
  • Caching, logging, file storage
  • Background job workers

Presentation Layer:

  • Controllers
  • Request/response mapping
  • User authentication
  • API documentation

Tactical Patterns: Building Blocks

Repositories

Repositories abstract data access and represent collections. They accept and return domain objects—never exposing database details.

// Domain layer - Interface only
public interface IOrderRepository
{
    Task<Order> GetByIdAsync(OrderId id);
    Task SaveAsync(Order order);
    Task DeleteAsync(OrderId id);
}

// Infrastructure layer - Implementation
public class OrderRepository : IOrderRepository
{
    private readonly DbContext _context;

    public async Task<Order> GetByIdAsync(OrderId id)
    {
        return await _context.Orders
            .FirstOrDefaultAsync(o => o.Id == id);
    }

    public async Task SaveAsync(Order order)
    {
        _context.Orders.Update(order);
        await _context.SaveChangesAsync();
    }
}

// Application layer - Usage
public class PlaceOrderService
{
    private readonly IOrderRepository _repository;

    public async Task<Result<Order, InvalidOperationException>> ExecuteAsync(PlaceOrderCommand command)
    {
        var order = await _repository.GetByIdAsync(command.OrderId);

        var result = order.Place();
        if (!result.IsSuccess)
            return result; // Propagate the typed error

        await _repository.SaveAsync(order);
        return result;
    }
}

Domain Services

When logic spans multiple aggregates and has no natural home in a single entity, use a Domain Service.

Important: Domain Services are stateless and contain only logic—never repositories or infrastructure concerns.

public class ShippingCalculationService
{
    public Money CalculateShipping(Order order, DeliveryAddress address)
    {
        var baseRate = new Money(10m, order.Total.Currency);
        var distance = address.Distance;

        // Business rule: domestic shipping is flat rate
        if (address.Country == "USA")
            return baseRate;

        // International: add surcharge based on distance zones
        return baseRate.Add(new Money(distance switch
        {
            < 1000 => 15m,
            < 5000 => 30m,
            _ => 50m
        }, order.Total.Currency));
    }
}

Specifications

Specifications encapsulate complex query logic as reusable, testable objects.

public abstract class Specification<T>
{
    public IQueryable<T> Apply(IQueryable<T> query)
    {
        query = ApplyCriteria(query);
        query = Includes.Aggregate(query, (current, include) => current);
        return query;
    }

    protected abstract IQueryable<T> ApplyCriteria(IQueryable<T> query);
    protected List<Expression<Func<T, object>>> Includes { get; } = new();
}

public class ActiveOrdersSpecification : Specification<Order>
{
    protected override IQueryable<Order> ApplyCriteria(IQueryable<Order> query)
    {
        return query.Where(o => o.Status == OrderStatus.Placed);
    }
}

// Usage
var orders = await _repository.GetAsync(new ActiveOrdersSpecification());

Strategic Patterns: Bounded Contexts

As systems grow, divide them into Bounded Contexts—explicit boundaries where each context defines its own ubiquitous language and domain model.

Context Mapping

Contexts interact through well-defined integration points:

┌──────────────────────┐      ┌──────────────────────┐
│   Orders Context     │      │   Inventory Context  │
│                      │  →   │                      │
│  (Relationship: ACL) │      │  (Implementation)    │
└──────────────────────┘      └──────────────────────┘

Anti-Corruption Layer (ACL): Translates between contexts, preventing foreign domain logic from contaminating your context.

// Orders context uses its own model
public class Order { }

// Inventory context API exposes a different model
public class InventoryItem { }

// Anti-Corruption Layer translates
public class InventoryAdapter
{
    private readonly IInventoryClient _client;

    public async Task<Result<ReservationConfirmation, OutOfStockException>> ReserveInventoryAsync(Order order)
    {
        var inventoryRequest = new ReservationRequest
        {
            Items = order.LineItems.Select(li => new InventoryLine
            {
                ProductCode = li.ProductId.Value,
                Quantity = li.Quantity
            }).ToList()
        };

        var response = await _client.ReserveAsync(inventoryRequest);

        // Translate back to Orders context language
        if (!response.IsSuccessful)
            return Result<ReservationConfirmation, OutOfStockException>.Failure(
                new OutOfStockException(response.ErrorMessage));

        return Result<ReservationConfirmation, OutOfStockException>.Success(
            new ReservationConfirmation(response.ReservationId));
    }
}

Transactional Outbox Pattern

Domain events represent important business occurrences. Publishing them reliably is critical.

The Problem: If you save an aggregate to the database then try to publish events, network failure could leave the system inconsistent.

The Solution: Write aggregate changes AND domain events to the same database transaction using the Outbox pattern.

// Domain event
public abstract record DomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
}

public record OrderPlacedEvent(OrderId OrderId, Money Total) : DomainEvent;

// Aggregate publishes events
public class Order
{
    private readonly List<DomainEvent> _events = new();
    public IReadOnlyList<DomainEvent> Events => _events.AsReadOnly();

    public void Place()
    {
        _status = OrderStatus.Placed;
        _events.Add(new OrderPlacedEvent(Id, Total));
    }
}

// Application service: single transaction
public class PlaceOrderService
{
    public async Task ExecuteAsync(PlaceOrderCommand command)
    {
        var order = new Order(command.OrderId);
        order.AddLineItems(command.LineItems);
        order.Place();

        // Single transaction: aggregate + events
        await _repository.SaveAsync(order);

        // Events persist in the same transaction ✓
        foreach (var evt in order.Events)
            await _outbox.PublishAsync(evt);
    }
}

// Background worker publishes events asynchronously
public class OutboxDispatcher
{
    public async Task PublishPendingEventsAsync()
    {
        var unpublished = await _context.OutboxEvents
            .Where(e => !e.IsPublished)
            .ToListAsync();

        foreach (var evt in unpublished)
        {
            await _eventBus.PublishAsync(evt);
            evt.MarkAsPublished();
        }

        await _context.SaveChangesAsync();
    }
}

Key Anti-Patterns to Avoid

1. Anemic Domain Model

Problem: Entities contain only properties; all logic lives in services.

// ✗ Wrong: No domain logic
public class Order
{
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

public class OrderService
{
    public void PlaceOrder(Order order)
    {
        if (order.Quantity <= 0) // Logic in service!
            throw new Exception();
    }
}

// ✓ Right: Logic in the domain, returning Result
public class Order
{
    public Result<Order, InvalidOperationException> Place()
    {
        if (Quantity <= 0)
            return Result<Order, InvalidOperationException>.Failure(
                new InvalidOperationException("Quantity must be positive"));

        return Result<Order, InvalidOperationException>.Success(this);
    }
}

2. Invariants Enforced Elsewhere

Problem: Business rules checked in the application layer, not the domain.

This means the rule can be bypassed if the domain object is used in a different context—tests, batch jobs, migrations, etc.

3. Premature CQRS/Event Sourcing

Problem: Adding complexity before it's needed.

Start with simple CRUD. Add CQRS only when query and command models genuinely diverge. Event Sourcing adds significant complexity and should only be adopted for specific bounded contexts where audit trails or temporal queries are critical.

4. Over-Aggregation

Problem: Putting too many entities in one aggregate.

Large aggregates become harder to load, modify, and test. Keep them small—typically 3-5 entities maximum.

5. Direct Database Queries in Application Services

Problem: Bypassing repository abstractions.

This tightly couples you to a specific database technology and makes testing difficult.


Practical Implementation Checklist

When building a DDD system:

  • Define the Ubiquitous Language: Document key terms with domain experts
  • Identify Aggregates: Find natural consistency boundaries
  • Enforce Invariants: Place all business rules in the domain
  • Design Repositories: Create abstractions for data access
  • Separate Layers: Ensure unidirectional dependencies
  • Map Value Objects: Replace primitives with type-safe objects
  • Test Domain Logic: Write unit tests for entities and services
  • Plan Context Integration: Design ACLs for cross-context communication
  • Implement Outbox: Use transactional outbox for reliable events
  • Document Decisions: Capture mapping between code and business terms

DDD as a Modeling Discipline

Everything described above -- Entities, Value Objects, Aggregates, Repositories, Domain Services, Bounded Contexts -- is a modeling vocabulary. DDD gives you a set of concepts for describing business domains. In Model-Driven Engineering terms, these concepts form an M2 metamodel:

DDD Concept M2 Role What It Models
Entity Structural concept with identity Business objects that persist and change over time
Value Object Structural concept without identity Immutable descriptors (Money, Address, DateRange)
Aggregate Containment boundary Consistency unit -- what gets loaded/saved together
Aggregate Root Entry point constraint The only entity accessible from outside the boundary
Repository Interface abstraction Collection semantics over persistence
Domain Service Stateless operation Cross-aggregate business logic
Domain Event Notification concept Something important that happened in the domain
Bounded Context Namespace/package A distinct model with its own Ubiquitous Language

When this metamodel is formalized -- as C# attributes backed by a meta-metamodel -- the compiler can enforce DDD rules at build time:

// M2: DDD metamodel expressed as attributes (built on M3 primitives)
[MetaConcept("Aggregate")]
[MetaConstraint("HasRoot", "Properties.Any(p => p.IsAggregateRoot)",
    Message = "Every aggregate must designate an aggregate root")]
public sealed class AggregateAttribute : Attribute { /* ... */ }

// M1: Domain model that conforms to the DDD metamodel
[Aggregate("Order")]
public partial class Order
{
    [AggregateRoot]
    [Property("Id", "OrderId", IsKey = true)]
    public partial OrderId Id { get; }

    [Property("Status", "OrderStatus")]
    public partial OrderStatus Status { get; }

    [ValueObject]
    [Property("Total", "Money")]
    public partial Money Total { get; }
}

The source generator validates that every [Aggregate] has a root, that [ValueObject] types are immutable, that internal entities are not referenced from outside their aggregate. Violations become compiler errors, not code review comments.

This is where DDD and MDE converge: DDD provides the domain semantics, MDE provides the structural enforcement. The Ubiquitous Language becomes a DSL. Aggregate boundaries become compile-time constraints. Domain invariants become generated validators. The model is not just documentation -- it is the code, and the compiler guarantees it.


Conclusion

Domain-Driven Design is, at its heart, a modeling practice. It asks you to build an accurate model of the business before thinking about databases, APIs, or frameworks. The tactical patterns (Entities, Aggregates, Value Objects) are the vocabulary of that model. The strategic patterns (Bounded Contexts, Context Maps) are its architecture.

When combined with a meta-metamodeling framework, DDD's concepts become formal metamodel elements -- compile-time checked, source-generated, and structurally enforced. The Ubiquitous Language stops being a convention and becomes a DSL. The domain model stops being "the code that happens to be in the Domain project" and becomes a first-class artifact that the compiler understands.

Start small: pick a bounded context, model it rigorously using DDD patterns, and let the domain drive every architectural decision. Infrastructure serves the model. The model serves the business. That ordering is the entire point.