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

Can't find what you need? support@j17.app