Skip to main content

Domain Layer Architecture

The Ordering domain implements Domain-Driven Design tactical patterns with a focus on:
  • Rich Domain Models: Business logic encapsulated in entities
  • Aggregate Boundaries: Order as the consistency boundary
  • Value Objects: Immutable primitives with validation
  • Domain Events: Decoupled domain logic
  • Invariant Protection: Guard clauses and private setters

Base Abstractions

Entity Base Class

All entities inherit from Entity<T> with strongly-typed identifiers and audit fields:
namespace Ordering.Domain.Abstractions;

public abstract class Entity<T> : IEntity<T>
{
    public T Id { get; set; }
    public DateTime? CreatedAt { get; set; }
    public string? CreatedBy { get; set; }
    public DateTime? LastModified { get; set; }
    public string? LastModifiedBy { get; set; }
}

Aggregate Base Class

Aggregates manage domain events and define transactional boundaries:
namespace Ordering.Domain.Abstractions;

public abstract class Aggregate<TId> : Entity<TId>, IAggregate<TId>
{
    private readonly List<IDomainEvent> _domainEvents = new();
    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void AddDomainEvent(IDomainEvent domainEvent)
    {
        _domainEvents.Add(domainEvent);
    }

    public IDomainEvent[] ClearDomainEvents()
    {
        IDomainEvent[] dequeuedEvents = _domainEvents.ToArray();
        _domainEvents.Clear();
        return dequeuedEvents;
    }
}
Key Features:
  • Collects domain events during aggregate operations
  • Events are dispatched by DispatchDomainEventsInterceptor during SaveChanges
  • Ensures events are only published when changes are persisted
  • Maintains transactional consistency

Aggregates

Order Aggregate Root

The Order is the main aggregate root, defining the consistency boundary for order operations:
namespace Ordering.Domain.Models;

public class Order : Aggregate<OrderId>
{
    private readonly List<OrderItem> _orderItems = new();
    public IReadOnlyList<OrderItem> OrderItems => _orderItems.AsReadOnly();

    public CustomerId CustomerId { get; private set; } = default!;
    public OrderName OrderName { get; private set; } = default!;
    public Address ShippingAddress { get; private set; } = default!;
    public Address BillingAddress { get; private set; } = default!;
    public Payment Payment { get; private set; } = default!;
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    public decimal TotalPrice
    {
        get => OrderItems.Sum(x => x.Price * x.Quantity);
        private set { }
    }

    public static Order Create(OrderId id, CustomerId customerId, OrderName orderName, 
        Address shippingAddress, Address billingAddress, Payment payment)
    {
        var order = new Order
        {
            Id = id,
            CustomerId = customerId,
            OrderName = orderName,
            ShippingAddress = shippingAddress,
            BillingAddress = billingAddress,
            Payment = payment,
            Status = OrderStatus.Pending
        };

        order.AddDomainEvent(new OrderCreatedEvent(order));

        return order;
    }

    public void Update(OrderName orderName, Address shippingAddress, 
        Address billingAddress, Payment payment, OrderStatus status)
    {
        OrderName = orderName;
        ShippingAddress = shippingAddress;
        BillingAddress = billingAddress;
        Payment = payment;
        Status = status;

        AddDomainEvent(new OrderUpdatedEvent(this));
    }

    public void Add(ProductId productId, int quantity, decimal price)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);

        var orderItem = new OrderItem(Id, productId, quantity, price);
        _orderItems.Add(orderItem);
    }

    public void Remove(ProductId productId)
    {
        var orderItem = _orderItems.FirstOrDefault(x => x.ProductId == productId);
        if (orderItem is not null)
        {
            _orderItems.Remove(orderItem);
        }
    }
}
Key Characteristics:
  • Factory Method: Create() ensures valid initial state
  • Encapsulation: Private setters prevent invalid state changes
  • Invariant Protection: Guard clauses in Add() method
  • Domain Events: Raises OrderCreatedEvent and OrderUpdatedEvent
  • Calculated Properties: TotalPrice computed from order items
  • Collection Management: OrderItems exposed as read-only, modified through methods
Aggregate Boundary: The Order aggregate includes OrderItem entities. All changes to order items must go through the Order aggregate root.

OrderItem Entity

Order line items are entities within the Order aggregate:
namespace Ordering.Domain.Models;

public class OrderItem : Entity<OrderItemId>
{
    internal OrderItem(OrderId orderId, ProductId productId, int quantity, decimal price)
    {
        Id = OrderItemId.Of(Guid.NewGuid());
        OrderId = orderId;
        ProductId = productId;
        Quantity = quantity;
        Price = price;
    }

    public OrderId OrderId { get; private set; } = default!;
    public ProductId ProductId { get; private set; } = default!;
    public int Quantity { get; private set; } = default!;
    public decimal Price { get; private set; } = default!;
}
Design Notes:
  • Internal Constructor: Can only be created by the Order aggregate
  • Immutable: No update methods; modifications require remove and re-add
  • Strongly-Typed IDs: Uses value objects for type safety

Customer Entity

Customer is a separate aggregate root:
namespace Ordering.Domain.Models;

public class Customer : Entity<CustomerId>
{
    public string Name { get; private set; } = default!;
    public string Email { get; private set; } = default!;

    public static Customer Create(CustomerId id, string name, string email)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentException.ThrowIfNullOrWhiteSpace(email);

        var customer = new Customer
        {
            Id = id,
            Name = name,
            Email = email
        };

        return customer;
    }
}

Product Entity

Product is a separate aggregate root for catalog information:
namespace Ordering.Domain.Models;

public class Product : Entity<ProductId>
{
    public string Name { get; private set; } = default!;
    public decimal Price { get; private set; } = default!;

    public static Product Create(ProductId id, string name, decimal price)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(name);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(price);

        var product = new Product
        {
            Id = id,
            Name = name,
            Price = price
        };

        return product;
    }
}

Value Objects

Value objects are immutable and defined by their properties, not identity.

Strongly-Typed Identifiers

All entity IDs are value objects preventing primitive obsession:
namespace Ordering.Domain.ValueObjects;

public record OrderId
{
    public Guid Value { get; }
    private OrderId(Guid value) => Value = value;
    
    public static OrderId Of(Guid value)
    {
        ArgumentNullException.ThrowIfNull(value);
        if (value == Guid.Empty)
        {
            throw new DomainException("OrderId cannot be empty.");
        }
        return new OrderId(value);
    }
}
public record CustomerId
{
    public Guid Value { get; }
    private CustomerId(Guid value) => Value = value;
    
    public static CustomerId Of(Guid value)
    {
        ArgumentNullException.ThrowIfNull(value);
        if (value == Guid.Empty)
        {
            throw new DomainException("CustomerId cannot be empty.");
        }
        return new CustomerId(value);
    }
}
public record ProductId
{
    public Guid Value { get; }
    private ProductId(Guid value) => Value = value;
    
    public static ProductId Of(Guid value)
    {
        ArgumentNullException.ThrowIfNull(value);
        if (value == Guid.Empty)
        {
            throw new DomainException("ProductId cannot be empty.");
        }
        return new ProductId(value);
    }
}
public record OrderItemId
{
    public Guid Value { get; }
    private OrderItemId(Guid value) => Value = value;
    
    public static OrderItemId Of(Guid value)
    {
        ArgumentNullException.ThrowIfNull(value);
        if (value == Guid.Empty)
        {
            throw new DomainException("OrderItemId cannot be empty.");
        }
        return new OrderItemId(value);
    }
}
Benefits:
  • Type Safety: Cannot accidentally pass wrong ID type
  • Validation: Ensures IDs are never empty
  • Domain Semantics: Clear intent in method signatures
  • Refactoring: Easy to find all usages of specific ID types

OrderName Value Object

Wraps the order name with validation:
namespace Ordering.Domain.ValueObjects;

public record OrderName
{
    private const int DefaultLength = 5;
    public string Value { get; }
    private OrderName(string value) => Value = value;
    
    public static OrderName Of(string value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);
        return new OrderName(value);
    }
}

Address Value Object

Immutable address with comprehensive validation:
namespace Ordering.Domain.ValueObjects;

public record Address
{
    public string FirstName { get; } = default!;
    public string LastName { get; } = default!;
    public string? EmailAddress { get; } = default!;
    public string AddressLine { get; } = default!;
    public string Country { get; } = default!;
    public string State { get; } = default!;
    public string ZipCode { get; } = default!;
    
    protected Address() { }

    private Address(string firstName, string lastName, string emailAddress, 
        string addressLine, string country, string state, string zipCode)
    {
        FirstName = firstName;
        LastName = lastName;
        EmailAddress = emailAddress;
        AddressLine = addressLine;
        Country = country;
        State = state;
        ZipCode = zipCode;
    }

    public static Address Of(string firstName, string lastName, string emailAddress, 
        string addressLine, string country, string state, string zipCode)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(emailAddress);
        ArgumentException.ThrowIfNullOrWhiteSpace(addressLine);

        return new Address(firstName, lastName, emailAddress, addressLine, 
            country, state, zipCode);
    }
}
Features:
  • Record Type: Structural equality by default
  • Immutability: All properties are init-only
  • Factory Method: Of() ensures valid construction
  • Guard Clauses: Validates required fields

Payment Value Object

Secure payment information with validation:
namespace Ordering.Domain.ValueObjects;

public record Payment
{
    public string? CardName { get; } = default!;
    public string CardNumber { get; } = default!;
    public string Expiration { get; } = default!;
    public string CVV { get; } = default!;
    public int PaymentMethod { get; } = default!;

    protected Payment() { }

    private Payment(string cardName, string cardNumber, string expiration, 
        string cvv, int paymentMethod)
    {
        CardName = cardName;
        CardNumber = cardNumber;
        Expiration = expiration;
        CVV = cvv;
        PaymentMethod = paymentMethod;
    }

    public static Payment Of(string cardName, string cardNumber, string expiration, 
        string cvv, int paymentMethod)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(cardName);
        ArgumentException.ThrowIfNullOrWhiteSpace(cardNumber);
        ArgumentException.ThrowIfNullOrWhiteSpace(cvv);
        ArgumentOutOfRangeException.ThrowIfGreaterThan(cvv.Length, 3);

        return new Payment(cardName, cardNumber, expiration, cvv, paymentMethod);
    }
}
Security Note: In production, sensitive payment data should be tokenized and not stored directly.

Enumerations

OrderStatus

Defines the lifecycle states of an order:
namespace Ordering.Domain.Enums;

public enum OrderStatus
{
    Draft = 1,
    Pending = 2,
    Completed = 3,
    Cancelled = 4
}

Domain Events

Domain events represent significant occurrences within the domain.

OrderCreatedEvent

Raised when a new order is created:
namespace Ordering.Domain.Events;

public record OrderCreatedEvent(Order order) : IDomainEvent;
Usage: Triggers integration event publishing to notify other services (see Ordering.Application/Orders/EventHandlers/Domain/OrderCreatedEventHandler.cs:32)

OrderUpdatedEvent

Raised when an order is updated:
namespace Ordering.Domain.Events;

public record OrderUpdatedEvent(Order order) : IDomainEvent;
Usage: Currently logs the event; can be extended for additional side effects (see Ordering.Application/Orders/EventHandlers/Domain/OrderUpdatedEventHandler.cs:45)

Domain Exceptions

namespace Ordering.Domain.Exceptions;

public class DomainException : Exception
{
    public DomainException(string message) : base(message) { }
}
Thrown when domain invariants are violated.

Domain Event Dispatch

Domain events are automatically dispatched during database SaveChanges through the DispatchDomainEventsInterceptor:
public async Task DispatchDomainEvents(DbContext? context)
{
    if (context == null) return;

    var aggregates = context.ChangeTracker
        .Entries<IAggregate>()
        .Where(a => a.Entity.DomainEvents.Any())
        .Select(a => a.Entity);

    var domainEvents = aggregates
        .SelectMany(a => a.DomainEvents)
        .ToList();

    aggregates.ToList().ForEach(a => a.ClearDomainEvents());

    foreach (var domainEvent in domainEvents)
        await mediator.Publish(domainEvent);
}
Flow:
  1. Aggregate raises domain event during operation
  2. Event is stored in aggregate’s event collection
  3. When SaveChanges is called, interceptor extracts all events
  4. Events are cleared from aggregates
  5. Events are published via MediatR
  6. Domain event handlers execute
  7. Database transaction commits

Design Principles Applied

Encapsulation

  • Private setters prevent unauthorized state changes
  • Factory methods control object creation
  • Collection properties exposed as read-only

Invariant Protection

  • Guard clauses in factory methods and operations
  • Value object validation in Of() methods
  • Business rules enforced in aggregate methods

Ubiquitous Language

  • Domain terms reflected in code (Order, OrderItem, Customer)
  • Value objects capture domain concepts (OrderName, Address, Payment)
  • Methods use domain language (Add, Remove, Update, not generic CRUD)

Aggregate Boundaries

  • Order is the consistency boundary
  • OrderItems can only be modified through Order
  • Customer and Product are separate aggregates
  • No cross-aggregate references except by ID

Next Steps

  • Application Layer - See how domain models are used in CQRS commands and queries
  • API Reference - Explore the REST endpoints that interact with the domain

Build docs developers (and LLMs) love