← Back to Domain-Driven Design

Domain-Driven Design

Aggregates

Consistency boundaries inside a model — what must change together.

An aggregate is the part of a model that must be consistent at the end of one change. Its root is the entry point for that change. Load the root, ask it to perform behaviour, then save the boundary it protects.

What must stay true together

A report is Draft while it is being assembled. It can only become Published from a ready state. Status, PublishedAt, and the audit timestamp therefore move through one root method:

public void MarkPublished(DateTimeOffset at)
{
    if (Status is not (
        ReportStatus.DataReady
        or ReportStatus.PartiallyReady
        or ReportStatus.AiDrafted
        or ReportStatus.ReviewRequired))
        throw new InvalidOperationException($"Cannot publish in status {Status}.");

    PublishedAt = at;
    Status = ReportStatus.Published;
    Touch(at);
}

The caller cannot set those properties directly. If the transition is invalid, nothing changes. If it succeeds, the report cannot be Published without a publication time.

That is the boundary earning its name. A transaction is still needed when the root is persisted, but a database transaction alone does not discover or protect the rule. The behaviour on the root does.

The root controls the route in

An aggregate may contain one entity or several. Size is not the test. The test is whether an outside caller can bypass the root and leave part of the model contradicting another part.

For this report, publishing goes through MarkPublished. Code that updates Status in one repository call and PublishedAt in another has split one domain decision into two writes. Even if both writes usually share a transaction, the rule is now optional.

Large aggregates make too many changes wait on the same boundary. Tiny aggregates push rules into application services. The boundary should contain what must be immediately consistent, not everything that is related.

A cube row is not a boundary

Not every cluster that sits in a table is an aggregate. An hourly delivery row is a serving cube: a strategy, an hour, a play count. The source says it is not an aggregate. Replacing the hour is a measurement write. It is not a consistency boundary for a campaign.

Putting those hours inside Campaign would make every measurement contend with the buying model. Putting campaign budget on the hourly row would make a measurement responsible for a campaign rule. Related data can remain outside the aggregate and carry the campaign id.

An aggregate root has identity over time. That is Entities.