# Domain-driven design

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

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](/docs/vertical-slices).

## 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:

```csharp
public class User : AggregateRoot
{
    private User() { }

    private readonly List _permissions = [];
    public IReadOnlyCollection 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` over a private `List`.** 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` 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.

```csharp
public abstract class AggregateRoot : IAggregateRoot
{
    private readonly List _events = [];

    public IReadOnlyList 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.

```csharp
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:

```csharp
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:

```csharp
public abstract class ValueObject : IEquatable
{
    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.

```csharp
// 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](/docs/cqrs-and-events), and the catalog of event types and what subscribes
to them is in [domain events](/docs/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`. 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`, 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.
