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
}
}
| Field | Description |
|---|---|
data |
Current aggregate state after applying all events through handlers |
metadata.length |
Total number of events in this aggregate |
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 | Start from this stream ID (exclusive) |
count |
integer | Max events to return (default 100) |
List aggregate IDs
GET /{aggregate_type}
curl https://myapp.j17.dev/order \
-H "Authorization: Bearer $API_KEY"
Returns known aggregate IDs for a type.
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.
Singleton aggregates
Use the literal global as the aggregate ID:
curl https://myapp.j17.dev/config/global \
-H "Authorization: Bearer $API_KEY"
Returns the singleton config aggregate. Useful for feature flags and app-wide settings.
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