Unveiling Domain-Driven Design: A Strategic Blueprint for Complex E-Commerce Systems
Domain-Driven Design (DDD) is a disciplined software development approach that prioritizes the core domain—the business problem space—and aligns the architecture, code structure, and team communication around a shared understanding of that domain. Introduced by Eric Evans in his seminal 2003 work Domain-Driven Design: Tackling Complexity in the Heart of Software, DDD transcends traditional data-centric or framework-driven development by treating the domain as the primary source of truth.
In an e-commerce platform, the domain is not merely “shopping carts” or “orders”—it is a rich, evolving model of customer behavior, inventory dynamics, pricing rules, promotions, fulfillment workflows, and payment reconciliation. These concepts are not static data structures but living processes governed by business invariants:
- A product cannot be purchased if stock is insufficient.
- A promotional discount must respect minimum order value and customer segment eligibility.
- An order transitions through well-defined states (Draft → Submitted → Paid → Shipped → Delivered) with strict validation at each step.
DDD achieves alignment through three foundational pillars:
- Ubiquitous Language – A common vocabulary shared between domain experts (business stakeholders) and developers. Terms like Cart, LineItem, InventoryReservation, and OrderFulfillment must mean the same in code as in meetings.
- Bounded Contexts – Logical boundaries within which a model is consistent. For example, the Catalog context defines a Product differently than the Inventory context (catalog cares about description and SEO; inventory cares about stock levels and warehouse location).
- Layered Architecture – Isolating the Domain Layer as the heart of the system, free from infrastructure concerns (databases, UI, messaging). This layer contains:
- Entities
- Value Objects
- Aggregates
- Domain Services
- Domain Events
- Repositories (as interfaces)
In this five-part series, we will construct a production-grade e-commerce domain model using .NET Core 9 and C# 13, adhering to DDD tactical patterns. Each part builds incrementally, with comprehensive explanations, code, and rationale.
Part 1 Focus: Laying the foundation—project setup and first entity.
Establishing the Domain Foundation: Creating the Domain Layer Project
The Domain Layer must be pure, independent, and testable. It should have zero dependencies on external frameworks, databases, or presentation layers. This isolation ensures that business logic remains stable even as infrastructure evolves.
Step 1: Create the Domain Project
Using the .NET CLI:
dotnet new classlib -n ECommerce.Domain -f net9.0This creates a class library targeting .NET 9.0. The resulting project file:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>Best Practices Enforced:
- <Nullable>enable</Nullable>: Enforces nullability to prevent runtime NullReferenceException.
- <ImplicitUsings>enable</ImplicitUsings>: Reduces boilerplate.
Step 2: Directory Structure (Recommended)
Organize the Domain Layer for clarity and scalability:
ECommerce.Domain/
├── Aggregates/ → Root entities (Aggregates)
├── Entities/ → Supporting entities
├── ValueObjects/ → Immutable types with behavior
├── Enums/ → Domain-specific enumerations
├── Events/ → Domain events
├── Services/ → Domain services
├── Repositories/ → Repository interfaces
├── Exceptions/ → Domain-specific exceptions
├── Common/ → Shared base classes, Result, etc.
└── Errors/ → Centralized error codesWe begin populating this structure progressively.
Step 3: Add to Solution
If using a solution file:
dotnet new sln -n ECommerce
dotnet sln add ./ECommerce.Domain/ECommerce.Domain.csprojThe Domain project is now the core dependency for all other layers (Application, Infrastructure, API).
Crafting Your Inaugural Domain Entity: The Customer Aggregate Root
In DDD, an Entity is an object defined not by its attributes, but by a thread of continuity and identity. A Customer retains its identity even if email or name changes. This identity is typically represented by a unique identifier (Guid in greenfield systems).
We begin with the Customer—the aggregate root of the Customer Management bounded context.
Why Customer First?
- Central to user journeys (registration, profile, order history).
- Encapsulates identity, contact info, and lifecycle (active, suspended).
- Demonstrates encapsulation, validation, and identity management.
Implementation: Customer.cs
// File: ECommerce.Domain/Aggregates/Customer.cs
using System.Text.RegularExpressions;
using ECommerce.Domain.ValueObjects;
using ECommerce.Domain.Common;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.Aggregates;
public class Customer : Entity<Guid>, IAggregateRoot
{
public FullName Name { get; private set; }
public EmailAddress Email { get; private set; }
public bool IsActive { get; private set; } = true;
public DateTime CreatedAt { get; private set; }
public DateTime? UpdatedAt { get; private set; }
private readonly List<Guid> _orderIds = new();
public IReadOnlyCollection<Guid> OrderIds => _orderIds.AsReadOnly();
// Private parameterless constructor for EF Core / serialization
private Customer() { }
private Customer(Guid id, FullName name, EmailAddress email) : base(id)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Email = email ?? throw new ArgumentNullException(nameof(email));
CreatedAt = DateTime.UtcNow;
IsActive = true;
}
public static Result<Customer> Create(string firstName, string lastName, string email)
{
var nameResult = FullName.Create(firstName, lastName);
if (!nameResult.IsSuccess)
return Result<Customer>.Failure(nameResult.Error);
var emailResult = EmailAddress.Create(email);
if (!emailResult.IsSuccess)
return Result<Customer>.Failure(emailResult.Error);
var customer = new Customer(Guid.NewGuid(), nameResult.Value, emailResult.Value);
return Result<Customer>.Success(customer);
}
public Result UpdateName(string firstName, string lastName)
{
var nameResult = FullName.Create(firstName, lastName);
if (!nameResult.IsSuccess)
return nameResult;
Name = nameResult.Value;
UpdatedAt = DateTime.UtcNow;
return Result.Success();
}
public Result UpdateEmail(string email)
{
var emailResult = EmailAddress.Create(email);
if (!emailResult.IsSuccess)
return emailResult;
Email = emailResult.Value;
UpdatedAt = DateTime.UtcNow;
return Result.Success();
}
public void Deactivate()
{
if (!IsActive) return;
IsActive = false;
UpdatedAt = DateTime.UtcNow;
}
public void Reactivate()
{
if (IsActive) return;
IsActive = true;
UpdatedAt = DateTime.UtcNow;
}
internal void AddOrderReference(Guid orderId)
{
if (!_orderIds.Contains(orderId))
_orderIds.Add(orderId);
}
}Key DDD Concepts Demonstrated
| Concept | Implementation |
|---|---|
| Identity | Entity<Guid> base class ensures Id is immutable |
| Encapsulation | Private setters; changes via methods only |
| Validation | Handled in Create and Update methods |
| Factory Method | Customer.Create(…) centralizes instantiation |
| Immutability of Identity | Id set once in private constructor |
| Aggregate Root | IAggregateRoot marker; controls access to inner state |
Supporting Types
1. Entity<T>.cs – Base Class for All Entities
// File: ECommerce.Domain/Common/Entity.cs
namespace ECommerce.Domain.Common;
public abstract class Entity<T> where T : notnull
{
public T Id { get; protected set; } = default!;
protected Entity(T id)
{
Id = id;
}
public override bool Equals(object? obj)
{
if (obj is not Entity<T> other) return false;
if (ReferenceEquals(this, other)) return true;
if (GetType() != other.GetType()) return false;
return EqualityComparer<T>.Default.Equals(Id, other.Id);
}
public override int GetHashCode() => EqualityComparer<T>.Default.GetHashCode(Id);
public static bool operator ==(Entity<T>? a, Entity<T>? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.Equals(b);
}
public static bool operator !=(Entity<T>? a, Entity<T>? b) => !(a == b);
}2. IAggregateRoot.cs – Marker Interface
// File: ECommerce.Domain/Common/IAggregateRoot.cs
namespace ECommerce.Domain.Common;
public interface IAggregateRoot { }3. Result.cs – Explicit Success/Failure (Preview)
// File: ECommerce.Domain/Common/Result.cs
namespace ECommerce.Domain.Common;
public record Result
{
public bool IsSuccess { get; }
public string Error { get; } = string.Empty;
public bool IsFailure => !IsSuccess;
protected Result(bool isSuccess, string error)
{
IsSuccess = isSuccess;
Error = error;
}
public static Result Success() => new(true, string.Empty);
public static Result Failure(string error) => new(false, error);
}
public record Result<T> : Result
{
public T? Value { get; }
private Result(T value) : base(true, string.Empty) => Value = value;
private Result(string error) : base(false, error) => Value = default;
public static Result<T> Success(T value) => new(value);
public static implicit operator Result<T>(T value) => Success(value);
public static Result<T> Failure(string error) => new(error);
}Banishing Primitive Obsession: The Power of Value Objects in Domain Modeling
Primitive Obsession occurs when domain concepts are represented using basic types such as string, int, or decimal, without encapsulating their inherent rules, validation, and behavior. In an e-commerce system, treating email as a mere string or price as a decimal invites scattered validation, inconsistent formatting, and fragile code.
Value Objects (VOs) eradicate this anti-pattern by modeling domain concepts as first-class, immutable types with:
- Structural equality (two VOs are equal if all components are equal)
- No identity (unlike entities)
- Self-validation
- Behavior (e.g., formatting, comparison)
In DDD, Value Objects are the building blocks of expressive, type-safe domain models. They enhance readability, reduce bugs, and enforce business invariants at the type level.
Core Characteristics of a Value Object
| Property | Rationale |
|---|---|
| Immutability | Prevents unintended state changes; enables safe sharing |
| Equality by Value | EmailAddress(“a@x.com”) == EmailAddress(“a@x.com”) |
| Validation in Constructor | Fail-fast on invalid input |
| No Side Effects | Pure, predictable behavior |
Introducing Value Objects: FullName and EmailAddress
We now define two foundational Value Objects used by the Customer entity.
1. FullName Value Object
Represents a person’s complete name with validation and formatting rules.
// File: ECommerce.Domain/ValueObjects/FullName.cs
using System.Text.RegularExpressions;
using ECommerce.Domain.Common;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.ValueObjects;
public sealed record FullName : ValueObject
{
private const int MinLength = 1;
private const int MaxLength = 100;
private static readonly Regex NameRegex = new(@"^[a-zA-Z\s\'\-]+$", RegexOptions.Compiled);
public string FirstName { get; init; }
public string LastName { get; init; }
public string DisplayName => $"{FirstName.Trim()} {LastName.Trim()}";
private FullName(string firstName, string lastName)
{
FirstName = firstName.Trim();
LastName = lastName.Trim();
}
public static Result<FullName> Create(string firstName, string lastName)
{
if (string.IsNullOrWhiteSpace(firstName))
return Result<FullName>.Failure(DomainErrors.Customer.FirstNameRequired);
if (string.IsNullOrWhiteSpace(lastName))
return Result<FullName>.Failure(DomainErrors.Customer.LastNameRequired);
if (firstName.Length < MinLength || firstName.Length > MaxLength)
return Result<FullName>.Failure(DomainErrors.Customer.FirstNameInvalidLength);
if (lastName.Length < MinLength || lastName.Length > MaxLength)
return Result<FullName>.Failure(DomainErrors.Customer.LastNameInvalidLength);
if (!NameRegex.IsMatch(firstName) || !NameRegex.IsMatch(lastName))
return Result<FullName>.Failure(DomainErrors.Customer.NameContainsInvalidCharacters);
return Result<FullName>.Success(new FullName(firstName, lastName));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return FirstName.ToLowerInvariant();
yield return LastName.ToLowerInvariant();
}
public override string ToString() => DisplayName;
}2. EmailAddress Value Object
Encapsulates email validation, normalization, and domain-specific rules.
// File: ECommerce.Domain/ValueObjects/EmailAddress.cs
using System.Text.RegularExpressions;
using ECommerce.Domain.Common;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.ValueObjects;
public sealed record EmailAddress : ValueObject
{
private const int MaxLength = 254;
private static readonly Regex EmailRegex = new(
pattern: @"^[^@\s]+@[^@\s]+\.[^@\s]+$",
options: RegexOptions.Compiled | RegexOptions.IgnoreCase);
public string Value { get; init; }
private EmailAddress(string value)
{
Value = value.Trim().ToLowerInvariant();
}
public static Result<EmailAddress> Create(string email)
{
if (string.IsNullOrWhiteSpace(email))
return Result<EmailAddress>.Failure(DomainErrors.Customer.EmailRequired);
if (email.Length > MaxLength)
return Result<EmailAddress>.Failure(DomainErrors.Customer.EmailTooLong);
if (!EmailRegex.IsMatch(email))
return Result<EmailAddress>.Failure(DomainErrors.Customer.InvalidEmailFormat);
return Result<EmailAddress>.Success(new EmailAddress(email));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
public override string ToString() => Value;
public static implicit operator string(EmailAddress email) => email.Value;
}Supporting Infrastructure: ValueObject Base Class
To avoid duplication, introduce an abstract ValueObject base class.
// File: ECommerce.Domain/Common/ValueObject.cs
namespace ECommerce.Domain.Common;
public abstract class ValueObject
{
protected abstract IEnumerable<object> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj == null || obj.GetType() != GetType())
return false;
var other = (ValueObject)obj;
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}
public override int GetHashCode()
{
return GetEqualityComponents()
.Select(x => x?.GetHashCode() ?? 0)
.Aggregate((x, y) => x ^ y);
}
public static bool operator ==(ValueObject? a, ValueObject? b)
{
if (a is null && b is null) return true;
if (a is null || b is null) return false;
return a.Equals(b);
}
public static bool operator !=(ValueObject? a, ValueObject? b) => !(a == b);
}Refactoring Customer to Use Value Objects
Update the Customer entity to leverage these VOs:
// Updated excerpts from Customer.cs
public FullName Name { get; private set; } = default!;
public EmailAddress Email { get; private set; } = default!;
// In Create method:
public static Result<Customer> Create(string firstName, string lastName, string email)
{
var nameResult = FullName.Create(firstName, lastName);
if (!nameResult.IsSuccess)
return Result<Customer>.Failure(nameResult.Error);
var emailResult = EmailAddress.Create(email);
if (!emailResult.IsSuccess)
return Result<Customer>.Failure(emailResult.Error);
var customer = new Customer(Guid.NewGuid(), nameResult.Value, emailResult.Value);
return Result<Customer>.Success(customer);
}
// Update methods:
public Result UpdateName(string firstName, string lastName)
{
var result = FullName.Create(firstName, lastName);
if (!result.IsSuccess) return result;
Name = result.Value;
UpdatedAt = DateTime.UtcNow;
return Result.Success();
}Centralized Domain Errors
Define error codes for consistency across layers.
// File: ECommerce.Domain/Errors/DomainErrors.cs
namespace ECommerce.Domain.Errors;
public static class DomainErrors
{
public static class Customer
{
public const string FirstNameRequired = "CUSTOMER_FIRSTNAME_REQUIRED";
public const string LastNameRequired = "CUSTOMER_LASTNAME_REQUIRED";
public const string FirstNameInvalidLength = "CUSTOMER_FIRSTNAME_INVALID_LENGTH";
public const string LastNameInvalidLength = "CUSTOMER_LASTNAME_INVALID_LENGTH";
public const string NameContainsInvalidCharacters = "CUSTOMER_NAME_INVALID_CHARS";
public const string EmailRequired = "CUSTOMER_EMAIL_REQUIRED";
public const string EmailTooLong = "CUSTOMER_EMAIL_TOO_LONG";
public const string InvalidEmailFormat = "CUSTOMER_EMAIL_INVALID_FORMAT";
}
}Benefits Realized in the E-Commerce Domain
| Benefit | Example |
|---|---|
| Type Safety | Customer.Email is EmailAddress, not string |
| Single Source of Validation | All email checks in EmailAddress.Create |
| Business Rule Encapsulation | Name formatting, regex, length limits |
| Testability | Unit test EmailAddress in isolation |
| Readability | new Customer(…, new FullName(“John”, “Doe”), …) |
The Importance of Private Setters and Encapsulation in the Domain Model
Encapsulation is a cornerstone of DDD. The Domain Layer must protect its invariants—rules that must always hold true.
Why Private Setters?
public FullName Name { get; private set; }- Prevents external mutation: No layer can reassign Name directly.
- Forces behavior through methods: UpdateName() validates and updates audit fields.
- Maintains consistency: UpdatedAt is set only when a real change occurs.
Example: Invalid State Prevention
Without encapsulation:
customer.Name = null; // Compile-time allowed, runtime disasterWith encapsulation:
customer.UpdateName("", ""); // Returns Result.Failure(...) — controlledThis ensures all state transitions are valid.
Elevating Object Creation: The Static Factory Pattern in Domain Modeling
The Static Factory Method is a creational pattern that replaces direct constructor invocation with a dedicated static method. In DDD, it serves as the canonical entry point for entity and value object instantiation, enabling:
- Intent-revealing names (Customer.Create(…) vs. new Customer(…))
- Centralized validation
- Controlled object lifecycle
- Return of subtypes (if needed in future)
- Result-based creation with explicit success/failure
We already applied this in Customer.Create, FullName.Create, and EmailAddress.Create. Now, we formalize and expand its role.
Igniting Business Significance: Introducing Domain Events
Domain Events represent something that happened in the domain with business meaning. They enable loose coupling, audit trails, event sourcing, and cross-aggregate coordination.
Core Principles
| Principle | Implementation |
|---|---|
| Past tense naming | ProductAddedToCatalog, InventoryReserved |
| Immutable | Events are facts; never change |
| Part of the Ubiquitous Language | Named with domain experts |
| Raised within aggregates | Only aggregate roots publish events |
Event Infrastructure
// File: ECommerce.Domain/Events/IDomainEvent.cs
namespace ECommerce.Domain.Events;
public interface IDomainEvent { }// File: ECommerce.Domain/Common/Entity.cs (updated)
public abstract class Entity<T> where T : notnull
{
public T Id { get; protected set; } = default!;
private readonly List<IDomainEvent> _domainEvents = new();
public IReadOnlyCollection<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();
protected Entity(T id) => Id = id;
protected void AddDomainEvent(IDomainEvent domainEvent)
{
_domainEvents.Add(domainEvent);
}
public void ClearDomainEvents() => _domainEvents.Clear();
// Equality members unchanged...
}// File: ECommerce.Domain/Common/IAggregateRoot.cs (updated)
public interface IAggregateRoot
{
IReadOnlyCollection<IDomainEvent> DomainEvents { get; }
void ClearDomainEvents();
}Sculpting the Catalog: The Product Aggregate Root
The Product is the central aggregate in the Catalog Bounded Context. It encapsulates:
- Identity (ProductId)
- Descriptive data (Name, Description, SKU)
- Pricing
- Lifecycle (Draft → Published → Archived)
- Inventory coordination (via events)
Value Objects for Product
1. Sku – Stock Keeping Unit
// File: ECommerce.Domain/ValueObjects/Sku.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.Errors;
using System.Text.RegularExpressions;
namespace ECommerce.Domain.ValueObjects;
public sealed record Sku : ValueObject
{
private const int ExpectedLength = 8;
private static readonly Regex SkuRegex = new(@"^[A-Z0-9]{8}$", RegexOptions.Compiled);
public string Value { get; init; }
private Sku(string value) => Value = value.ToUpperInvariant();
public static Result<Sku> Create(string sku)
{
if (string.IsNullOrWhiteSpace(sku))
return Result<Sku>.Failure(DomainErrors.Product.SkuRequired);
if (sku.Length != ExpectedLength)
return Result<Sku>.Failure(DomainErrors.Product.SkuInvalidLength);
if (!SkuRegex.IsMatch(sku))
return Result<Sku>.Failure(DomainErrors.Product.SkuInvalidFormat);
return Result<Sku>.Success(new Sku(sku));
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Value;
}
}2. Money – Currency-Aware Amount
// File: ECommerce.Domain/ValueObjects/Money.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.ValueObjects;
public sealed record Money : ValueObject
{
public decimal Amount { get; init; }
public string Currency { get; init; } = "USD"; // Default; configurable later
private Money(decimal amount, string currency)
{
Amount = decimal.Round(amount, 2);
Currency = currency.ToUpperInvariant();
}
public static Result<Money> Create(decimal amount, string? currency = null)
{
currency ??= "USD";
if (amount < 0)
return Result<Money>.Failure(DomainErrors.Product.PriceNegative);
if (!IsSupportedCurrency(currency))
return Result<Money>.Failure(DomainErrors.Product.CurrencyNotSupported);
return Result<Money>.Success(new Money(amount, currency));
}
private static bool IsSupportedCurrency(string currency) =>
currency is "USD" or "EUR" or "GBP"; // Expand as needed
public Money Add(Money other)
{
if (Currency != other.Currency)
throw new InvalidOperationException("Cannot add different currencies.");
return new Money(Amount + other.Amount, Currency);
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return Amount;
yield return Currency;
}
public override string ToString() => $"{Amount:F2} {Currency}";
}Domain Events for Product
// File: ECommerce.Domain/Events/ProductCreatedEvent.cs
namespace ECommerce.Domain.Events;
public record ProductCreatedEvent(
Guid ProductId,
string Name,
string Sku,
decimal Price,
string Currency) : IDomainEvent;// File: ECommerce.Domain/Events/ProductPublishedEvent.cs
namespace ECommerce.Domain.Events;
public record ProductPublishedEvent(Guid ProductId) : IDomainEvent;The Product Aggregate
// File: ECommerce.Domain/Aggregates/Product.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.Events;
using ECommerce.Domain.ValueObjects;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.Aggregates;
public enum ProductStatus
{
Draft,
Published,
Archived
}
public class Product : Entity<Guid>, IAggregateRoot
{
public Sku Sku { get; private set; } = default!;
public string Name { get; private set; } = default!;
public string? Description { get; private set; }
public Money Price { get; private set; } = default!;
public ProductStatus Status { get; private set; } = ProductStatus.Draft;
public DateTime CreatedAt { get; private set; }
public DateTime? PublishedAt { get; private set; }
private Product() { } // For EF Core
private Product(Guid id, Sku sku, string name, string? description, Money price) : base(id)
{
Sku = sku;
Name = name.Trim();
Description = description?.Trim();
Price = price;
Status = ProductStatus.Draft;
CreatedAt = DateTime.UtcNow;
AddDomainEvent(new ProductCreatedEvent(id, name, sku.Value, price.Amount, price.Currency));
}
public static Result<Product> Create(string sku, string name, string? description, decimal price, string? currency = null)
{
var skuResult = Sku.Create(sku);
if (!skuResult.IsSuccess) return Result<Product>.Failure(skuResult.Error);
var priceResult = Money.Create(price, currency);
if (!priceResult.IsSuccess) return Result<Product>.Failure(priceResult.Error);
if (string.IsNullOrWhiteSpace(name))
return Result<Product>.Failure(DomainErrors.Product.NameRequired);
if (name.Length > 200)
return Result<Product>.Failure(DomainErrors.Product.NameTooLong);
var product = new Product(Guid.NewGuid(), skuResult.Value, name, description, priceResult.Value);
return Result<Product>.Success(product);
}
public Result Publish()
{
if (Status != ProductStatus.Draft)
return Result.Failure(DomainErrors.Product.AlreadyPublished);
Status = ProductStatus.Published;
PublishedAt = DateTime.UtcNow;
AddDomainEvent(new ProductPublishedEvent(Id));
return Result.Success();
}
public Result UpdatePrice(decimal newPrice, string? currency = null)
{
var moneyResult = Money.Create(newPrice, currency ?? Price.Currency);
if (!moneyResult.IsSuccess) return moneyResult;
Price = moneyResult.Value;
return Result.Success();
}
public Result UpdateName(string newName)
{
if (string.IsNullOrWhiteSpace(newName))
return Result.Failure(DomainErrors.Product.NameRequired);
if (newName.Length > 200)
return Result.Failure(DomainErrors.Product.NameTooLong);
Name = newName.Trim();
return Result.Success();
}
}Updated DomainErrors.cs
// Add to existing file
public static class Product
{
public const string SkuRequired = "PRODUCT_SKU_REQUIRED";
public const string SkuInvalidLength = "PRODUCT_SKU_INVALID_LENGTH";
public const string SkuInvalidFormat = "PRODUCT_SKU_INVALID_FORMAT";
public const string NameRequired = "PRODUCT_NAME_REQUIRED";
public const string NameTooLong = "PRODUCT_NAME_TOO_LONG";
public const string PriceNegative = "PRODUCT_PRICE_NEGATIVE";
public const string CurrencyNotSupported = "PRODUCT_CURRENCY_NOT_SUPPORTED";
public const string AlreadyPublished = "PRODUCT_ALREADY_PUBLISHED";
}Using the Factory and Events: Example Flow
var productResult = Product.Create(
sku: "ABC12345",
name: "Wireless Headphones",
description: "Premium noise-cancelling headphones",
price: 199.99m,
currency: "USD");
if (productResult.IsFailure)
{
// Handle error
return;
}
var product = productResult.Value;
product.Publish();
// Events available:
// product.DomainEvents[0] = ProductCreatedEvent
// product.DomainEvents[1] = ProductPublishedEventBridging the Domain to Persistence: Repositories and the Unit of Work Pattern
In DDD, Repositories abstract the persistence mechanism, allowing the Domain Layer to remain agnostic of databases, ORMs, or infrastructure. They provide a collection-like interface for retrieving and storing aggregates, ensuring that business logic operates on in-memory objects.
The Unit of Work (UoW) coordinates transactional boundaries across multiple repositories, guaranteeing atomicity when persisting changes.
Key Principles
| Principle | Rationale |
|---|---|
| Interface in Domain | Only IRepository interfaces reside in the Domain Layer |
| Implementation in Infrastructure | EF Core, Dapper, etc., implement in a separate project |
| Aggregate-focused | One repository per aggregate root |
| No CRUD methods on entities | Entities are not aware of persistence |
Repository Interfaces in the Domain
// File: ECommerce.Domain/Repositories/ICustomerRepository.cs
using ECommerce.Domain.Aggregates;
namespace ECommerce.Domain.Repositories;
public interface ICustomerRepository
{
Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Customer?> GetByEmailAsync(EmailAddress email, CancellationToken ct = default);
Task AddAsync(Customer customer, CancellationToken ct = default);
Task UpdateAsync(Customer customer, CancellationToken ct = default);
Task<bool> ExistsByEmailAsync(EmailAddress email, CancellationToken ct = default);
}// File: ECommerce.Domain/Repositories/IProductRepository.cs
using ECommerce.Domain.Aggregates;
namespace ECommerce.Domain.Repositories;
public interface IProductRepository
{
Task<Product?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<Product?> GetBySkuAsync(Sku sku, CancellationToken ct = default);
Task AddAsync(Product product, CancellationToken ct = default);
Task UpdateAsync(Product product, CancellationToken ct = default);
Task<IReadOnlyList<Product>> ListPublishedAsync(CancellationToken ct = default);
}// File: ECommerce.Domain/Repositories/IUnitOfWork.cs
namespace ECommerce.Domain.Repositories;
public interface IUnitOfWork : IDisposable
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
Task BeginTransactionAsync(CancellationToken ct = default);
Task CommitTransactionAsync(CancellationToken ct = default);
Task RollbackTransactionAsync(CancellationToken ct = default);
}Modeling the Shopping Cart: The Cart Aggregate Root
The Cart is a temporary aggregate representing a customer’s intent to purchase. It is not persisted long-term but is critical for pricing, availability, and checkout.
Value Objects for Cart
1. CartItem
// File: ECommerce.Domain/ValueObjects/CartItem.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.ValueObjects;
namespace ECommerce.Domain.ValueObjects;
public sealed record CartItem : ValueObject
{
public Guid ProductId { get; init; }
public string ProductName { get; init; } = default!;
public Money UnitPrice { get; init; } = default!;
public int Quantity { get; init; }
private CartItem() { }
public CartItem(Guid productId, string productName, Money unitPrice, int quantity)
{
if (quantity <= 0) throw new ArgumentOutOfRangeException(nameof(quantity));
ProductId = productId;
ProductName = productName;
UnitPrice = unitPrice;
Quantity = quantity;
}
public Money LineTotal => UnitPrice.Multiply(Quantity);
protected override IEnumerable<object> GetEqualityComponents()
{
yield return ProductId;
}
}2. Money Extension (Add to existing Money.cs)
public Money Multiply(int quantity) => new(Amount * quantity, Currency);Domain Events for Cart
// File: ECommerce.Domain/Events/CartItemAddedEvent.cs
namespace ECommerce.Domain.Events;
public record CartItemAddedEvent(
Guid CartId,
Guid CustomerId,
Guid ProductId,
int Quantity,
decimal UnitPrice,
string Currency) : IDomainEvent;// File: ECommerce.Domain/Events/CartCheckedOutEvent.cs
namespace ECommerce.Domain.Events;
public record CartCheckedOutEvent(
Guid CartId,
Guid CustomerId,
IReadOnlyList<CartItem> Items,
Money Total) : IDomainEvent;The Cart Aggregate
// File: ECommerce.Domain/Aggregates/Cart.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.Events;
using ECommerce.Domain.ValueObjects;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.Aggregates;
public enum CartStatus
{
Active,
CheckedOut,
Abandoned
}
public class Cart : Entity<Guid>, IAggregateRoot
{
public Guid CustomerId { get; private set; }
public CartStatus Status { get; private set; } = CartStatus.Active;
public DateTime CreatedAt { get; private set; }
public DateTime? CheckedOutAt { get; private set; }
private readonly List<CartItem> _items = new();
public IReadOnlyCollection<CartItem> Items => _items.AsReadOnly();
private Cart() { } // EF Core
private Cart(Guid id, Guid customerId) : base(id)
{
CustomerId = customerId;
CreatedAt = DateTime.UtcNow;
}
public static Result<Cart> Create(Guid customerId)
{
if (customerId == Guid.Empty)
return Result<Cart>.Failure(DomainErrors.Cart.CustomerRequired);
return Result<Cart>.Success(new Cart(Guid.NewGuid(), customerId));
}
public Result AddItem(Guid productId, string productName, Money unitPrice, int quantity)
{
if (Status != CartStatus.Active)
return Result.Failure(DomainErrors.Cart.NotActive);
if (productId == Guid.Empty)
return Result.Failure(DomainErrors.Cart.InvalidProductId);
if (quantity <= 0)
return Result.Failure(DomainErrors.Cart.QuantityMustBePositive);
var existing = _items.FirstOrDefault(i => i.ProductId == productId);
if (existing != null)
{
var updated = new CartItem(productId, productName, unitPrice, existing.Quantity + quantity);
_items.Remove(existing);
_items.Add(updated);
}
else
{
_items.Add(new CartItem(productId, productName, unitPrice, quantity));
}
AddDomainEvent(new CartItemAddedEvent(
Id, CustomerId, productId, quantity, unitPrice.Amount, unitPrice.Currency));
return Result.Success();
}
public Result RemoveItem(Guid productId)
{
var item = _items.FirstOrDefault(i => i.ProductId == productId);
if (item == null)
return Result.Failure(DomainErrors.Cart.ItemNotFound);
_items.Remove(item);
return Result.Success();
}
public Money CalculateTotal()
{
return _items.Aggregate(Money.Create(0).Value, (total, item) => total.Add(item.LineTotal));
}
public Result Checkout()
{
if (Status != CartStatus.Active)
return Result.Failure(DomainErrors.Cart.NotActive);
if (!_items.Any())
return Result.Failure(DomainErrors.Cart.Empty);
Status = CartStatus.CheckedOut;
CheckedOutAt = DateTime.UtcNow;
AddDomainEvent(new CartCheckedOutEvent(Id, CustomerId, Items.ToList(), CalculateTotal()));
return Result.Success();
}
}Updated DomainErrors.cs
public static class Cart
{
public const string CustomerRequired = "CART_CUSTOMER_REQUIRED";
public const string NotActive = "CART_NOT_ACTIVE";
public const string InvalidProductId = "CART_INVALID_PRODUCT_ID";
public const string QuantityMustBePositive = "CART_QUANTITY_POSITIVE";
public const string ItemNotFound = "CART_ITEM_NOT_FOUND";
public const string Empty = "CART_EMPTY";
}Orchestrating Complex Logic: Implementing Domain Services
Domain Services contain operations that don’t naturally fit within a single entity or value object—often involving multiple aggregates or external systems.
Example: IPricingService – Dynamic Pricing with Promotions
// File: ECommerce.Domain/Services/IPricingService.cs
using ECommerce.Domain.ValueObjects;
namespace ECommerce.Domain.Services;
public interface IPricingService
{
Task<Money> CalculateFinalPriceAsync(Guid productId, int quantity, Guid? customerId = null, CancellationToken ct = default);
}// File: ECommerce.Domain/Services/ICartService.cs
using ECommerce.Domain.Aggregates;
namespace ECommerce.Domain.Services;
public interface ICartService
{
Task<Result<Cart>> GetOrCreateCartAsync(Guid customerId, CancellationToken ct = default);
Task<Result> AddToCartAsync(Guid customerId, Guid productId, int quantity, CancellationToken ct = default);
}Implementation (Domain-Only Logic)
// File: ECommerce.Domain/Services/CartService.cs
using ECommerce.Domain.Aggregates;
using ECommerce.Domain.Repositories;
namespace ECommerce.Domain.Services;
public class CartService : ICartService
{
private readonly ICustomerRepository _customerRepository;
private readonly IProductRepository _productRepository;
private readonly IUnitOfWork _unitOfWork;
public CartService(
ICustomerRepository customerRepository,
IProductRepository productRepository,
IUnitOfWork unitOfWork)
{
_customerRepository = customerRepository;
_productRepository = productRepository;
_unitOfWork = unitOfWork;
}
public async Task<Result<Cart>> GetOrCreateCartAsync(Guid customerId, CancellationToken ct = default)
{
var customer = await _customerRepository.GetByIdAsync(customerId, ct);
if (customer == null)
return Result<Cart>.Failure(DomainErrors.Customer.NotFound);
// In real system: load existing cart from persistence
// Here: create new for simplicity
return Cart.Create(customerId);
}
public async Task<Result> AddToCartAsync(Guid customerId, Guid productId, int quantity, CancellationToken ct = default)
{
var customer = await _customerRepository.GetByIdAsync(customerId, ct);
if (customer == null)
return Result.Failure(DomainErrors.Customer.NotFound);
var product = await _productRepository.GetByIdAsync(productId, ct);
if (product == null || product.Status != ProductStatus.Published)
return Result.Failure(DomainErrors.Product.NotFoundOrUnpublished);
var cartResult = await GetOrCreateCartAsync(customerId, ct);
if (cartResult.IsFailure) return cartResult;
var cart = cartResult.Value;
var addResult = cart.AddItem(productId, product.Name, product.Price, quantity);
if (addResult.IsFailure) return addResult;
// In full system: persist cart via repository
await _unitOfWork.SaveChangesAsync(ct);
return Result.Success();
}
}Refining Polymorphic Behavior: Double Dispatch for Extensible Domain Logic
In complex domains like e-commerce, pricing strategies vary by product type: standard items, bundles, digital downloads, or subscription-based products. Using Double Dispatch (or the Visitor Pattern) eliminates type-checking (is/as) and enables clean, extensible behavior without modifying existing classes.
Scenario: Dynamic Pricing with Promotions
A Promotion can apply different discounts based on product type. Double Dispatch ensures the correct logic executes without fragile conditionals.
1. Define the Pricing Visitor Interface
// File: ECommerce.Domain/Services/IPricingVisitor.cs
using ECommerce.Domain.ValueObjects;
namespace ECommerce.Domain.Services;
public interface IPricingVisitor
{
Money VisitStandardProduct(StandardProduct product, Money basePrice, int quantity);
Money VisitBundleProduct(BundleProduct bundle, Money basePrice, int quantity);
Money VisitDigitalProduct(DigitalProduct digital, Money basePrice, int quantity);
}2. Product Hierarchy with Accept Method
Refactor Product into a base with specific subtypes.
// File: ECommerce.Domain/Aggregates/Product.cs (base)
public abstract class Product : Entity<Guid>, IAggregateRoot
{
public Sku Sku { get; protected set; } = default!;
public string Name { get; protected set; } = default!;
public Money BasePrice { get; protected set; } = default!;
public ProductStatus Status { get; protected set; } = ProductStatus.Draft;
protected Product() { }
public abstract Money Accept(IPricingVisitor visitor, int quantity);
}Concrete Product Types
// File: ECommerce.Domain/Aggregates/StandardProduct.cs
public class StandardProduct : Product
{
public override Money Accept(IPricingVisitor visitor, int quantity) =>
visitor.VisitStandardProduct(this, BasePrice, quantity);
}// File: ECommerce.Domain/Aggregates/BundleProduct.cs
public class BundleProduct : Product
{
public IReadOnlyList<Guid> IncludedProductIds { get; private set; } = default!;
public override Money Accept(IPricingVisitor visitor, int quantity) =>
visitor.VisitBundleProduct(this, BasePrice, quantity);
}// File: ECommerce.Domain/Aggregates/DigitalProduct.cs
public class DigitalProduct : Product
{
public override Money Accept(IPricingVisitor visitor, int quantity) =>
visitor.VisitDigitalProduct(this, BasePrice, quantity);
}3. Promotion as Visitor
// File: ECommerce.Domain/Services/PercentageDiscountPromotion.cs
using ECommerce.Domain.ValueObjects;
namespace ECommerce.Domain.Services;
public class PercentageDiscountPromotion : IPricingVisitor
{
private readonly decimal _percentage;
public PercentageDiscountPromotion(decimal percentage) =>
_percentage = percentage > 0 && percentage <= 100 ? percentage : throw new ArgumentException("Invalid percentage.");
public Money VisitStandardProduct(StandardProduct product, Money basePrice, int quantity)
{
var discount = basePrice.Amount * (_percentage / 100);
return basePrice.Subtract(Money.Create(discount).Value).Multiply(quantity);
}
public Money VisitBundleProduct(BundleProduct bundle, Money basePrice, int quantity)
{
// Bundles get 1.5x discount
var discount = basePrice.Amount * (_percentage * 1.5m / 100);
return basePrice.Subtract(Money.Create(discount).Value).Multiply(quantity);
}
public Money VisitDigitalProduct(DigitalProduct digital, Money basePrice, int quantity)
{
// Digital: no quantity multiplier
var discount = basePrice.Amount * (_percentage / 100);
return basePrice.Subtract(Money.Create(discount).Value);
}
}Add Subtract to Money.cs:
public Money Subtract(Money other)
{
if (Currency != other.Currency) throw new InvalidOperationException("Currency mismatch.");
return new Money(Amount - other.Amount, Currency);
}Finalizing the Order Aggregate: From Cart to Persistence
The Order is the source of truth post-checkout. It is immutable after creation and captures the snapshot of pricing and items.
Value Objects
OrderItem
// File: ECommerce.Domain/ValueObjects/OrderItem.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.ValueObjects;
namespace ECommerce.Domain.ValueObjects;
public sealed record OrderItem : ValueObject
{
public Guid ProductId { get; init; }
public string ProductName { get; init; } = default!;
public Money UnitPrice { get; init; } = default!;
public int Quantity { get; init; }
public Money LineTotal => UnitPrice.Multiply(Quantity);
protected override IEnumerable<object> GetEqualityComponents()
{
yield return ProductId;
yield return UnitPrice;
yield return Quantity;
}
}Domain Events
// File: ECommerce.Domain/Events/OrderCreatedEvent.cs
namespace ECommerce.Domain.Events;
public record OrderCreatedEvent(
Guid OrderId,
Guid CustomerId,
IReadOnlyList<OrderItem> Items,
Money TotalAmount,
DateTime CreatedAt) : IDomainEvent;The Order Aggregate
// File: ECommerce.Domain/Aggregates/Order.cs
using ECommerce.Domain.Common;
using ECommerce.Domain.Events;
using ECommerce.Domain.ValueObjects;
using ECommerce.Domain.Errors;
namespace ECommerce.Domain.Aggregates;
public enum OrderStatus
{
Pending,
Confirmed,
Shipped,
Delivered,
Cancelled
}
public class Order : Entity<Guid>, IAggregateRoot
{
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; } = OrderStatus.Pending;
public Money TotalAmount { get; private set; } = default!;
public DateTime CreatedAt { get; private set; }
public DateTime? ConfirmedAt { get; private set; }
private readonly List<OrderItem> _items = new();
public IReadOnlyCollection<OrderItem> Items => _items.AsReadOnly();
private Order() { }
private Order(Guid id, Guid customerId, IReadOnlyList<OrderItem> items, Money total) : base(id)
{
CustomerId = customerId;
_items.AddRange(items);
TotalAmount = total;
CreatedAt = DateTime.UtcNow;
AddDomainEvent(new OrderCreatedEvent(id, customerId, items, total, CreatedAt));
}
public static Result<Order> Create(Guid customerId, IReadOnlyList<CartItem> cartItems, IPricingVisitor? pricingVisitor = null)
{
if (customerId == Guid.Empty)
return Result<Order>.Failure(DomainErrors.Order.CustomerRequired);
if (!cartItems.Any())
return Result<Order>.Failure(DomainErrors.Order.Empty);
var orderItems = new List<OrderItem>();
var total = Money.Create(0).Value;
foreach (var cartItem in cartItems)
{
var unitPrice = pricingVisitor != null
? GetPriceWithPromotion(cartItem, pricingVisitor)
: cartItem.UnitPrice;
var orderItem = new OrderItem
{
ProductId = cartItem.ProductId,
ProductName = cartItem.ProductName,
UnitPrice = unitPrice,
Quantity = cartItem.Quantity
};
orderItems.Add(orderItem);
total = total.Add(orderItem.LineTotal);
}
return Result<Order>.Success(new Order(Guid.NewGuid(), customerId, orderItems, total));
}
private static Money GetPriceWithPromotion(CartItem item, IPricingVisitor visitor)
{
// In real system: resolve product type and call Accept
// Simplified: assume standard
return visitor.VisitStandardProduct(null!, item.UnitPrice, item.Quantity) with { };
}
public Result Confirm()
{
if (Status != OrderStatus.Pending)
return Result.Failure(DomainErrors.Order.AlreadyConfirmed);
Status = OrderStatus.Confirmed;
ConfirmedAt = DateTime.UtcNow;
return Result.Success();
}
}Updated DomainErrors.cs
public static class Order
{
public const string CustomerRequired = "ORDER_CUSTOMER_REQUIRED";
public const string Empty = "ORDER_EMPTY";
public const string AlreadyConfirmed = "ORDER_ALREADY_CONFIRMED";
}Enhancing the Result Pattern: Fluent Validation and Error Aggregation
Improve Result to support multiple errors and fluent chaining.
// File: ECommerce.Domain/Common/Result.cs (enhanced)
public record Result
{
public bool IsSuccess { get; }
public IReadOnlyList<string> Errors { get; }
public bool IsFailure => !IsSuccess;
protected Result(bool isSuccess, IReadOnlyList<string> errors)
{
IsSuccess = isSuccess;
Errors = errors;
}
public static Result Success() => new(true, Array.Empty<string>());
public static Result Failure(string error) => new(false, new[] { error });
public static Result Failure(IReadOnlyList<string> errors) => new(false, errors);
}
public record Result<T> : Result
{
public T? Value { get; }
private Result(T value) : base(true, Array.Empty<string>()) => Value = value;
private Result(IReadOnlyList<string> errors) : base(false, errors) => Value = default;
public static Result<T> Success(T value) => new(value);
public static Result<T> Failure(string error) => new(new[] { error });
public static Result<T> Failure(IReadOnlyList<string> errors) => new(errors);
}Repository for Order
// File: ECommerce.Domain/Repositories/IOrderRepository.cs
namespace ECommerce.Domain.Repositories;
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task AddAsync(Order order, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default);
}Recap: A Complete DDD E-Commerce Domain
| Component | Implementation |
|---|---|
| Entities | Customer, Product (with subtypes), Cart, Order |
| Value Objects | FullName, EmailAddress, Sku, Money, CartItem, OrderItem |
| Encapsulation | Private setters, behavior methods, invariants |
| Static Factory | Create methods with Result<T> |
| Domain Events | ProductCreated, CartCheckedOut, OrderCreated |
| Repositories | Interfaces per aggregate |
| Unit of Work | Transaction coordination |
| Domain Services | ICartService, IPricingVisitor |
| Double Dispatch | Polymorphic pricing via Visitor |
| Result Pattern | Explicit success/failure with error aggregation |
Key Benefits Realized
- Expressiveness: Code reads like business language
- Maintainability: Changes in one bounded context don’t ripple
- Testability: Pure domain logic, no infrastructure
- Scalability: Events enable CQRS, event sourcing
- Resilience: Invariants enforced at the model level
Final Thoughts
This five-part series has constructed a production-ready DDD architecture in .NET Core 9, using an e-commerce system as the domain. The patterns—entities, value objects, aggregates, factories, events, repositories, services, and double dispatch—form a cohesive, extensible foundation. Apply these principles incrementally. Start with a bounded context, enforce ubiquitous language, and let the domain model evolve with business needs. The true power of DDD lies not in the code, but in the shared understanding it fosters between developers and domain experts.




