The Domain Layer in Clean Architecture

The Domain Layer in Clean Architecture

7 min read··

clean-architectureddddotnet

Every business rule in your system should have exactly one home: the Domain layer. In Clean Architecture that's the innermost layer, holding entities, value objects, domain events, domain services, repository interfaces, and domain errors, with zero external package references.

Every Clean Architecture diagram puts the Domain layer at the center, but most of them never tell you what actually goes inside it. That's a problem, because the Domain layer is the one part of the system you can't afford to get wrong. Here is what belongs there, what doesn't, and how to keep it free of every framework concern.

What Is the Domain Layer?

The Domain layer is the innermost layer in Clean Architecture. It sits at the center of the dependency graph, and nothing depends on anything outside of it.

This is where your business rules live. Not HTTP concerns. Not database details. Just the rules that make your business what it is.

The Domain layer contains:

  • Entities - objects with identity and lifecycle
  • Value Objects - immutable objects defined by their attributes
  • Domain Events - notifications that something meaningful happened
  • Enumerations - smart enums representing domain concepts
  • Domain Services - business logic that doesn't belong to a single entity
  • Repository Interfaces - abstractions for data access
  • Custom Exceptions - domain-specific error types

Entities

Entities are objects defined by their identity, not their attributes. Two customers with the same name are still different customers.

public abstract class Entity : IEquatable<Entity>
{
    private readonly List<IDomainEvent> _domainEvents = new();

    protected Entity(Guid id)
    {
        Id = id;
    }

    public Guid Id { get; private init; }

    public IReadOnlyList<IDomainEvent> DomainEvents => _domainEvents.AsReadOnly();

    public void ClearDomainEvents() => _domainEvents.Clear();

    protected void RaiseDomainEvent(IDomainEvent domainEvent) =>
        _domainEvents.Add(domainEvent);

    public bool Equals(Entity? other)
    {
        return other is not null && Id == other.Id;
    }

    public override bool Equals(object? obj)
    {
        return obj is Entity entity && Equals(entity);
    }

    public override int GetHashCode() => Id.GetHashCode();
}

The base class also collects domain events (we'll define IDomainEvent in a moment), so entities can record what happened and let an outer layer publish it after saving.

And a concrete entity:

public sealed class Customer : Entity
{
    private Customer(Guid id, string name, Email email) : base(id)
    {
        Name = name;
        Email = email;
    }

    public string Name { get; private set; }
    public Email Email { get; private set; }

    public static Customer Create(string name, Email email)
    {
        var customer = new Customer(Guid.NewGuid(), name, email);

        customer.RaiseDomainEvent(new CustomerCreatedDomainEvent(customer.Id));

        return customer;
    }

    public void UpdateName(string name)
    {
        if (string.IsNullOrWhiteSpace(name))
        {
            throw new DomainException("Customer name cannot be empty.");
        }

        Name = name;
    }
}

Key principles:

  • Private constructor - creation goes through a factory method that enforces invariants
  • Private setters - state changes go through methods that validate the transition
  • Domain events - the entity signals when something important happens

This is what makes the domain model the best place to enforce invariants: there's no way to construct or mutate the entity into an invalid state.

Value Objects

Value Objects represent concepts with no identity. Two Email("test@example.com") instances are equal.

public sealed class Email : ValueObject
{
    private Email(string value) => Value = value;

    public string Value { get; }

    public static Result<Email> Create(string email)
    {
        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
        {
            return Result.Failure<Email>(DomainErrors.Email.InvalidFormat);
        }

        return new Email(email.Trim().ToLowerInvariant());
    }

    protected override IEnumerable<object> GetAtomicValues()
    {
        yield return Value;
    }
}

Use Value Objects to replace primitives wherever a concept has business rules attached to it.

Domain Events

Domain events represent something that happened in the domain. They're raised by entities and handled by the Application layer.

public interface IDomainEvent
{
    Guid Id { get; }
    DateTime OccurredOnUtc { get; }
}

public sealed record CustomerCreatedDomainEvent(Guid CustomerId) : IDomainEvent
{
    public Guid Id { get; } = Guid.NewGuid();
    public DateTime OccurredOnUtc { get; } = DateTime.UtcNow;
}

Domain events live in the Domain layer because they describe domain facts. But the handlers live in the Application layer.

Smart Enumerations

Instead of plain enum types, use smart enums for domain concepts that carry behavior:

public abstract class OrderStatus : Enumeration<OrderStatus>
{
    public static readonly OrderStatus Draft = new DraftStatus();
    public static readonly OrderStatus Confirmed = new ConfirmedStatus();
    public static readonly OrderStatus Shipped = new ShippedStatus();
    public static readonly OrderStatus Delivered = new DeliveredStatus();
    public static readonly OrderStatus Cancelled = new CancelledStatus();

    private OrderStatus(int id, string name) : base(id, name) { }

    public abstract bool CanTransitionTo(OrderStatus next);

    private sealed class DraftStatus : OrderStatus
    {
        public DraftStatus() : base(1, "Draft") { }

        public override bool CanTransitionTo(OrderStatus next) =>
            next == Confirmed || next == Cancelled;
    }

    private sealed class ConfirmedStatus : OrderStatus
    {
        public ConfirmedStatus() : base(2, "Confirmed") { }

        public override bool CanTransitionTo(OrderStatus next) =>
            next == Shipped || next == Cancelled;
    }

    // ... other statuses
}

The state transition rules are embedded in the enum itself. No service needed to check if an order can be cancelled.

The Enumeration<TEnum> base class handles equality and provides static lookups like FromValue and FromName, which is what the Infrastructure layer uses to persist the enum as a string.

State machine for OrderStatus showing the allowed transitions: Draft moves to Confirmed or Cancelled, Confirmed moves to Shipped or Cancelled, Shipped moves to Delivered, and Delivered and Cancelled are terminal states

Repository Interfaces

Repository interfaces can live in the Domain layer, right next to the aggregates they load and save. That's the convention I'm showing here. Their implementations always go in Infrastructure.

public interface ICustomerRepository
{
    Task<Customer?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
    Task<Customer?> GetByEmailAsync(Email email, CancellationToken cancellationToken = default);
    void Add(Customer customer);
    void Update(Customer customer);
}

This follows the Dependency Rule: the Domain layer defines what it needs, and outer layers provide it.

Note: some developers place repository interfaces in the Application layer instead. Both approaches are valid - the key is that implementations go in Infrastructure either way.

Domain Services

When business logic doesn't naturally belong to a single entity, use a Domain Service:

public sealed class PricingService
{
    public Money CalculateTotal(
        IReadOnlyCollection<OrderLineItem> items,
        DiscountCode? discountCode)
    {
        var subtotal = items.Aggregate(
            Money.Zero(Currency.Usd),
            (sum, item) => sum + item.TotalPrice);

        if (discountCode is not null)
        {
            subtotal = discountCode.Apply(subtotal);
        }

        return subtotal;
    }
}

Domain Services are stateless. They operate on entities and value objects passed to them.

Domain Errors

Define domain-specific errors in the Domain layer:

public static class DomainErrors
{
    public static class Email
    {
        public static readonly Error InvalidFormat = new(
            "Email.InvalidFormat",
            "The email address is not in a valid format.");
    }

    public static class Customer
    {
        public static readonly Error NotFound = new(
            "Customer.NotFound",
            "The customer was not found.");

        public static readonly Error EmailNotUnique = new(
            "Customer.EmailNotUnique",
            "The email address is already in use.");
    }

    public static class Order
    {
        public static readonly Error AlreadyCancelled = new(
            "Order.AlreadyCancelled",
            "The order has already been cancelled.");
    }
}

These errors are used with the Result pattern for explicit error handling without exceptions.

Folder Structure

Domain/
  Customers/
    Customer.cs
    CustomerCreatedDomainEvent.cs
    ICustomerRepository.cs
  Orders/
    Order.cs
    OrderLineItem.cs
    OrderStatus.cs
    OrderCompletedDomainEvent.cs
    IOrderRepository.cs
  Shared/
    Entity.cs
    AggregateRoot.cs
    ValueObject.cs
    IDomainEvent.cs
    Result.cs
    Error.cs
    DomainException.cs

Organize by aggregate or concept, not by pattern type. Don't create folders like Entities/, ValueObjects/, Events/.

What Does NOT Belong in the Domain Layer

  • DTOs - those belong in the Application layer
  • Database concerns - no DbContext, no [Table] attributes, no migration code
  • Logging - the domain doesn't know about ILogger
  • External service calls - no HTTP, no message queue access
  • Validation with FluentValidation - command/query validation belongs in Application

The Domain layer has zero external package references. It references only .NET base class libraries.

Enforcing the Rules

Use architecture tests to keep the Domain layer pure:

[Fact]
public void Domain_Should_Not_Have_Dependencies_On_Other_Layers()
{
    var result = Types
        .InAssembly(typeof(Customer).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny("Application", "Infrastructure", "Presentation")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

Summary

The Domain layer is the most stable and valuable part of your Clean Architecture solution. It contains the business rules that make your application unique - everything else is infrastructure.

Keep it pure, keep it focused, and the rest of your architecture will thank you.

Thanks for reading, and stay awesome!


Frequently Asked Questions

What belongs in the domain layer in Clean Architecture?

Entities, value objects, domain events, domain services, repository interfaces, and domain errors or exceptions. In short: the business rules and the types that express them, with no infrastructure concerns.

Should the domain layer have NuGet package references?

Ideally zero. The domain layer should reference only the .NET base class libraries. Framework attributes, EF Core, logging, and messaging packages all belong in outer layers.

What is the difference between an entity and a value object?

An entity is defined by its identity: two customers with the same name are still different customers. A value object is defined by its attributes: two Email objects with the same address are interchangeable. Value objects are immutable.

Do repository interfaces belong in the domain layer?

It is a valid and common choice, since repositories are conceptually part of an aggregate contract. Some teams put them in the application layer instead. Either way, the implementations always live in the infrastructure layer.

Where do domain event handlers live?

In the application layer. The domain layer defines and raises the events because they are domain facts, but reacting to them is use case orchestration, which is the application layer job.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.