Back to blog
architectureintermediate

Domain-Driven Design Fundamentals in .NET

Learn DDD from the ground up: ubiquitous language, bounded contexts, aggregates, value objects, domain events, and repositories with practical C# examples.

Asma HafeezApril 17, 202618 min read
ddddomain-driven-designdotnetcsharpaggregatesvalue-objectsdomain-events
Share:𝕏

Domain-Driven Design Fundamentals in .NET

Domain-Driven Design (DDD) is a software development approach that puts the business domain at the center of design decisions. Introduced by Eric Evans in his 2003 book, DDD gives us a vocabulary and a set of patterns for modelling complex domains in code.

The central insight: code should reflect the business, not the other way around. When a business expert says "an order is submitted", your code should have an Order with a Submit() method — not a UpdateOrderStatusService that sets a property.

The Building Blocks

DDD has two categories of patterns:

Strategic patterns — how you divide and organize large systems:

  • Ubiquitous Language
  • Bounded Contexts
  • Context Mapping

Tactical patterns — how you model within a single bounded context:

  • Entities
  • Value Objects
  • Aggregates and Aggregate Roots
  • Domain Events
  • Repositories
  • Domain Services

Let's explore each with a running example: an Order management domain.

Ubiquitous Language

The ubiquitous language is a shared vocabulary agreed upon by developers and domain experts. Every class name, method name, and variable should come from this language.

Without ubiquitous language:

C#
// Terrible: developer jargon, not domain language
public class DataRecord
{
    public int TypeId { get; set; }        // What is a "type"?
    public decimal Num1 { get; set; }      // Is this price? quantity?
    public int StatusCode { get; set; }    // What do the codes mean?
    public void ProcessRecord() { }        // Process... how?
}

With ubiquitous language:

C#
// Good: mirrors how the business talks about the domain
public class Order
{
    public Money TotalAmount { get; private set; }
    public OrderStatus Status { get; private set; }

    public void Submit() { /* business rule here */ }
    public void Confirm() { /* business rule here */ }
    public void Ship(TrackingNumber trackingNumber) { /* business rule here */ }
}

When the business says "the order is submitted", there is literally a Submit() method. When they say "the order is delivered", there is a Delivered status. No translation layer in your head needed.

Bounded Contexts

A bounded context is an explicit boundary within which a domain model applies. The same concept (e.g., "customer") can mean different things in different bounded contexts.

┌─────────────────────────────────────────────────────────────────┐
│  E-Commerce Platform                                            │
│                                                                 │
│  ┌─────────────────┐   ┌──────────────────┐   ┌─────────────┐  │
│  │  Order Context  │   │  Catalog Context │   │  Shipping   │  │
│  │                 │   │                  │   │  Context    │  │
│  │  Customer =     │   │  Customer =      │   │             │  │
│  │  Buyer with     │   │  User who        │   │  Recipient  │  │
│  │  payment method │   │  browses items   │   │  address    │  │
│  └─────────────────┘   └──────────────────┘   └─────────────┘  │
└─────────────────────────────────────────────────────────────────┘

Each bounded context owns its own model. The Customer in the Order context cares about payment methods. The Customer in the Catalog context cares about browsing preferences. They are not the same class shared across contexts.

C#
// Order Context — Customer = who is paying
namespace OrderContext.Domain;

public class Customer
{
    public Guid Id { get; private set; }
    public string Email { get; private set; } = string.Empty;
    public IReadOnlyList<PaymentMethod> PaymentMethods { get; } = new List<PaymentMethod>();
    public Address DefaultShippingAddress { get; private set; } = null!;
}

// Catalog Context — Customer = who is browsing
namespace CatalogContext.Domain;

public class Customer
{
    public Guid Id { get; private set; }
    public string Email { get; private set; } = string.Empty;
    public IReadOnlyList<Guid> WishlistProductIds { get; } = new List<Guid>();
    public string PreferredLanguage { get; private set; } = "en";
}

They both have an Id that lets you correlate them (likely the same user), but they model very different things.

Entities

An entity is an object with a unique identity that persists through time. Two entities are equal if they have the same identity, regardless of their current attribute values.

C#
// src/OrderContext.Domain/Entities/Order.cs
namespace OrderContext.Domain.Entities;

public class Order : IEquatable<Order>
{
    private readonly List<OrderItem> _items = new();
    private readonly List<DomainEvent> _domainEvents = new();

    private Order() { } // For EF Core

    public Order(Guid customerId, Address shippingAddress)
    {
        ArgumentNullException.ThrowIfNull(shippingAddress);

        if (customerId == Guid.Empty)
            throw new ArgumentException("Customer ID cannot be empty.", nameof(customerId));

        Id              = Guid.NewGuid();
        CustomerId      = customerId;
        ShippingAddress = shippingAddress;
        Status          = OrderStatus.Draft;
        CreatedAt       = DateTime.UtcNow;
        OrderNumber     = GenerateOrderNumber();
    }

    public Guid Id { get; private set; }
    public string OrderNumber { get; private set; } = string.Empty;
    public Guid CustomerId { get; private set; }
    public Address ShippingAddress { get; private set; } = null!;
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? SubmittedAt { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    // Identity equality — two orders are equal if they have the same ID
    public bool Equals(Order? other) => other is not null && Id == other.Id;
    public override bool Equals(object? obj) => Equals(obj as Order);
    public override int GetHashCode() => Id.GetHashCode();
    public static bool operator ==(Order? left, Order? right) =>
        left is null ? right is null : left.Equals(right);
    public static bool operator !=(Order? left, Order? right) => !(left == right);

    private static string GenerateOrderNumber() =>
        $"ORD-{DateTime.UtcNow:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpper()}";

    public void ClearDomainEvents() => _domainEvents.Clear();

    // Business methods that enforce invariants
    public void AddItem(Product product, int quantity)
    {
        EnsureStatus(OrderStatus.Draft, "add items to");

        if (quantity <= 0)
            throw new DomainException("Quantity must be greater than zero.");

        var existingItem = _items.FirstOrDefault(i => i.ProductId == product.Id);
        if (existingItem is not null)
        {
            existingItem.IncreaseQuantity(quantity);
            return;
        }

        _items.Add(OrderItem.Create(Id, product, quantity));
    }

    public void RemoveItem(Guid productId)
    {
        EnsureStatus(OrderStatus.Draft, "remove items from");

        var item = _items.FirstOrDefault(i => i.ProductId == productId)
            ?? throw new DomainException($"Product {productId} is not in this order.");

        _items.Remove(item);
    }

    public void UpdateShippingAddress(Address newAddress)
    {
        ArgumentNullException.ThrowIfNull(newAddress);

        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("Cannot change shipping address after order has shipped.");

        ShippingAddress = newAddress;
    }

    public void Submit()
    {
        EnsureStatus(OrderStatus.Draft, "submit");

        if (!_items.Any())
            throw new DomainException("Cannot submit an empty order.");

        Status      = OrderStatus.Submitted;
        SubmittedAt = DateTime.UtcNow;

        _domainEvents.Add(new OrderSubmittedEvent(
            OrderId:    Id,
            OrderNumber: OrderNumber,
            CustomerId: CustomerId,
            TotalAmount: CalculateTotal(),
            SubmittedAt: SubmittedAt.Value
        ));
    }

    public void Confirm(Guid confirmedByUserId)
    {
        EnsureStatus(OrderStatus.Submitted, "confirm");

        Status = OrderStatus.Confirmed;
        _domainEvents.Add(new OrderConfirmedEvent(Id, confirmedByUserId));
    }

    public void Ship(TrackingNumber trackingNumber)
    {
        EnsureStatus(OrderStatus.Confirmed, "ship");
        ArgumentNullException.ThrowIfNull(trackingNumber);

        Status = OrderStatus.Shipped;
        _domainEvents.Add(new OrderShippedEvent(Id, trackingNumber));
    }

    public void Deliver()
    {
        EnsureStatus(OrderStatus.Shipped, "deliver");
        Status = OrderStatus.Delivered;
        _domainEvents.Add(new OrderDeliveredEvent(Id, DateTime.UtcNow));
    }

    public Money CalculateTotal()
    {
        if (!_items.Any())
            return Money.Zero("USD");

        var total = _items.Aggregate(
            Money.Zero(_items[0].UnitPrice.Currency),
            (acc, item) => acc.Add(item.LineTotal));

        return total;
    }

    private void EnsureStatus(OrderStatus expected, string action)
    {
        if (Status != expected)
            throw new DomainException(
                $"Cannot {action} an order with status '{Status}'. Expected '{expected}'.");
    }
}
C#
// src/OrderContext.Domain/Entities/OrderItem.cs
namespace OrderContext.Domain.Entities;

public class OrderItem : IEquatable<OrderItem>
{
    private OrderItem() { } // For EF Core

    private OrderItem(Guid orderId, Guid productId, string productName,
                      Money unitPrice, int quantity)
    {
        Id          = Guid.NewGuid();
        OrderId     = orderId;
        ProductId   = productId;
        ProductName = productName;
        UnitPrice   = unitPrice;
        Quantity    = quantity;
    }

    public static OrderItem Create(Guid orderId, Product product, int quantity) =>
        new(orderId, product.Id, product.Name, product.Price, quantity);

    public Guid Id { get; private set; }
    public Guid OrderId { get; private set; }
    public Guid ProductId { get; private set; }
    public string ProductName { get; private set; } = string.Empty;
    public Money UnitPrice { get; private set; } = null!;
    public int Quantity { get; private set; }
    public Money LineTotal => UnitPrice.Multiply(Quantity);

    internal void IncreaseQuantity(int additional)
    {
        if (additional <= 0)
            throw new DomainException("Additional quantity must be positive.");
        Quantity += additional;
    }

    internal void SetQuantity(int newQuantity)
    {
        if (newQuantity <= 0)
            throw new DomainException("Quantity must be positive.");
        Quantity = newQuantity;
    }

    public bool Equals(OrderItem? other) => other is not null && Id == other.Id;
    public override bool Equals(object? obj) => Equals(obj as OrderItem);
    public override int GetHashCode() => Id.GetHashCode();
}

Value Objects

A value object has no identity. Two value objects are equal when all their attributes are equal. Value objects should be immutable.

C#
// src/OrderContext.Domain/ValueObjects/Money.cs
namespace OrderContext.Domain.ValueObjects;

/// <summary>
/// Represents a monetary amount with currency.
/// Structural equality: Money(10, "USD") == Money(10, "USD")
/// </summary>
public sealed record Money
{
    public decimal Amount { get; init; }
    public string Currency { get; init; }

    public Money(decimal amount, string currency)
    {
        if (amount < 0)
            throw new DomainException($"Money amount cannot be negative: {amount}");

        if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
            throw new DomainException($"Currency must be a 3-letter ISO code: '{currency}'");

        Amount   = Math.Round(amount, 2, MidpointRounding.AwayFromZero);
        Currency = currency.ToUpperInvariant();
    }

    public static Money Zero(string currency) => new(0m, currency);

    public Money Add(Money other)
    {
        GuardSameCurrency(other);
        return new Money(Amount + other.Amount, Currency);
    }

    public Money Subtract(Money other)
    {
        GuardSameCurrency(other);
        if (Amount < other.Amount)
            throw new DomainException($"Insufficient amount: {this} < {other}");
        return new Money(Amount - other.Amount, Currency);
    }

    public Money Multiply(int factor)
    {
        if (factor < 0)
            throw new DomainException("Multiplication factor cannot be negative.");
        return new Money(Amount * factor, Currency);
    }

    public Money ApplyDiscount(decimal discountPercent)
    {
        if (discountPercent < 0 || discountPercent > 100)
            throw new DomainException("Discount must be between 0 and 100.");
        var discountAmount = Amount * (discountPercent / 100m);
        return new Money(Amount - discountAmount, Currency);
    }

    public bool IsGreaterThan(Money other)
    {
        GuardSameCurrency(other);
        return Amount > other.Amount;
    }

    public bool IsLessThan(Money other)
    {
        GuardSameCurrency(other);
        return Amount < other.Amount;
    }

    private void GuardSameCurrency(Money other)
    {
        if (Currency != other.Currency)
            throw new DomainException(
                $"Currency mismatch: cannot operate on {Currency} and {other.Currency}.");
    }

    public override string ToString() => $"{Amount:F2} {Currency}";
}
C#
// src/OrderContext.Domain/ValueObjects/Address.cs
namespace OrderContext.Domain.ValueObjects;

public sealed record Address
{
    public string Line1 { get; init; }
    public string? Line2 { get; init; }
    public string City { get; init; }
    public string StateOrProvince { get; init; }
    public string PostalCode { get; init; }
    public string CountryCode { get; init; }  // ISO 3166-1 alpha-2

    public Address(string line1, string? line2, string city,
                   string stateOrProvince, string postalCode, string countryCode)
    {
        if (string.IsNullOrWhiteSpace(line1)) throw new DomainException("Street line 1 required.");
        if (string.IsNullOrWhiteSpace(city)) throw new DomainException("City required.");
        if (string.IsNullOrWhiteSpace(stateOrProvince)) throw new DomainException("State required.");
        if (string.IsNullOrWhiteSpace(postalCode)) throw new DomainException("PostalCode required.");
        if (string.IsNullOrWhiteSpace(countryCode) || countryCode.Length != 2)
            throw new DomainException("Country code must be 2-letter ISO code.");

        Line1           = line1.Trim();
        Line2           = line2?.Trim();
        City            = city.Trim();
        StateOrProvince = stateOrProvince.Trim();
        PostalCode      = postalCode.Trim().ToUpperInvariant();
        CountryCode     = countryCode.Trim().ToUpperInvariant();
    }

    public override string ToString() =>
        $"{Line1}{(Line2 is not null ? ", " + Line2 : "")}, {City}, {StateOrProvince} {PostalCode}, {CountryCode}";
}
C#
// src/OrderContext.Domain/ValueObjects/TrackingNumber.cs
namespace OrderContext.Domain.ValueObjects;

public sealed record TrackingNumber
{
    private static readonly Regex _pattern = new(@"^[A-Z]{2}\d{9}[A-Z]{2}$",
        RegexOptions.Compiled, TimeSpan.FromSeconds(1));

    public string Value { get; init; }

    public TrackingNumber(string value)
    {
        if (string.IsNullOrWhiteSpace(value))
            throw new DomainException("Tracking number cannot be empty.");

        var normalized = value.Trim().ToUpperInvariant();

        // Validate against a carrier format (simplified)
        if (!_pattern.IsMatch(normalized) && normalized.Length < 10)
            throw new DomainException($"Invalid tracking number format: '{value}'");

        Value = normalized;
    }

    public static TrackingNumber Generate() =>
        new($"LX{Random.Shared.Next(100_000_000, 999_999_999)}GB");

    public override string ToString() => Value;

    // Implicit conversion for convenience
    public static implicit operator string(TrackingNumber t) => t.Value;
}

Entity vs Value Object — A Decision Guide

Ask yourself:
  Does this thing have a unique identity that matters?
  
  YES → Entity
    Example: Order, Customer, Product
    Two orders with same items are NOT the same order.
    
  NO → Value Object
    Example: Money, Address, TrackingNumber
    Two Money(10, "USD") values ARE the same.
    Replace them freely. They are interchangeable.

Aggregates and Aggregate Roots

An aggregate is a cluster of domain objects that are treated as a single unit. The aggregate root is the only object in the cluster that external objects may hold a reference to.

Rules:

  1. External objects reference only the aggregate root, never inner objects directly.
  2. Only the aggregate root may be loaded from the repository.
  3. All invariants within the aggregate boundary are enforced by the aggregate root.
  4. Changes to the aggregate are persisted in a single transaction.
C#
// The Order is the aggregate root.
// OrderItem belongs to the Order aggregate.
// External code NEVER holds a reference to OrderItem directly.
// To change an OrderItem, you go through Order.

// WRONG: holding a direct reference to OrderItem
var item = orderItemRepository.GetById(itemId); // ❌ Don't do this
item.SetQuantity(5);

// RIGHT: go through the aggregate root
var order = orderRepository.GetById(orderId);   // ✅
order.UpdateItemQuantity(productId, 5);
orderRepository.Save(order);

The Order aggregate enforces all invariants:

  • An order cannot be submitted with zero items
  • An item quantity must be positive
  • Items can only be added to draft orders

These rules live in the Order class, not scattered across services.

Domain Events

Domain events represent something that happened in the domain. They are immutable records of the past.

C#
// src/OrderContext.Domain/Events/DomainEvent.cs
namespace OrderContext.Domain.Events;

public abstract record DomainEvent
{
    public Guid EventId   { get; } = Guid.NewGuid();
    public DateTime OccurredAt { get; } = DateTime.UtcNow;
    public int Version { get; init; } = 1;
}
C#
// src/OrderContext.Domain/Events/OrderSubmittedEvent.cs
namespace OrderContext.Domain.Events;

public sealed record OrderSubmittedEvent(
    Guid OrderId,
    string OrderNumber,
    Guid CustomerId,
    Money TotalAmount,
    DateTime SubmittedAt
) : DomainEvent;
C#
// src/OrderContext.Domain/Events/OrderConfirmedEvent.cs
namespace OrderContext.Domain.Events;

public sealed record OrderConfirmedEvent(
    Guid OrderId,
    Guid ConfirmedByUserId
) : DomainEvent;
C#
// src/OrderContext.Domain/Events/OrderShippedEvent.cs
namespace OrderContext.Domain.Events;

public sealed record OrderShippedEvent(
    Guid OrderId,
    TrackingNumber TrackingNumber
) : DomainEvent;
C#
// src/OrderContext.Domain/Events/OrderDeliveredEvent.cs
namespace OrderContext.Domain.Events;

public sealed record OrderDeliveredEvent(
    Guid OrderId,
    DateTime DeliveredAt
) : DomainEvent;

Collecting and Dispatching Domain Events

A common pattern: aggregates collect events, and after persistence the application dispatches them.

C#
// src/OrderContext.Domain/Common/AggregateRoot.cs
namespace OrderContext.Domain.Common;

public abstract class AggregateRoot
{
    private readonly List<DomainEvent> _domainEvents = new();

    public IReadOnlyList<DomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    protected void RaiseDomainEvent(DomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public void ClearDomainEvents()
    {
        _domainEvents.Clear();
    }
}
C#
// Order now inherits from AggregateRoot
public class Order : AggregateRoot
{
    // ...
    public void Submit()
    {
        EnsureStatus(OrderStatus.Draft, "submit");

        if (!_items.Any())
            throw new DomainException("Cannot submit an empty order.");

        Status      = OrderStatus.Submitted;
        SubmittedAt = DateTime.UtcNow;

        // Raise via base class — stored in _domainEvents list
        RaiseDomainEvent(new OrderSubmittedEvent(
            OrderId:     Id,
            OrderNumber: OrderNumber,
            CustomerId:  CustomerId,
            TotalAmount: CalculateTotal(),
            SubmittedAt: SubmittedAt.Value
        ));
    }
}
C#
// src/OrderContext.Application/Orders/SubmitOrder/SubmitOrderHandler.cs
namespace OrderContext.Application.Orders.SubmitOrder;

public class SubmitOrderHandler
{
    private readonly IOrderRepository _repository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IDomainEventDispatcher _dispatcher;

    public SubmitOrderHandler(
        IOrderRepository repository,
        IUnitOfWork unitOfWork,
        IDomainEventDispatcher dispatcher)
    {
        _repository = repository;
        _unitOfWork = unitOfWork;
        _dispatcher = dispatcher;
    }

    public async Task HandleAsync(SubmitOrderCommand command, CancellationToken ct = default)
    {
        var order = await _repository.GetByIdAsync(command.OrderId, ct)
            ?? throw new NotFoundException($"Order {command.OrderId} not found.");

        order.Submit();

        await _unitOfWork.SaveChangesAsync(ct);

        // Dispatch domain events AFTER successful persistence
        await _dispatcher.DispatchAsync(order.DomainEvents, ct);
        order.ClearDomainEvents();
    }
}

Repositories

The repository pattern abstracts data access behind an interface. In DDD, the interface lives in the domain and the implementation lives in infrastructure.

Interface (Domain Layer)

C#
// src/OrderContext.Domain/Repositories/IOrderRepository.cs
namespace OrderContext.Domain.Repositories;

/// <summary>
/// Provides access to the Order aggregate.
/// Only aggregate roots have repositories.
/// Note: No IOrderItemRepository — items are always accessed via their aggregate root.
/// </summary>
public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<Order?> GetByOrderNumberAsync(string orderNumber, CancellationToken ct = default);
    Task<IReadOnlyList<Order>> GetByCustomerIdAsync(
        Guid customerId, int page = 1, int pageSize = 20, CancellationToken ct = default);
    Task<int> CountByCustomerIdAsync(Guid customerId, CancellationToken ct = default);
    void Add(Order order);
    void Update(Order order);
}

Implementation (Infrastructure Layer)

C#
// src/OrderContext.Infrastructure/Repositories/OrderRepository.cs
namespace OrderContext.Infrastructure.Repositories;

public class OrderRepository : IOrderRepository
{
    private readonly OrderDbContext _context;

    public OrderRepository(OrderDbContext context)
    {
        _context = context;
    }

    public async Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .AsSplitQuery()
            .FirstOrDefaultAsync(o => o.Id == id, ct);
    }

    public async Task<Order?> GetByOrderNumberAsync(string orderNumber, CancellationToken ct = default)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .FirstOrDefaultAsync(o => o.OrderNumber == orderNumber, ct);
    }

    public async Task<IReadOnlyList<Order>> GetByCustomerIdAsync(
        Guid customerId, int page = 1, int pageSize = 20, CancellationToken ct = default)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .Skip((page - 1) * pageSize)
            .Take(pageSize)
            .Include(o => o.Items)
            .AsSplitQuery()
            .ToListAsync(ct);
    }

    public async Task<int> CountByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
    {
        return await _context.Orders.CountAsync(o => o.CustomerId == customerId, ct);
    }

    public void Add(Order order) => _context.Orders.Add(order);

    public void Update(Order order) => _context.Orders.Update(order);
}

Domain Services

Sometimes business logic doesn't naturally belong to any single entity. That is when you introduce a domain service — a stateless service that operates on domain objects.

C#
// src/OrderContext.Domain/Services/PricingService.cs
namespace OrderContext.Domain.Services;

/// <summary>
/// Calculates the final price of an order considering promotions and customer tier.
/// This logic doesn't naturally fit Order or Customer alone, so it's a domain service.
/// </summary>
public class PricingService
{
    private readonly IPromotionRepository _promotions;

    public PricingService(IPromotionRepository promotions)
    {
        _promotions = promotions;
    }

    public async Task<Money> CalculateFinalPriceAsync(
        Order order,
        Customer customer,
        CancellationToken ct = default)
    {
        var baseTotal = order.CalculateTotal();
        var activePromotions = await _promotions.GetActivePromotionsAsync(ct);

        // Apply customer tier discount
        var tierDiscount = customer.Tier switch
        {
            CustomerTier.Silver  => 0.05m,  // 5%
            CustomerTier.Gold    => 0.10m,  // 10%
            CustomerTier.Platinum => 0.15m, // 15%
            _ => 0m
        };

        var afterTierDiscount = baseTotal.ApplyDiscount(tierDiscount * 100);

        // Apply best promotion (cannot stack promotions)
        var bestPromotion = activePromotions
            .Where(p => p.Applies(order, customer))
            .OrderByDescending(p => p.DiscountPercent)
            .FirstOrDefault();

        if (bestPromotion is null)
            return afterTierDiscount;

        var afterPromotion = baseTotal.ApplyDiscount(bestPromotion.DiscountPercent * 100);

        // Take the better of tier discount or promotion
        return afterPromotion.IsLessThan(afterTierDiscount) ? afterPromotion : afterTierDiscount;
    }
}
C#
// src/OrderContext.Domain/Services/OrderFulfillmentService.cs
namespace OrderContext.Domain.Services;

/// <summary>
/// Coordinates the fulfillment of an order.
/// Spans multiple aggregates (Order + Inventory), so it's a domain service.
/// </summary>
public class OrderFulfillmentService
{
    public FulfillmentResult CheckFulfillability(Order order, Inventory inventory)
    {
        var unavailableItems = new List<UnavailableItem>();

        foreach (var item in order.Items)
        {
            var stock = inventory.GetStock(item.ProductId);

            if (stock is null || stock.AvailableQuantity < item.Quantity)
            {
                unavailableItems.Add(new UnavailableItem(
                    ProductId:         item.ProductId,
                    ProductName:       item.ProductName,
                    RequestedQuantity: item.Quantity,
                    AvailableQuantity: stock?.AvailableQuantity ?? 0
                ));
            }
        }

        return unavailableItems.Any()
            ? FulfillmentResult.Unavailable(unavailableItems)
            : FulfillmentResult.Available();
    }
}

Putting It All Together: A Complete Scenario

Here is the full flow from HTTP request to domain events dispatched:

C#
// POST /api/orders/{orderId}/submit
// → SubmitOrderHandler → Order.Submit() → OrderSubmittedEvent
// → OrderSubmittedEmailHandler (send confirmation)
// → OrderSubmittedInventoryHandler (reserve stock)
// → OrderSubmittedAnalyticsHandler (track conversion)

public class SubmitOrderHandler
{
    private readonly IOrderRepository _repository;
    private readonly ICustomerRepository _customerRepository;
    private readonly PricingService _pricingService;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IDomainEventDispatcher _dispatcher;

    public SubmitOrderHandler(
        IOrderRepository repository,
        ICustomerRepository customerRepository,
        PricingService pricingService,
        IUnitOfWork unitOfWork,
        IDomainEventDispatcher dispatcher)
    {
        _repository         = repository;
        _customerRepository = customerRepository;
        _pricingService     = pricingService;
        _unitOfWork         = unitOfWork;
        _dispatcher         = dispatcher;
    }

    public async Task<SubmitOrderResult> HandleAsync(
        SubmitOrderCommand command,
        CancellationToken ct = default)
    {
        // Load aggregates
        var order = await _repository.GetByIdAsync(command.OrderId, ct)
            ?? throw new NotFoundException($"Order {command.OrderId} not found.");

        var customer = await _customerRepository.GetByIdAsync(order.CustomerId, ct)
            ?? throw new NotFoundException($"Customer {order.CustomerId} not found.");

        // Business logic enforced in domain service
        var finalPrice = await _pricingService.CalculateFinalPriceAsync(order, customer, ct);

        // State transition enforced in aggregate root
        order.Submit();

        // Persist in single transaction
        _repository.Update(order);
        await _unitOfWork.SaveChangesAsync(ct);

        // Dispatch events (outside transaction — at-least-once delivery)
        await _dispatcher.DispatchAsync(order.DomainEvents, ct);
        order.ClearDomainEvents();

        return new SubmitOrderResult(
            OrderId:     order.Id,
            OrderNumber: order.OrderNumber,
            FinalPrice:  finalPrice
        );
    }
}

Domain Exception

Always throw domain-specific exceptions rather than generic ones:

C#
// src/OrderContext.Domain/Exceptions/DomainException.cs
namespace OrderContext.Domain.Exceptions;

/// <summary>
/// Represents a violation of a domain rule or invariant.
/// Thrown by domain objects, caught and translated at the application boundary.
/// </summary>
public sealed class DomainException : Exception
{
    public DomainException(string message) : base(message) { }

    public DomainException(string message, Exception inner) : base(message, inner) { }
}

Common DDD Pitfalls

Anemic Domain Model: You have entities that are just bags of properties, and all logic lives in service classes. This is a procedural model wearing DDD clothes.

C#
// ANEMIC — DON'T DO THIS
public class Order
{
    public Guid Id { get; set; }
    public OrderStatus Status { get; set; } // public setter!
    public List<OrderItem> Items { get; set; } = new();
}

public class OrderService
{
    public void SubmitOrder(Order order)
    {
        if (order.Status != OrderStatus.Draft) throw new Exception("...");
        order.Status = OrderStatus.Submitted; // logic in service
    }
}
C#
// RICH — DO THIS
public class Order
{
    public OrderStatus Status { get; private set; }

    public void Submit() // logic IN the entity
    {
        if (Status != OrderStatus.Draft)
            throw new DomainException("...");
        Status = OrderStatus.Submitted;
    }
}

Sharing entities across bounded contexts: Every bounded context should own its own model. Do not pass an Order entity from the OrderContext into the ShippingContext. Pass an OrderShippedEvent and let ShippingContext build its own Shipment entity.

Making every class an aggregate: Most classes are entities within an aggregate. Only aggregate roots have repositories. OrderItem does not need its own repository.

Summary

DDD gives you tools to fight complexity:

| Pattern | Purpose | |---|---| | Ubiquitous Language | One vocabulary shared by devs and domain experts | | Bounded Context | Explicit model boundaries — no single model for everything | | Entity | Identity-based equality; persists through time | | Value Object | Structural equality; immutable; replaces safely | | Aggregate | Consistency boundary; root enforces invariants | | Domain Event | Communicates what happened; enables decoupling | | Repository | Abstracts persistence; interface in domain | | Domain Service | Stateless logic that spans multiple aggregates |

The goal is not to use all these patterns everywhere. The goal is to have a model that accurately reflects the business, is easy to reason about, and enforces business rules at the right level — in the domain, not scattered across layers.

Enjoyed this article?

Explore the learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.