Aggregates
An aggregate is the current state derived from replaying all events for a given type and ID.
How aggregates work
When you query an aggregate, j17 replays all events and applies each handler in order:
Event 1: was_created { name: "Alice" } -> State: { name: "Alice", created_at: 1705312800 }
Event 2: had_email_updated { email: "..." } -> State: { name: "Alice", email: "...", created_at: ... }
Event 3: had_name_updated { name: "Alicia" } -> State: { name: "Alicia", email: "...", created_at: ... }
Each event type has a handler defined in your spec that determines how it transforms state. For example:
{
"was_created": {
"handler": [
{ "set": { "target": "", "value": "$.data" } },
{ "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
]
},
"had_name_updated": {
"handler": [
{ "merge": { "target": "", "value": "$.data" } }
]
}
}
Querying aggregates
curl https://myapp.j17.dev/user/550e8400-e29b-41d4-a716-446655440000 \
-H "Authorization: Bearer $J17_API_KEY"
Response:
{
"ok": true,
"data": {
"name": "Alicia",
"email": "alice@example.com",
"created_at": 1705312800
},
"metadata": {
"length": 3,
"created_at": 1705312800,
"updated_at": 1705399200
}
}
metadata.length is the number of events applied to this aggregate. Pass it as previous_length when writing to ensure no concurrent changes occurred between your read and write. created_at and updated_at are Unix timestamps from the first and most recent events.
Aggregate IDs
The aggregate ID is the second segment of the URL path when writing or reading events:
POST /{aggregate_type}/{aggregate_id}/{event_type}
GET /{aggregate_type}/{aggregate_id}
You supply the ID -- j17 never generates one for you. The ID determines which event stream the event is appended to.
Accepted formats
| Format | Example | Use case |
|---|---|---|
| UUID v4 | 550e8400-e29b-41d4-a716-446655440000 |
Default for all entities |
| UUID v5 | a4339497-daa0-5c39-a0d3-8e894750d2b0 |
Deterministic IDs derived from external data |
| Tagged UUID | uuid:2026 |
Time-partitioned streams (fiscal years, quarters) |
| Humane code | ABC123XYZ |
Human-readable 9-character codes (promos, bookings) |
global |
global |
Built-in singleton (no spec changes needed) |
| Custom singleton | all |
Named singletons (requires spec config) |
Use UUIDs by default. The other formats exist for specific use cases described below.
UUIDs (v4 and v5)
Most aggregates should use UUIDs. v4 (random) is the most common. v5 (deterministic, namespace-based) is useful when you need to derive a stable ID from external data -- for example, generating a consistent aggregate ID from an external user's email address.
Humane codes
9-character identifiers using Crockford base32 (e.g. ABC123XYZ). Useful when humans need to type or read aggregate IDs, such as booking codes or promo codes. j17 normalizes ambiguous characters at the edge: I becomes 1, L becomes 1, O becomes 0, U becomes V, and lowercase is uppercased.
Tagged UUIDs
A tagged UUID is a UUID followed by a colon and a short tag (1-10 lowercase alphanumeric characters): {uuid}:{tag}. This creates a separate aggregate stream for each tag while keeping the same entity identity.
The primary use case is time-partitioned aggregates -- when you need to close out one period and start fresh without losing history:
# Write to the current fiscal year's ledger
curl -X POST https://myapp.j17.dev/ledger/a433...d2b0:2025/entry_posted \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": {"amount": 500, "account": "revenue"}, "metadata": {"actor": {"type": "system", "id": "..."}}}'
# Close the year, start a new stream
curl -X POST https://myapp.j17.dev/ledger/a433...d2b0:2026/was_opened \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": {"carried_forward": 12500}, "metadata": {"actor": {"type": "system", "id": "..."}}}'
Each tagged variant is a fully independent aggregate with its own event stream, length, and state. The old period's aggregate remains readable and immutable while the new one starts from zero.
Common tag patterns:
| Tag | Example | Use case |
|---|---|---|
2026 |
uuid:2026 |
Fiscal year partitioning |
202603 |
uuid:202603 |
Monthly partitioning |
q1 |
uuid:q1 |
Quarterly periods |
v2 |
uuid:v2 |
Schema versioning / migrations |
Tags must be 1-10 characters, lowercase letters and digits only. The UUID portion must be a valid v4 or v5 UUID.
Singleton aggregates
A singleton is an aggregate type where you only need one instance -- app-wide config, a global counter, a shared audit log. Instead of generating a UUID, you use a fixed, well-known ID.
The global keyword
Every aggregate type automatically supports the keyword global as an ID, with no spec changes required:
# Write to a singleton config aggregate
curl -X POST https://myapp.j17.dev/config/global/was_updated \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": {"dark_mode": true}, "metadata": {"actor": {"type": "admin", "id": "..."}}}'
# Read it back
curl https://myapp.j17.dev/config/global \
-H "Authorization: Bearer $API_KEY"
This is the simplest way to create a singleton. Use it for settings, feature flags, rate limit config, or any "one per type" aggregate.
Custom singletons
If you need more than one named singleton per type, or want a more descriptive name than global, add custom singletons to your spec:
{
"singletons": ["all", "daily_summary"],
"aggregate_types": {
"company_audit": {
"events": {
"RecordCreated": {
"schema": { "type": "object" },
"handler": [
{ "increment": { "target": "total_events" } },
{ "set": { "target": "last_activity", "value": "$.metadata.timestamp" } }
]
}
}
}
}
}
Then use the singleton name as the aggregate ID:
curl -X POST https://myapp.j17.dev/company_audit/all/RecordCreated \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-d '{"data": {"action": "user_login"}, "metadata": {"actor": {"type": "system", "id": "..."}}}'
Custom singletons are defined at the top level of the spec (not per aggregate type) and are available to all aggregate types in the instance. Use them sparingly -- global handles most singleton cases.
Don't fake singletons with UUIDs
If you need a singleton, use global or a custom singleton name. Don't generate a fixed UUID (like 00000000-0000-5000-8000-000000000000) and use it as a pseudo-singleton -- it works, but it's fighting the system. Singletons are a first-class concept with a cleaner API and clearer intent.
Aggregate sizing
Each aggregate is replayed from its event stream on read. This is fast -- under 1ms for small aggregates and under 50ms for 10,000 events -- but aggregates are designed to represent individual entities, not unbounded collections.
If an aggregate grows unboundedly, you have several options:
- Tagged UUIDs -- partition by time period. A ledger aggregate that accumulates entries all year can be split into
uuid:2025,uuid:2026, etc. Each period gets its own stream that stops growing when the period closes. - Projections -- maintain a queryable view across aggregates. An audit log with one event per action can sometimes be better modeled as many small aggregates (one per audited entity) with a projection that provides the cross-cutting query view.
Use whatever is most natural to your domain.