Skip to main content

Introduction to Domain-Driven Design

Domain-Driven Design (DDD) is an approach to software development that focuses on modeling complex business domains. The Ordering service in AspNetRun demonstrates tactical DDD patterns including Entities, Value Objects, Aggregates, and Domain Events.
Implementation Location: src/Services/Ordering/Ordering.Domain/The Domain layer contains pure business logic with no infrastructure dependencies.

Core DDD Building Blocks

1. Entities

Entities

Objects that have a distinct identity that runs through time and different states.

Entity Base Class

All entities inherit from a base class that provides common properties:
src/Services/Ordering/Ordering.Domain/Abstractions/Entity.cs
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; }
}
Key Characteristics:
  • Has a unique identifier (Id)
  • Identity persists across different states
  • Two entities are equal if they have the same Id
  • Contains audit fields for tracking changes

OrderItem Entity Example

An entity within the Order aggregate:
namespace Ordering.Domain.Models;

public class OrderItem : Entity<OrderItemId>
{
    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!;

    internal OrderItem(OrderId orderId, ProductId productId, int quantity, decimal price)
    {
        OrderId = orderId;
        ProductId = productId;
        Quantity = quantity;
        Price = price;
    }
}
Design Decisions:
  • internal constructor prevents creation outside the aggregate
  • Private setters protect invariants
  • Strongly-typed IDs prevent primitive obsession
  • Can only be created through the Order aggregate

2. Value Objects

Value Objects

Objects that have no conceptual identity. They are defined by their attributes and are immutable.

Characteristics

Once created, value objects cannot be modified. Any change creates a new instance.
Two value objects are equal if all their attributes are equal.
Value objects validate themselves upon creation.
They don’t have an Id field. They are identified by their values.

Address Value Object

Represents a shipping or billing address:
src/Services/Ordering/Ordering.Domain/ValueObjects/Address.cs
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 constructor for EF Core
    protected Address() { }

    // Private constructor enforces factory method usage
    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;
    }

    // Factory method with validation
    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);
    }
}
Why use record in C#?Records provide value-based equality by default, making them perfect for value objects.

Payment Value Object

Represents payment information with validation:
src/Services/Ordering/Ordering.Domain/ValueObjects/Payment.cs
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);
    }
}

Strongly-Typed IDs

Prevent primitive obsession by wrapping IDs in value objects:
src/Services/Ordering/Ordering.Domain/ValueObjects/OrderId.cs
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);
    }
}

Without Strongly-Typed IDs

// Easy to mix up parameters
CreateOrder(
    Guid.NewGuid(), // Is this orderId?
    Guid.NewGuid()  // Or customerId?
)

With Strongly-Typed IDs

// Compiler enforces correctness
CreateOrder(
    OrderId.Of(Guid.NewGuid()),
    CustomerId.Of(Guid.NewGuid())
)

3. Aggregates

Aggregates

A cluster of domain objects (entities and value objects) treated as a single unit. An aggregate has a root entity called the Aggregate Root.

Aggregate Rules

1

One Aggregate Root

Only the root entity can be referenced from outside the aggregate
2

Transactional Boundary

Aggregates are the unit of consistency. All changes within an aggregate are atomic
3

Reference by Identity

External objects can only reference the aggregate by its root identity
4

Enforce Invariants

The root enforces all business rules and invariants for the entire aggregate

Aggregate Base Class

src/Services/Ordering/Ordering.Domain/Abstractions/Aggregate.cs
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;
    }
}

Order Aggregate Root

The complete Order aggregate demonstrating all DDD patterns:
src/Services/Ordering/Ordering.Domain/Models/Order.cs
namespace Ordering.Domain.Models;

public class Order : Aggregate<OrderId>
{
    // Private collection - encapsulation
    private readonly List<OrderItem> _orderItems = new();
    
    // Read-only access - prevents external modification
    public IReadOnlyList<OrderItem> OrderItems => _orderItems.AsReadOnly();

    // Value Objects
    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!;
    
    // Enum
    public OrderStatus Status { get; private set; } = OrderStatus.Pending;
    
    // Calculated property
    public decimal TotalPrice
    {
        get => OrderItems.Sum(x => x.Price * x.Quantity);
        private set { }
    }

    // Factory method - enforces valid construction
    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
        };

        // Raise domain event
        order.AddDomainEvent(new OrderCreatedEvent(order));

        return order;
    }

    // Business logic method
    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));
    }

    // Business logic method with validation
    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 Design Patterns:
  1. Encapsulation: Private setters and collections
  2. Factory Method: Create() ensures valid object creation
  3. Business Methods: Add(), Remove(), Update() encapsulate business logic
  4. Validation: Methods validate inputs before changing state
  5. Domain Events: Communicates state changes
  6. Calculated Properties: TotalPrice is derived, not stored

4. Domain Events

Domain Events

Domain events represent something that happened in the domain that domain experts care about.

Why Domain Events?

Different parts of the system can react to events without tight coupling
Handle side effects (logging, notifications, integration events) without polluting domain logic
Events provide a natural audit trail of what happened
Foundation for event sourcing patterns

Domain Event Definition

src/Services/Ordering/Ordering.Domain/Events/OrderCreatedEvent.cs
namespace Ordering.Domain.Events;

public record OrderCreatedEvent(Order order) : IDomainEvent;
namespace Ordering.Domain.Events;

public record OrderUpdatedEvent(Order order) : IDomainEvent;

Raising Domain Events

Events are raised within the aggregate:
public static Order Create(...)
{
    var order = new Order { /* ... */ };
    
    // Raise domain event
    order.AddDomainEvent(new OrderCreatedEvent(order));
    
    return order;
}

Dispatching Domain Events

Events are automatically dispatched when saving changes via an EF Core interceptor:
src/Services/Ordering/Ordering.Infrastructure/Data/Interceptors/DispatchDomainEventsInterceptor.cs
using MediatR;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Ordering.Infrastructure.Data.Interceptors;

public class DispatchDomainEventsInterceptor(IMediator mediator) 
    : SaveChangesInterceptor
{
    public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, 
        InterceptionResult<int> result, 
        CancellationToken cancellationToken = default)
    {
        await DispatchDomainEvents(eventData.Context);
        return await base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    public async Task DispatchDomainEvents(DbContext? context)
    {
        if (context == null) return;

        // Find all aggregates with domain events
        var aggregates = context.ChangeTracker
            .Entries<IAggregate>()
            .Where(a => a.Entity.DomainEvents.Any())
            .Select(a => a.Entity);

        // Collect all domain events
        var domainEvents = aggregates
            .SelectMany(a => a.DomainEvents)
            .ToList();

        // Clear events from aggregates
        aggregates.ToList().ForEach(a => a.ClearDomainEvents());

        // Publish events via MediatR
        foreach (var domainEvent in domainEvents)
            await mediator.Publish(domainEvent);
    }
}

Handling Domain Events

src/Services/Ordering/Ordering.Application/Orders/EventHandlers/Domain/OrderCreatedEventHandler.cs
using MassTransit;
using Microsoft.FeatureManagement;

namespace Ordering.Application.Orders.EventHandlers.Domain;

public class OrderCreatedEventHandler
    (IPublishEndpoint publishEndpoint, 
     IFeatureManager featureManager, 
     ILogger<OrderCreatedEventHandler> logger)
    : INotificationHandler<OrderCreatedEvent>
{
    public async Task Handle(
        OrderCreatedEvent domainEvent, 
        CancellationToken cancellationToken)
    {
        logger.LogInformation("Domain Event handled: {DomainEvent}", 
            domainEvent.GetType().Name);

        // Check feature flag
        if (await featureManager.IsEnabledAsync("OrderFullfilment"))
        {
            // Convert to integration event and publish
            var orderCreatedIntegrationEvent = domainEvent.order.ToOrderDto();
            await publishEndpoint.Publish(
                orderCreatedIntegrationEvent, 
                cancellationToken);
        }
    }
}

Domain Event Flow

DDD Anti-Patterns to Avoid

Common mistakes when implementing DDD:

1. Anemic Domain Model

❌ Anemic (Bad)

public class Order
{
    public Guid Id { get; set; }
    public List<OrderItem> Items { get; set; }
    public decimal Total { get; set; }
}

// Logic in service
public class OrderService
{
    public void AddItem(Order order, OrderItem item)
    {
        order.Items.Add(item);
        order.Total += item.Price;
    }
}

✅ Rich Domain Model (Good)

public class Order : Aggregate<OrderId>
{
    private readonly List<OrderItem> _items = new();
    
    public decimal TotalPrice => 
        _items.Sum(x => x.Price * x.Quantity);
    
    public void Add(ProductId productId, 
        int quantity, decimal price)
    {
        // Validation
        ArgumentOutOfRangeException
            .ThrowIfNegativeOrZero(quantity);
        
        var item = new OrderItem(
            Id, productId, quantity, price);
        _items.Add(item);
    }
}

2. Exposing Collections Directly

❌ Bad

public class Order
{
    // Allows external modification!
    public List<OrderItem> Items { get; set; }
}

// Can bypass validation
order.Items.Add(new OrderItem(...));

✅ Good

public class Order
{
    private readonly List<OrderItem> _items = new();
    
    // Read-only view
    public IReadOnlyList<OrderItem> Items => 
        _items.AsReadOnly();
    
    // Controlled access
    public void Add(ProductId id, int qty, decimal price)
    {
        // Validation here
        _items.Add(new OrderItem(Id, id, qty, price));
    }
}

3. Using Primitive Types

❌ Primitive Obsession

public class Order
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
}

// Easy to mix up!
CreateOrder(
    Guid.NewGuid(), 
    Guid.NewGuid()
);

✅ Strongly-Typed IDs

public class Order : Aggregate<OrderId>
{
    public CustomerId CustomerId { get; private set; }
}

// Compiler prevents mistakes
CreateOrder(
    OrderId.Of(Guid.NewGuid()),
    CustomerId.Of(Guid.NewGuid())
);

Benefits of DDD

Business Logic Centralization

All business rules are in one place (the domain layer)

Ubiquitous Language

Code uses the same terms as domain experts

Testability

Domain logic can be tested without infrastructure

Maintainability

Changes to business rules are localized

Invariant Protection

Aggregates ensure data consistency

Domain Events

Loose coupling between domain concepts

When to Use DDD

DDD is most valuable for complex domains with:
  • Rich business logic
  • Complex business rules
  • Multiple stakeholders with domain expertise
  • Long-lived applications
  • Need for ubiquitous language
DDD may be overkill for:
  • Simple CRUD applications
  • Data-centric applications
  • Short-lived projects
  • Applications with minimal business logic

Clean Architecture

See how DDD fits into Clean Architecture layers

CQRS Pattern

Learn how CQRS complements DDD

Vertical Slice

Compare with simpler patterns for CRUD operations

Microservices

Understand DDD in the context of microservices

Build docs developers (and LLMs) love