A logbook for curious waters
← Software Engineering Field Guide

Aggregate and Invariant Boundary

Before you begin: Objects, collections, validation, and database transactions.

An aggregate groups domain state that must stay consistent together. One object, the aggregate root, is the entry point for changes to that group. An invariant is a rule that must hold whenever an operation successfully completes. 1

An aggregate is therefore not just a list, a database join, or every object reachable from a customer. Its boundary is chosen around rules that need coordinated enforcement.

A class must not enroll more students than it has seats

A class roster has a capacity and a set of enrolled student identifiers. Those facts belong together because a new enrollment is valid only when the resulting count stays within capacity.

The root should check capacity and add the student in one operation. Returning its mutable collection for callers to edit directly would bypass that rule.

public sealed class ClassRoster
{
    private readonly int capacity;
    private readonly HashSet<string> students = new HashSet<string>();

    public ClassRoster(int capacity)
    {
        if (capacity < 1) throw new ArgumentOutOfRangeException(nameof(capacity));
        this.capacity = capacity;
    }

    public string[] Students { get { return students.ToArray(); } }

    public bool Enroll(string studentId)
    {
        if (string.IsNullOrWhiteSpace(studentId)) throw new ArgumentException("Id required.");
        if (students.Contains(studentId)) return false;
        if (students.Count >= capacity) throw new InvalidOperationException("Class is full.");
        students.Add(studentId);
        return true;
    }
}
var roster = new ClassRoster(1);
Console.WriteLine(roster.Enroll("Mira")); // True
Console.WriteLine(roster.Enroll("Mira")); // False: already enrolled

An already enrolled student does not consume another seat. Another student would be rejected without changing the collection. The returned array is a copy; its contents cannot be used to mutate the roster.

class ClassRoster:
    def __init__(self, capacity):
        if type(capacity) is not int or capacity < 1:
            raise ValueError("Capacity must be positive.")
        self._capacity = capacity
        self._students = set()

    @property
    def students(self):
        return tuple(sorted(self._students))

    def enroll(self, student_id):
        if not isinstance(student_id, str) or not student_id.strip():
            raise ValueError("Id required.")
        if student_id in self._students:
            return False
        if len(self._students) >= self._capacity:
            raise RuntimeError("Class is full.")
        self._students.add(student_id)
        return True
roster = ClassRoster(1)
print(roster.enroll("Mira"))  # True
print(roster.enroll("Mira"))  # False: already enrolled

The public methods funnel enrollment through the capacity rule. The tuple provides a read snapshot; internal attributes remain private by convention. Sorting makes the Python snapshot deterministic, while the C# set snapshot does not promise an order.

Private fields do not prevent two servers from overbooking

The code assumes sequential calls to one in-memory root. Imagine two servers load a roster with one remaining seat. Each independently approves a different student. If both writes succeed without conflict detection, the persisted class may exceed capacity.

The storage operation must enforce the same consistency boundary. Optimistic version checks, an appropriate transaction, or database constraints can prevent conflicting updates from both committing. On a conflict, reload and reevaluate the rule rather than blindly overwriting another enrollment.

An aggregate tells you what must be consistent; it does not supply that database behavior merely by being a class. Tests should cover both local invariants and the real persistence conflict path.

Keep the boundary small enough to use

The roster can refer to students by identifier without loading each student’s entire profile. A profile name change does not need to lock every class the student attends. A single giant aggregate would turn independent changes into contention.

Conversely, splitting capacity and enrollment into independently committed records would make this rule harder to enforce. Start with the required invariant, then decide which state must participate in its change.

Rules spanning several aggregates may need a coordinating process with explicit intermediate states or compensation. Do not promise immediate consistency across unrelated services simply because an application method calls them one after another.

Sources

  1. Martin Fowler — DDD Aggregate.
  2. Microsoft — Domain model design, on aggregate roots and invariants.