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
One Aggregate Root
Only the root entity can be referenced from outside the aggregate
Transactional Boundary
Aggregates are the unit of consistency. All changes within an aggregate are atomic
Reference by Identity
External objects can only reference the aggregate by its root identity
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:
Encapsulation : Private setters and collections
Factory Method : Create() ensures valid object creation
Business Methods : Add(), Remove(), Update() encapsulate business logic
Validation : Methods validate inputs before changing state
Domain Events : Communicates state changes
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