Skip to content

Aggregates

An aggregate is a cluster of domain types that change together and must stay consistent as a whole. Koine makes the aggregate boundary explicit: you name the cluster, name its root, and nest the entities and value objects it owns. The root becomes the single entry point — the only type that gets a repository and the only handle the outside world holds onto.

The aggregate pattern comes from Domain-Driven Design: by enforcing that all mutations go through the root, the model guarantees that invariants spanning the cluster are never violated by partial updates. The compiler enforces this at the modeling level — nested types have no independent identity outside the aggregate.

An aggregate is declared with the aggregate keyword, a cluster name, the root keyword, and a root-entity name. The optional versioned qualifier opts the aggregate into optimistic concurrency (§7.5):

aggregate_decl
: annotation* 'aggregate' Identifier 'root' Identifier 'versioned'? '{' aggregate_member* '}'
;
aggregate_member
: type_decl
| spec_decl
| repository_decl
;
repository_decl
: 'repository' '{' operations_clause? finder_decl* '}'
;
operations_clause
: 'operations' ':' Identifier ( ',' Identifier )*
;
finder_decl
: 'find' Identifier '(' param_list? ')' ':' type_ref
;

The first Identifier names the aggregate; the second (after root) names the root entity, which must be declared as a nested entity inside the body. Every aggregate_member is either a nested type declaration (value objects, quantities, entities, nested aggregates, enums, events, and integration events — the complete type_decl set), an aggregate-scoped specification, or a single repository block.

The expression grammar used in specs and invariants is specified in Expressions (§9). The annotation* prefix supports @since and @deprecated evolution annotations (see Versioning (§18)).

Here is the order aggregate from the demo, trimmed to its skeleton:

context Ordering {
enum OrderStatus { Draft, Submitted, Paid, Shipped, Cancelled }
value Money {
amount: Decimal
currency: Currency
invariant amount >= 0 "an amount cannot be negative"
}
aggregate Order root Order {
value OrderLine {
product: ProductId
quantity: Int
unitPrice: Money
invariant quantity >= 1 "an order line needs at least one unit"
}
entity Order identified by OrderId {
customer: CustomerId
lines: List<OrderLine>
status: OrderStatus = Draft
total: Money = lines.sum(l => l.unitPrice * l.quantity)
}
}
}

The root entity is identified the same way any entity (§6) is — with identified by <IdType> and an optional identity strategy. See Repositories & concurrency (§14) for the identity strategies.

The root is the consistency boundary. Nested value objects and entities have no independent identity outside the aggregate — they are reached through the root. That is why only the root gets a repository and why the root’s constructor enforces every nested invariant before the aggregate can exist.

The identifier after root must match the name of exactly one entity declared directly inside the aggregate body. The compiler raises a diagnostic if the named root entity is absent or if multiple entities share that name.

Declaring an aggregate emits a persistence-ignorant repository interface keyed on the root’s id. Non-root nested entities and standalone (context-level) entities get no repository — you can only load and save a whole aggregate, never one of its inner parts. To restrict the mutating set or add intention-revealing finders, use a repository { … } block; that and concurrency semantics are covered in Repositories & concurrency (§14).

A context that contains at least one aggregate also gets a generated IUnitOfWork exposing one repository property per aggregate plus SaveChangesAsync.

7.3.4 Namespacing: one context, one namespace

Section titled “7.3.4 Namespacing: one context, one namespace”

All types of a context — including aggregate-owned value objects, events, and nested entities — are emitted into the single <Context> namespace. The aggregate block is a modeling boundary, not a C# namespace boundary: Order, OrderLine, and OrderOpened all land in namespace Ordering.

The aggregate boundary is expressed purely by the root entity implementing IAggregateRoot. This keeps generated cross-references simple (no nested namespaces to qualify) and avoids a namespace/type-name clash when the aggregate and its root share a name (as in aggregate Order root Order).

The root entity implements the marker interface IAggregateRoot, and an I<Root>Repository contract is emitted for it:

namespace Ordering;
public sealed class Order : IAggregateRoot
{
public OrderId Id { get; }
public CustomerId Customer { get; }
public IReadOnlyList<OrderLine> Lines { get; }
public OrderStatus Status { get; private set; }
public Money Total => Lines.Select(l => l.UnitPrice * l.Quantity).Aggregate((a, b) => a + b);
// ...
}

IAggregateRoot is a pure marker emitted once into the self-contained Koine.Runtime namespace:

namespace Koine.Runtime;
/// <summary>Marks an entity as the consistency boundary (root) of an aggregate.</summary>
public interface IAggregateRoot { }

The default operation set emitted for any aggregate root is GetByIdAsync, AddAsync, UpdateAsync, and RemoveAsync:

namespace Ordering;
/// <summary>Persistence-ignorant repository contract for the Order aggregate root.</summary>
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct = default);
Task AddAsync(Order aggregate, CancellationToken ct = default);
Task UpdateAsync(Order aggregate, CancellationToken ct = default);
Task RemoveAsync(Order aggregate, CancellationToken ct = default);
}

A context that contains at least one aggregate gets a generated IUnitOfWork exposing one repository property per aggregate plus SaveChangesAsync. This interface is emitted into the context’s namespace alongside the aggregate types.

Add the versioned soft keyword after root <Entity> (before the opening brace) to opt the aggregate into optimistic concurrency:

aggregate Order root Order versioned {
entity Order identified by OrderId {
customer: CustomerId
}
}

The root gains a get-only Version token, assigned by the persistence layer:

public sealed class Order : IAggregateRoot
{
public OrderId Id { get; }
/// <summary>Optimistic-concurrency token, assigned by the persistence layer.</summary>
public int Version { get; init; }
// ...
}

A shared runtime exception is emitted once into Koine.Runtime, and the repository’s UpdateAsync / RemoveAsync operations document that they throw it on a stale write (AddAsync does not):

namespace Koine.Runtime;
/// <summary>Thrown when a versioned aggregate is saved against a stale expected version.</summary>
public sealed class ConcurrencyConflictException : Exception
{
public string TypeName { get; }
public int ExpectedVersion { get; }
public int ActualVersion { get; }
public ConcurrencyConflictException(string type, int expected, int actual) { /* ... */ }
}

A non-versioned aggregate emits no Version property and no ConcurrencyConflictException file — you pay only for what you opt into.

The complete model below declares a versioned Order aggregate with a tuned repository, two events, a nested OrderLine value object, and a root entity with a state machine, commands, and a factory. It is copy-pasteable and compiles with koine build:

context Ordering {
enum OrderStatus { Draft, Submitted, Paid, Shipped, Cancelled }
value Money {
amount: Decimal
invariant amount >= 0 "an amount cannot be negative"
}
aggregate Order root Order versioned {
repository {
operations: getById, add, update
find byCustomer(customer: CustomerId): List<Order>
find mostRecent(customer: CustomerId): Order
}
event OrderOpened {
orderId: OrderId
customer: CustomerId
lineCount: Int
}
value OrderLine {
product: ProductId
quantity: Int
unitPrice: Money
lineTotal: Money = unitPrice * quantity
invariant quantity >= 1 "an order line needs at least one unit"
}
entity Order identified by OrderId {
customer: CustomerId
lines: List<OrderLine>
status: OrderStatus = Draft
total: Money = lines.sum(l => l.lineTotal)
lineCount: Int = lines.count
invariant lines.all(l => l.quantity >= 1) "every line needs a positive quantity"
states status {
Draft -> Submitted, Cancelled
Submitted -> Paid, Cancelled
Paid -> Shipped, Cancelled
Shipped
Cancelled
}
command submit {
requires status == Draft "only a draft order can be submitted"
requires !lines.isEmpty "cannot submit an empty order"
status -> Submitted
emit OrderSubmitted(orderId: id, lineCount: lines.count)
}
create open(customer: CustomerId, lines: List<OrderLine>) {
requires !lines.isEmpty "cannot open an empty order"
emit OrderOpened(orderId: id, customer: customer, lineCount: lines.count)
}
}
event OrderSubmitted {
orderId: OrderId
lineCount: Int
}
}
}

This model emits the Order root (implementing IAggregateRoot, with a Version token), a tuned IOrderRepository with two finders, the nested OrderLine value object, both events, and an IUnitOfWork for the Ordering context.