Reading Aggregates
GET aggregates to fetch current state. j17 replays events and applies handlers on demand. Handlers are declarative rules defined in your spec -- for example, a was_created event might use {"set": {"target": "", "value": "$.data"}} to set initial state, and a had_name_updated event might merge a new name into it.
Basic query
GET /{aggregate_type}/{aggregate_id}
curl https://myapp.j17.dev/order/ord_123 \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"data": {
"items": [
{ "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
],
"status": "paid",
"customer_id": "550e8400-e29b-41d4-a716-446655440003",
"total": 79.97,
"placed_at": 1705312800,
"paid_at": 1705312900
},
"metadata": {
"length": 5,
"created_at": 1705312800,
"updated_at": 1705312900
}
}
| Field | Description |
|---|---|
data |
Current aggregate state after applying all events through handlers |
metadata.length |
Total number of events in this aggregate |
metadata.created_at |
Timestamp of the first event (Unix seconds) |
metadata.updated_at |
Timestamp of the most recent event (Unix seconds) |
Status codes
| Status | When |
|---|---|
| 200 OK | Aggregate exists (metadata.length > 0); or the id is a singleton |
| 404 Not Found | Aggregate id is well-formed but no events have ever been written to it |
| 422 Unprocessable Entity | Aggregate id is malformed — see Identifiers |
Singletons always return 200, even before the first write — they're conceptually "one well-known instance, possibly empty." Branch on metadata.length > 0 rather than status code to detect missingness on a singleton.
Stream length (for OCC)
Get just the stream length without computing the full aggregate. This is an O(1) Redis operation, useful for optimistic concurrency control.
GET /{aggregate_type}/{aggregate_id}/length
curl https://myapp.j17.dev/order/ord_123/length \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"length": 5
}
Use this length as previous_length in your next write:
const { length } = await fetch('/order/ord_123/length').then(r => r.json());
await writeEvent('/order/ord_123/was_updated', {
data: { ... },
metadata: { actor: { ... }, previous_length: length }
});
If another write happened between your read and write, you'll get a 409 Conflict.
Query parameters
Synchronous reads
By default, j17 may serve cached aggregate state. To bypass the cache and compute fresh from events:
curl "https://myapp.j17.dev/order/ord_123?synchronous=true"
Use when you need guaranteed-fresh state, such as immediately after a write.
Get events
Get the raw event stream for an aggregate:
GET /{aggregate_type}/{aggregate_id}/events
curl "https://myapp.j17.dev/order/ord_123/events" \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"events": [
{
"stream_id": "1234567890123-0",
"key": "order:ord_123",
"type": "was_placed",
"data": { ... },
"metadata": {
"actor": { "type": "user", "id": "..." },
"timestamp": 1705312800
}
},
{
"stream_id": "1234567890124-0",
"key": "order:ord_123",
"type": "had_item_added",
"data": { ... },
"metadata": { ... }
}
]
}
Query parameters:
| Parameter | Type | Description |
|---|---|---|
start |
string | Return events after this stream ID (exclusive) |
count |
integer | Max events to return (default 100) |
Cursor semantics:
- Events are returned in stream order (monotonically increasing
stream_id) startis exclusive — an event with astream_idequal tostartis not returned- To page through, pass the last event's
stream_idback asstarton the next request - When fewer than
countevents are returned, you've reached the end of the stream stream_idformat is Redis-style"{ms-timestamp}-{seq}"(e.g."1705312800123-0")
# First page
curl "https://myapp.j17.dev/order/ord_123/events?count=50"
# Next page — pass the last stream_id from the previous response
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"
List aggregates
GET /{aggregate_type}
curl https://myapp.j17.dev/order \
-H "Authorization: Bearer $API_KEY"
Returns known aggregate IDs for a type with cursor-based pagination.
| Parameter | Type | Description |
|---|---|---|
resolve |
flag | Return full aggregate data instead of IDs (?resolve or ?resolve=true) |
synchronous |
boolean | Skip cache when resolving, read from event stream (?synchronous=true) |
limit |
integer | Page size (default 50, max 200) |
cursor |
string | Return IDs after this value (exclusive, lexicographic) |
Cursor semantics:
- IDs are returned in lexicographic (byte-wise) order
- The cursor is exclusive — an ID equal to the cursor value is not returned
- To page through, pass the last ID from the previous response back as
cursor - When the response omits the
cursorfield, you've reached the end - The cursor is stable across requests as long as no aggregate IDs are deleted; new aggregates with IDs greater than your current cursor will appear on subsequent pages
- All four identifier kinds — UUIDs, humane codes, singletons (including
global), tagged UUIDs — appear in the list once any event has been written to them. They sort lexicographically by raw string, so singleton names (lowercase letters) appear before UUIDs (hex digits) in the natural order.
IDs only (default):
{"ok": true, "data": ["order-123", "order-456", "order-789"]}
With ?resolve — full aggregate data:
{
"ok": true,
"data": [
{"id": "order-123", "data": {"status": "pending", ...}, "metadata": {...}},
{"id": "order-456", "data": {"status": "shipped", ...}, "metadata": {...}}
]
}
Resolved aggregates use the cache when available, falling back to sync reads from the event stream. Use ?synchronous=true to always read from the stream.
Pagination:
# First page
curl "https://myapp.j17.dev/order?limit=25"
# Response includes cursor: {"ok": true, "data": [...], "cursor": "order-xyz"}
# Next page
curl "https://myapp.j17.dev/order?limit=25&cursor=order-xyz"
Projections
Request a named projection:
curl "https://myapp.j17.dev/_projections/user_dashboard/cust_456" \
-H "Authorization: Bearer $API_KEY"
Projections are pre-computed views maintained by the platform. Use ?synchronous=true for a fresh computation instead of the cached value.
Export as CSV: GET /_projections/:name/export.csv
Query with SQL: POST /_projections/query with {"sql": "SELECT ...", "params": [...]}
See the projections guide for full details including limits and restrictions.
Singleton aggregates
Use the literal global as the aggregate ID for one-per-type aggregates:
curl https://myapp.j17.dev/config/global \
-H "Authorization: Bearer $API_KEY"
If you've defined custom singletons in your spec ("singletons": ["all"]), use them the same way:
curl https://myapp.j17.dev/company_audit/all \
-H "Authorization: Bearer $API_KEY"
Reads of singletons return 200 OK even before the first write — branch on metadata.length > 0 if you need missingness detection.
See Identifiers § Singletons for the full reference and Aggregates: Singleton aggregates for the conceptual overview.
Response codes
200 OK
Aggregate exists. Returns state.
404 Not Found
Aggregate doesn't exist (no events yet) or aggregate type not in spec.
{
"ok": false,
"error": "Aggregate not found"
}
An aggregate with no events returns 404, not empty state.
401 Unauthorized
Invalid or missing API key.
403 Forbidden
Valid key, but wrong environment (staging key on prod) or insufficient scope.
Performance
Aggregate computation is fast:
- Small aggregates (< 100 events): < 1ms
- Medium aggregates (< 1,000 events): < 5ms
- Large aggregates (< 10,000 events): < 50ms
If your aggregates grow larger, use checkpoints to snapshot state periodically. Root checkpoints capture all aggregates of a type at once.
Optimistic reads
For optimistic UI patterns:
// 1. Get stream length (O(1))
const { length } = await fetch('/order/ord_123/length').then(r => r.json());
// 2. Fetch current state
const { data } = await fetch('/order/ord_123').then(r => r.json());
// 3. Render UI
renderOrder(data);
// 4. User makes change -- write with OCC
try {
await writeEvent('/order/ord_123/was_shipped', {
data: { ... },
metadata: { actor: { ... }, previous_length: length }
});
} catch (err) {
// 409 Conflict -- refetch and retry
const fresh = await fetch('/order/ord_123').then(r => r.json());
renderOrder(fresh.data);
}
Comparing to traditional databases
| Operation | SQL | j17 |
|---|---|---|
| Read by ID | SELECT * FROM orders WHERE id = ? |
GET /order/ord_123 |
| Read length | SELECT COUNT(*) FROM events WHERE key = ? |
GET /order/ord_123/length |
| Read full history | Multiple queries/joins | GET /order/ord_123/events |
| List/filter | SELECT * FROM orders WHERE status = 'paid' |
Not directly (use projections) |
The trade-off: you lose ad-hoc queries, you gain immutable history.
Best practices
Store aggregate IDs. When you create an order, save the ID in your database. You'll need it to query later.
Use projections for lists. Don't try to "list all orders." Create a projection that maintains the list.
Use ?synchronous=true sparingly. Default reads may use cached state, which is faster. Only force synchronous when you need guaranteed freshness.
Handle 404 gracefully. A 404 just means no events yet. It's not an error condition.
See also
- Writing events - POST to modify state
- Batch operations - Atomic multi-event writes
- Admin API - Checkpoints, backups, data export