Concepts
Domain-driven design
Aggregates, invariants and domain events: how the domain model is structured and why handlers stay thin.
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 setorinit. Callers read the currentEmail; they cannot assign a new one.UpdateEmaildoes. - Collections are
IReadOnlyCollection<T>over a privateList<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-memoryEventslist. No message is sent; no side effect runs. - Dispatchers publish. After
SaveChangesAsynccommits, 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 usesAppDbContextdirectly (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 onUser. Mapping is configured separately withIEntityTypeConfiguration<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.