Skip to content

Aggregates

An aggregate is a stream of related events sharing the same aggregate ID. It’s the consistency boundary in your system — events within an aggregate are ordered and versioned, and optimistic concurrency operates at the aggregate level.

Every event is appended to an aggregate identified by a string ID. The ID is the primary key — it must be unique within your tenant.

// These are two different aggregates
hapnd.Aggregate("order_001")
hapnd.Aggregate("order_002")

Hapnd infers the aggregate type from the ID using the {type}_{id} or {type}-{id} convention. The type is everything before the first underscore or hyphen.

Aggregate IDInferred Type
order_123order
cart-abccart
user_profile_456user
inventory-item-789inventory

The aggregate type determines which reducer (if any) runs when events are appended. When you bind a reducer to aggregate type order, it runs for every event appended to any aggregate whose ID starts with order_ or order-.

When your IDs don’t follow the convention, specify the type explicitly:

await hapnd.Aggregate("ORD-2024-00123")
.WithAggregateType("order")
.Append(new OrderPlaced { CustomerId = "cust_456" });
await hapnd
.aggregate("ORD-2024-00123")
.withAggregateType("order")
.append("OrderPlaced", { customerId: "cust_456" });

The TypeScript SDK exports the parser for use in your own code:

import { parseAggregateType } from "@hapnd/client";
const type = parseAggregateType("order_123"); // "order"
const type2 = parseAggregateType("cart-abc"); // "cart"

If a reducer is bound to the aggregate type, you can query the current computed state directly. The state is stored as a snapshot in the Durable Object alongside the event stream — the reducer doesn’t need to replay all events from the beginning.

var aggregate = await hapnd.Aggregate("order_123").GetState<OrderState>();
if (aggregate is not null)
{
Console.WriteLine($"Order at version {aggregate.Version}");
Console.WriteLine($"Total: {aggregate.State.Total}");
Console.WriteLine($"Items: {aggregate.State.ItemCount}");
Console.WriteLine($"Last modified: {aggregate.LastModified}");
}

GetState returns null if the aggregate doesn’t exist or no reducer is bound.

interface OrderState {
total: number;
items: string[];
itemCount: number;
}
const aggregate = await hapnd.aggregate("order_123").getState<OrderState>();
if (aggregate) {
console.log(`Order at version ${aggregate.version}`);
console.log(`Total: ${aggregate.state.total}`);
console.log(`Items: ${aggregate.state.itemCount}`);
console.log(`Last modified: ${aggregate.lastModified}`);
}

Every event appended to an aggregate gets a sequential version number starting at 1. Versions are per-aggregate — two different aggregates each have their own independent version sequence.

The version is returned in append results and state queries. Use it with ExpectVersion for optimistic concurrency.

Each aggregate is stored in its own Durable Object with a co-located SQLite database. This gives you:

  • Strong consistency — reads and writes to an aggregate are serialized
  • Co-located storage — events and snapshots live next to the compute, minimising latency
  • Isolation — one aggregate’s data cannot interfere with another’s

Aggregates are namespaced by tenant: {tenantId}:{aggregateId}. Cross-tenant access is architecturally impossible.