Skip to content
Slicekit

Concepts

Domain-driven design

Aggregates, invariants and domain events: how the domain model is structured and why handlers stay thin.

View .md
On this page

The domain model is the part of Slicekit that knows the rules. It lives in Slicekit.Core/Domain/, depends on nothing but the language, and is where every invariant is enforced. Handlers stay thin precisely because the aggregates are not: a handler loads an aggregate, calls one method, and saves. The decision of whether the change is even allowed happens inside the model.

This is the concept page. For the end-to-end recipe (command, handler, endpoint), see adding a vertical slice.

Aggregates own their state

An aggregate is a cluster of objects treated as one unit, with a single root that guards the whole. In Slicekit, User is the aggregate root for permissions, consents, API keys and refresh tokens. You never load a RefreshToken on its own and mutate it: you load the User and ask it to change.

The shape is deliberate. State is exposed read-only, and every mutation goes through a method:

public class User : AggregateRoot<Guid>
{
    private User() { }

    private readonly List<UserPermission> _permissions = [];
    public IReadOnlyCollection<UserPermission> UserPermissions => _permissions;

    public string Email { get; private set; } = string.Empty;
    public bool IsAdmin { get; private set; }

    public void ChangeAdminStatus(Guid actorId, bool isAdmin)
    {
        if (IsAdmin == isAdmin) return;
        IsAdmin = isAdmin;
        Raise(new UserAdminStatusChangedEvent(actorId, Id, isAdmin));
    }
}

Three rules show up in every aggregate:

  • Properties use private set or init. Callers read the current Email; they cannot assign a new one. UpdateEmail does.
  • Collections are IReadOnlyCollection<T> over a private List<T>. The backing list is invisible outside the class, so nobody can add a permission behind the aggregate’s back.
  • Mutation lives in named methods. ChangeAdminStatus, AssignPermission, RecordLogin. The method name is the vocabulary of the domain, and it is the only door in.

The DomainEncapsulationTests architecture suite enforces this. A public setter or a publicly exposed mutable collection fails the build, so the convention cannot quietly rot.

The AggregateRoot base

Aggregates inherit AggregateRoot<TId> from Slicekit.Core/Domain/Primitives/. The base is small and does two things: it gives the root identity-based equality (two User instances are equal when their Id matches, regardless of loaded state), and it carries the list of domain events the aggregate has raised.

public abstract class AggregateRoot : IAggregateRoot
{
    private readonly List<IDomainEvent> _events = [];

    public IReadOnlyList<IDomainEvent> Events => _events;

    protected void Raise(IDomainEvent @event) => _events.Add(@event);

    public void ClearEvents() => _events.Clear();
}

Raise is protected: only the aggregate itself can record an event, and it does so synchronously with no I/O. The events sit on the instance until persistence flushes them. More on that below.

Invariants are enforced in the method

An invariant is a truth that must always hold. The job of an aggregate method is to refuse any call that would break one, before any state changes.

public bool AssignPermission(Guid actorId, Permission permission)
{
    var exclusion = _exclusions.FirstOrDefault(x => x.PermissionId == permission.Id);
    if (exclusion is not null) _exclusions.Remove(exclusion);

    if (HasPermission(permission.Id)) return exclusion is not null;
    _permissions.Add(new UserPermission { UserId = Id, PermissionId = permission.Id });
    Raise(new PermissionAssignedEvent(actorId, Id, permission.Name));
    return true;
}

Note what the handler never has to do: check for a duplicate permission, reconcile an exclusion, or decide whether an event is warranted. The aggregate guarantees that a permission is assigned at most once, and that an event is raised only when something actually changed. Because the check and the mutation are in the same method, you cannot end up with a User in an illegal state.

Child entities mutate through the root

UserPermission, UserConsent, RefreshToken and ApiKey belong to the User aggregate. Their constructors are non-public, so nothing outside the domain can new one up:

public sealed class UserConsent
{
    internal UserConsent() { }

    public required Guid UserId { get; init; }
    public required ConsentType ConsentType { get; init; }
    public required string Version { get; init; } = string.Empty;
    public DateTimeOffset GrantedAtUtc { get; init; } = DateTimeOffset.UtcNow;

    internal static UserConsent Grant(Guid userId, ConsentType type, string version) =>
        new() { UserId = userId, ConsentType = type, Version = version };
}

The root constructs them through its own methods (GrantConsent calls UserConsent.Grant). Keeping construction inside the boundary keeps the invariants local: every rule about consents lives next to the consents, not scattered across handlers.

Value objects

Some concepts are defined entirely by their values, not by an identity. Two money amounts of 10 EUR are the same money; two users with the same name are still two users. The base for the former is ValueObject, which derives equality from the values you declare:

public abstract class ValueObject : IEquatable<ValueObject>
{
    protected abstract IEnumerable<object?> GetEqualityComponents();

    public bool Equals(ValueObject? other) =>
        other is not null
        && GetType() == other.GetType()
        && GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
}

A subclass yields its parts from GetEqualityComponents, and structural equality follows for free. Use a value object when a concept has rules but no lifecycle of its own (an email address, a date range, a slug). Use an entity when it has identity and changes over time.

Raised events connect the model to the rest

When an aggregate decides something happened, it records the fact by raising a domain event. The two verbs are not interchangeable and the distinction is load-bearing:

  • Aggregates raise. Raise(new PermissionAssignedEvent(...)) appends a fact to the aggregate’s in-memory Events list. No message is sent; no side effect runs.
  • Dispatchers publish. After SaveChangesAsync commits, the infrastructure scrapes the raised events off every tracked aggregate and publishes them to handlers and to the transactional outbox.

If you ever reach for bus.Publish(...) inside an aggregate method, stop. The aggregate raises; the dispatcher publishes after the change is durable. This keeps the domain free of infrastructure and guarantees an event is never sent for a change that was rolled back.

// inside the aggregate: a fact is recorded
Raise(new PermissionRevokedEvent(actorId, Id, permission.Name));

// later, after the transaction commits, the dispatcher publishes it

The full flow (outbox, RabbitMQ, idempotent consumers) lives in CQRS and domain events, and the catalog of event types and what subscribes to them is in domain events.

What the domain may not touch

The domain has no dependency on EF Core, ASP.NET, Wolverine or any application code. Two consequences worth internalizing:

  • No persistence is a repository. Slicekit has no IRepository<T>. The handler uses AppDbContext directly (db.Users, db.SaveChangesAsync), and the change tracker is the unit of work. The aggregate knows nothing about how it is stored.
  • No EF attributes on domain types. [Table], [Column], [Required] do not belong on User. Mapping is configured separately with IEntityTypeConfiguration<T>, so the model stays a pure expression of the rules.

When the domain genuinely needs something from outside (a breached-password check, the current time), it depends on an interface under Domain/Services/ and the implementation lives with its adapter. The LayerDependencyTests suite blocks any import that would pull infrastructure into the model, so this stays true.