Mastering Domain-Driven Design in .NET Core 9: Building Robust Domain Layers for E-Commerce Systems

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:

  1. 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.
  2. 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).
  3. 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.0

This 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 codes

We 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.csproj

The 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

ConceptImplementation
IdentityEntity<Guid> base class ensures Id is immutable
EncapsulationPrivate setters; changes via methods only
ValidationHandled in Create and Update methods
Factory MethodCustomer.Create(…) centralizes instantiation
Immutability of IdentityId set once in private constructor
Aggregate RootIAggregateRoot 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

PropertyRationale
ImmutabilityPrevents unintended state changes; enables safe sharing
Equality by ValueEmailAddress(“a@x.com”) == EmailAddress(“a@x.com”)
Validation in ConstructorFail-fast on invalid input
No Side EffectsPure, 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

BenefitExample
Type SafetyCustomer.Email is EmailAddress, not string
Single Source of ValidationAll email checks in EmailAddress.Create
Business Rule EncapsulationName formatting, regex, length limits
TestabilityUnit test EmailAddress in isolation
Readabilitynew 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 disaster

With encapsulation:

customer.UpdateName("", ""); // Returns Result.Failure(...) — controlled

This 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

PrincipleImplementation
Past tense namingProductAddedToCatalog, InventoryReserved
ImmutableEvents are facts; never change
Part of the Ubiquitous LanguageNamed with domain experts
Raised within aggregatesOnly 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] = ProductPublishedEvent

Bridging 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

PrincipleRationale
Interface in DomainOnly IRepository interfaces reside in the Domain Layer
Implementation in InfrastructureEF Core, Dapper, etc., implement in a separate project
Aggregate-focusedOne repository per aggregate root
No CRUD methods on entitiesEntities 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

ComponentImplementation
EntitiesCustomer, Product (with subtypes), Cart, Order
Value ObjectsFullName, EmailAddress, Sku, Money, CartItem, OrderItem
EncapsulationPrivate setters, behavior methods, invariants
Static FactoryCreate methods with Result<T>
Domain EventsProductCreated, CartCheckedOut, OrderCreated
RepositoriesInterfaces per aggregate
Unit of WorkTransaction coordination
Domain ServicesICartService, IPricingVisitor
Double DispatchPolymorphic pricing via Visitor
Result PatternExplicit 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.

Uma Mahesh
Uma Mahesh

Author is working as an Architect in a reputed software company. He is having nearly 21+ Years of experience in web development using Microsoft Technologies.

Articles: 285