👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
Implementing Order Aggregate Pattern in .NET - Complete DDD Example

Implementing Order Aggregate Pattern in .NET - Complete DDD Example

Author - Abdul Rahman (Bhai)

DDD

22 Articles

Improve

Table of Contents

  1. What we gonna do?
  2. Why we gonna do?
  3. How we gonna do?
  4. Summary

What we gonna do?

In this article, we'll implement a complete Order Aggregate pattern in .NET, demonstrating how to apply Domain-Driven Design tactical patterns to a real-world e-commerce scenario. We'll build an order processing system that enforces business rules, maintains consistency, and provides a clear domain model.

Our Order aggregate will encapsulate order lines, handle discount coupons, track lifecycle states (draft, confirmed, shipped, delivered), and enforce invariants like "orders can't be modified after confirmation" and "quantities must be positive." This hands-on example will show you how all the DDD tactical patterns work together in practice.

By the end, you'll have a complete working example that demonstrates entities, value objects, aggregates, lifecycle management, and business rule enforcement—all implemented with clean, testable C# code following DDD best practices.

Why we gonna do?

The Scattered Order Logic Problem

Most e-commerce systems have order processing logic scattered across multiple services and database tables. This leads to inconsistent states and bugs:


// Problem: Order logic spread across multiple places
public class OrderService
{
    public void AddItemToOrder(Guid orderId, Guid productId, int quantity)
    {
        var order = _db.Orders.Find(orderId);
        
        // Rule 1: Quantity must be positive (checked here)
        if (quantity <= 0) throw new Exception("Invalid quantity");
        
        // Rule 2: Can't modify confirmed orders (sometimes forgot!)
        // Oops, developer forgot to check status...
        
        var line = new OrderLine 
        { 
            OrderId = orderId, 
            ProductId = productId, 
            Quantity = quantity 
        };
        
        _db.OrderLines.Add(line);
        _db.SaveChanges();
        
        // Rule 3: Update order total (completely separate operation)
        UpdateOrderTotal(orderId);  // In a different method!
    }
    
    public void UpdateOrderTotal(Guid orderId)
    {
        var lines = _db.OrderLines.Where(l => l.OrderId == orderId).ToList();
        var total = lines.Sum(l => l.UnitPrice * l.Quantity);
        
        var order = _db.Orders.Find(orderId);
        order.TotalAmount = total;
        _db.SaveChanges();
    }
    
    public void ConfirmOrder(Guid orderId)
    {
        var order = _db.Orders.Find(orderId);
        
        // Rule 4: Can't confirm empty orders (checked in different place)
        var lineCount = _db.OrderLines.Count(l => l.OrderId == orderId);
        if (lineCount == 0) throw new Exception("Empty order");
        
        // Rule 5: Can't confirm already confirmed orders
        if (order.Status == "Confirmed") throw new Exception("Already confirmed");
        
        order.Status = "Confirmed";
        order.ConfirmedAt = DateTime.UtcNow;
        _db.SaveChanges();
    }
}

// Problems:
// ❌ Rules scattered across different methods
// ❌ Easy to forget checks (status validation missing in Add Item)
// ❌ Total can get out of sync with lines
// ❌ Multiple database round-trips
// ❌ No compile-time safety
// ❌ Business rules unclear from code structure
      

Real-world consequences:

  • Orders get modified after confirmation (inventory already reserved!)
  • Order totals don't match line item sums
  • Empty orders get confirmed and sent to fulfillment
  • Duplicate products appear in same order
  • Racing conditions when multiple operations happen simultaneously

Why an Aggregate Solution Works

An Order aggregate solves these problems by:

  • Centralizing logic: All order rules in one place
  • Enforcing invariants: Invalid states are impossible to create
  • Clear lifecycle: State transitions are explicit and controlled
  • Consistency guarantee: Lines and totals always match
  • Single transaction: All changes save together or roll back together

How we gonna do?

Step 1: Define Business Requirements

Start by clearly stating the business rules:


Business Requirements:

1. Orders contain multiple line items
2. Each line has product name, unit price (captured at order time), and quantity
3. Quantity must be positive integers
4. Once confirmed, orders cannot be modified
5. Can apply one discount coupon per order (percentage off)
6. Cannot have duplicate products in an order
7. Order lifecycle: Draft → Confirmed → Shipped → Delivered
8. Can cancel orders before shipping
9. Total price = sum(line totals) - discount
      

Step 2: Create Value Objects

Build value objects for domain concepts:


// OrderStatus - Prevents invalid status strings
public sealed record OrderStatus
{
    public string Value { get; }
    
    private OrderStatus(string value) => Value = value;
    
    public static readonly OrderStatus Draft = new("Draft");
    public static readonly OrderStatus Confirmed = new("Confirmed");
    public static readonly OrderStatus Shipped = new("Shipped");
    public static readonly OrderStatus Delivered = new("Delivered");
    public static readonly OrderStatus Cancelled = new("Cancelled");
    
    public bool IsDraft => Value == "Draft";
    public bool IsConfirmed => Value == "Confirmed";
    public bool IsShipped => Value == "Shipped";
    public bool CanBeModified => IsDraft;
    public bool CanBeCancelled => IsDraft || IsConfirmed;
}

// Discount - Encapsulates discount logic
public sealed record Discount
{
    public decimal Percentage { get; }
    public string CouponCode { get; }
    
    private Discount(string couponCode, decimal percentage)
    {
        if (string.IsNullOrWhiteSpace(couponCode))
            throw new ArgumentException("Coupon code required");
        
        if (percentage < 0 || percentage > 100)
            throw new ArgumentException("Percentage must be between 0 and 100");
        
        CouponCode = couponCode.ToUpperInvariant();
        Percentage = percentage;
    }
    
    public static Discount Create(string couponCode, decimal percentage) 
        => new(couponCode, percentage);
    
    public Money Apply(Money amount) => amount * (1 - Percentage / 100);
}
      

Step 3: Create OrderLine Entity

OrderLine is an internal entity within the Order aggregate:


public class OrderLine
{
    public Guid Id { get; private set; }
    public string ProductName { get; private set; }
    public Money UnitPrice { get; private set; }
    public int Quantity { get; private set; }
    
    // Computed property
    public Money LineTotal => UnitPrice * Quantity;
    
    // Internal constructor - only Order can create lines
    internal OrderLine(Guid id, string productName, Money unitPrice, int quantity)
    {
        // Validate inputs
        if (string.IsNullOrWhiteSpace(productName))
            throw new ArgumentException("Product name required", nameof(productName));
        
        if (unitPrice == null || unitPrice.Amount <= 0)
            throw new ArgumentException("Unit price must be positive", nameof(unitPrice));
        
        if (quantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(quantity));
        
        Id = id;
        ProductName = productName;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }
    
    // Update quantity (called by Order aggregate only)
    internal void UpdateQuantity(int newQuantity)
    {
        if (newQuantity <= 0)
            throw new ArgumentException("Quantity must be positive", nameof(newQuantity));
        
        Quantity = newQuantity;
    }
}
      

Step 4: Implement Order Aggregate Root

Now build the Order aggregate root that manages all order logic:


public class Order
{
    // Identity
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    
    // Status tracking
    public OrderStatus Status { get; private set; }
    public DateTime CreatedAt { get; private set; }
    public DateTime? ConfirmedAt { get; private set; }
    
    // Discount
    private Discount? _discount;
    
    // Lines - encapsulated collection
    private readonly List<OrderLine> _lines = new();
    public IReadOnlyCollection<OrderLine> Lines => _lines.AsReadOnly();
    
    // Computed total
    public Money TotalPrice { get; private set; }
    
    // Private constructor
    private Order(Guid id, Guid customerId)
    {
        Id = id;
        CustomerId = customerId;
        Status = OrderStatus.Draft;
        CreatedAt = DateTime.UtcNow;
        TotalPrice = Money.Usd(0);  // Assume USD for example
    }
    
    // Factory method
    public static Order Create(Guid customerId)
    {
        if (customerId == Guid.Empty)
            throw new ArgumentException("Customer ID required");
        
        return new Order(Guid.NewGuid(), customerId);
    }
    
    // Behavior: Add line to order
    public void AddLine(string productName, Money unitPrice, int quantity)
    {
        EnsureDraft();  // Can only modify drafts
        
        // Business rule: No duplicate products
        if (_lines.Any(l => l.ProductName.Equals(productName, 
            StringComparison.OrdinalIgnoreCase)))
        {
            throw new InvalidOperationException(
                $"Product '{productName}' already in order. Update quantity instead.");
        }
        
        var line = new OrderLine(Guid.NewGuid(), productName, unitPrice, quantity);
        _lines.Add(line);
        
        RecalculateTotal();
    }
    
    // Behavior: Remove line from order
    public void RemoveLine(Guid lineId)
    {
        EnsureDraft();
        
        var line = _lines.FirstOrDefault(l => l.Id == lineId);
        if (line == null)
            throw new InvalidOperationException("Line not found");
        
        _lines.Remove(line);
        RecalculateTotal();
    }
    
    // Behavior: Update line quantity
    public void UpdateLineQuantity(Guid lineId, int newQuantity)
    {
        EnsureDraft();
        
        var line = _lines.FirstOrDefault(l => l.Id == lineId);
        if (line == null)
            throw new InvalidOperationException("Line not found");
        
        line.UpdateQuantity(newQuantity);
        RecalculateTotal();
    }
    
    // Behavior: Apply discount coupon
    public void ApplyDiscount(Discount discount)
    {
        EnsureDraft();
        
        if (discount == null)
            throw new ArgumentNullException(nameof(discount));
        
        // Business rule: Only one coupon per order
        if (_discount != null)
            throw new InvalidOperationException(
                $"Order already has discount '{_discount.CouponCode}' applied");
        
        _discount = discount;
        RecalculateTotal();
    }
    
    // Behavior: Remove discount
    public void RemoveDiscount()
    {
        EnsureDraft();
        _discount = null;
        RecalculateTotal();
    }
    
    // Behavior: Confirm order
    public void Confirm()
    {
        EnsureDraft();
        
        // Business rule: Must have at least one line
        if (_lines.Count == 0)
            throw new InvalidOperationException("Cannot confirm empty order");
        
        Status = OrderStatus.Confirmed;
        ConfirmedAt = DateTime.UtcNow;
    }
    
    // Behavior: Ship order
    public void Ship()
    {
        if (Status != OrderStatus.Confirmed)
            throw new InvalidOperationException(
                "Only confirmed orders can be shipped");
        
        Status = OrderStatus.Shipped;
    }
    
    // Behavior: Mark as delivered
    public void MarkDelivered()
    {
        if (Status != OrderStatus.Shipped)
            throw new InvalidOperationException(
                "Only shipped orders can be delivered");
        
        Status = OrderStatus.Delivered;
    }
    
    // Behavior: Cancel order
    public void Cancel()
    {
        if (!Status.CanBeCancelled)
            throw new InvalidOperationException(
                $"Cannot cancel order in status '{Status}'");
        
        Status = OrderStatus.Cancelled;
    }
    
    // Private helper: Ensure order is in draft state
    private void EnsureDraft()
    {
        if (!Status.CanBeModified)
            throw new InvalidOperationException(
                $"Cannot modify order in status '{Status}'");
    }
    
    // Private helper: Recalculate total
    private void RecalculateTotal()
    {
        var subtotal = _lines.Sum(l => l.LineTotal.Amount);
        var amount = Money.Usd(subtotal);
        
        TotalPrice = _discount == null 
            ? amount 
            : _discount.Apply(amount);
    }
}
      

Step 5: Create Repository Interface

The repository works with the aggregate root:


public interface IOrderRepository
{
    Order? GetById(Guid orderId);
    void Save(Order order);
    void Delete(Order order);
    IReadOnlyCollection<Order> GetByCustomerId(Guid customerId);
}

// Implementation with Entity Framework Core
public class OrderRepository : IOrderRepository
{
    private readonly ApplicationDbContext _context;
    
    public OrderRepository(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public Order? GetById(Guid orderId)
    {
        // Load aggregate with all internal entities
        return _context.Orders
            .Include(o => o.Lines)  // Load lines with order
            .FirstOrDefault(o => o.Id == orderId);
    }
    
    public void Save(Order order)
    {
        // EF Core tracks changes and saves entire aggregate
        if (_context.Entry(order).State == EntityState.Detached)
            _context.Orders.Add(order);
        else
            _context.Orders.Update(order);
        
        _context.SaveChanges();
    }
    
    public void Delete(Order order)
    {
        _context.Orders.Remove(order);  // Cascade deletes lines
        _context.SaveChanges();
    }
    
    public IReadOnlyCollection<Order> GetByCustomerId(Guid customerId)
    {
        return _context.Orders
            .Include(o => o.Lines)
            .Where(o => o.CustomerId == customerId)
            .ToList();
    }
}
      

Step 6: Use the Aggregate in Application Services

Application services orchestrate domain operations:


public class OrderApplicationService
{
    private readonly IOrderRepository _orderRepo;
    
    public OrderApplicationService(IOrderRepository orderRepo)
    {
        _orderRepo = orderRepo;
    }
    
    public Guid CreateOrder(Guid customerId)
    {
        var order = Order.Create(customerId);
        _orderRepo.Save(order);
        return order.Id;
    }
    
    public void AddItemToOrder(Guid orderId, string productName, 
        decimal unitPrice, int quantity)
    {
        var order = _orderRepo.GetById(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
        
        // Tell aggregate to add line - business logic inside aggregate
        order.AddLine(productName, Money.Usd(unitPrice), quantity);
        
        _orderRepo.Save(order);
    }
    
    public void UpdateItemQuantity(Guid orderId, Guid lineId, int newQuantity)
    {
        var order = _orderRepo.GetById(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
        
        order.UpdateLineQuantity(lineId, newQuantity);
        _orderRepo.Save(order);
    }
    
    public void ApplyCoupon(Guid orderId, string couponCode, decimal percentage)
    {
        var order = _orderRepo.GetById(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
        
        var discount = Discount.Create(couponCode, percentage);
        order.ApplyDiscount(discount);
        
        _orderRepo.Save(order);
    }
    
    public void ConfirmOrder(Guid orderId)
    {
        var order = _orderRepo.GetById(orderId);
        if (order == null)
            throw new OrderNotFoundException(orderId);
        
        order.Confirm();
        _orderRepo.Save(order);
    }
}
      

Step 7: Write Tests

Test domain logic without infrastructure:


public class OrderTests
{
    [Fact]
    public void AddLine_WhenOrderIsDraft_ShouldSucceed()
    {
        // Arrange
        var order = Order.Create(Guid.NewGuid());
        
        // Act
        order.AddLine("Product A", Money.Usd(50), 2);
        
        // Assert
        Assert.Single(order.Lines);
        Assert.Equal(Money.Usd(100), order.TotalPrice);
    }
    
    [Fact]
    public void AddLine_WhenDuplicateProduct_ShouldThrowException()
    {
        // Arrange
        var order = Order.Create(Guid.NewGuid());
        order.AddLine("Product A", Money.Usd(50), 1);
        
        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => 
            order.AddLine("Product A", Money.Usd(50), 1));
        
        Assert.Contains("already in order", ex.Message);
    }
    
    [Fact]
    public void Confirm_WhenOrderIsEmpty_ShouldThrowException()
    {
        // Arrange
        var order = Order.Create(Guid.NewGuid());
        
        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => 
            order.Confirm());
        
        Assert.Contains("empty order", ex.Message);
    }
    
    [Fact]
    public void AddLine_AfterConfirmation_ShouldThrowException()
    {
        // Arrange
        var order = Order.Create(Guid.NewGuid());
        order.AddLine("Product A", Money.Usd(100), 1);
        order.Confirm();
        
        // Act & Assert
        var ex = Assert.Throws<InvalidOperationException>(() => 
            order.AddLine("Product B", Money.Usd(50), 1));
        
        Assert.Contains("Cannot modify", ex.Message);
    }
    
    [Fact]
    public void ApplyDiscount_ShouldReduceTotal()
    {
        // Arrange
        var order = Order.Create(Guid.NewGuid());
        order.AddLine("Product A", Money.Usd(100), 1);
        
        // Act
        order.ApplyDiscount(Discount.Create("SAVE10", 10));
        
        // Assert
        Assert.Equal(Money.Usd(90), order.TotalPrice);
    }
    
    // No database needed! Pure domain logic testing
}
      

Complete Usage Flow


// Create order
var orderId = _orderService.CreateOrder(customerId);

// Add items
_orderService.AddItemToOrder(orderId, "Software License Pro", 299.99m, 1);
_orderService.AddItemToOrder(orderId, "Support Plan", 99.99m, 1);

// Apply discount
_orderService.ApplyCoupon(orderId, "WELCOME20", 20);

// Confirm order
_orderService.ConfirmOrder(orderId);

// Later: Ship and deliver
_orderService.ShipOrder(orderId);
_orderService.MarkOrderDelivered(orderId);

// All business rules enforced automatically!
// Invalid operations throw meaningful exceptions
// State is always consistent
      

Summary

  • Order aggregate encapsulates order lines and enforces business rules as a single unit
  • Value objects like OrderStatus and Discount prevent invalid states
  • OrderLine is an internal entity with internal constructor—only Order can create lines
  • Private setters and EnsureDraft() method prevent modifications after confirmation
  • Total price is always consistent with line items because RecalculateTotal() runs after every change
  • Repository loads and saves the entire aggregate (Order + Lines) in a single transaction
  • Application services orchestrate operations but delegate business logic to the aggregate
  • Domain logic is testable without databases, mocks, or infrastructure dependencies
  • State transitions (Draft → Confirmed → Shipped → Delivered) are explicit and validated
  • Business rules like "no duplicate products" and "can't modify confirmed orders" are enforced at the domain level
👉🏼 Click here to Join I ❤️ .NET WhatsApp Channel to get 🔔 notified about new articles and other updates.
  • DDD
  • Domain-Driven Design
  • DDD
  • Aggregates
  • Order Pattern
  • E-commerce
  • .NET
  • C#
  • Tactical DDD
  • Business Rules
  • Entity Framework