Projections
Projections are read-only views that combine data from multiple aggregates into a single response. Your frontend needs an order with customer name, product details, and shipping info -- that's four aggregates. Without projections, you either make 4 API calls or build a custom endpoint. Projections solve this: define the shape you need in JSON, and the platform composes it for you.
When to use projections
Good for: - Dashboards combining multiple data sources - API responses needing denormalized data - Complex queries that would need multiple round-trips
Not for: - Simple single-aggregate reads (use GET directly) - Write operations (projections are read-only)
Defining projections
Projections are deployed via the admin API. Define them in JSON:
{
"name": "order_with_details",
"for_each": "order",
"sources": {
"order": "$key",
"customer": {"type": "customer", "id": "$.order.customer_id"},
"products": {"type": "product", "id": "$.order.line_items[*].product_id", "mode": "array"}
},
"include": {
"pricing": "pricing_rules:global"
},
"select": {
"order_id": "$.order.id",
"total": "$.order.total",
"customer_name": "$.customer.name",
"tax_rate": "$.pricing.default_tax_rate",
"items": {
"$map": "$.order.line_items",
"as": "$item",
"to": {
"product_id": "$item.product_id",
"product_name": {
"$lookup": "$.products",
"where": {"id": "$item.product_id"},
"select": "$.name"
}
}
}
}
}
Fields
| Field | Required | Purpose |
|---|---|---|
name |
Yes | Projection identifier (lowercase, alphanumeric with underscores) |
for_each |
Yes | Trigger aggregate type -- one projection instance per aggregate of this type |
sources |
Yes | Aggregates derived from the trigger's state tree |
include |
No | Global singletons via type:global syntax |
select |
Yes | Output shape with JSONPath expressions |
Sources
Sources define which aggregates to fetch. The first source can use "$key" shorthand to reference the trigger aggregate:
"sources": {
"order": "$key",
"customer": {"type": "customer", "id": "$.order.customer_id"}
}
Source options
| Option | Default | Description |
|---|---|---|
type |
Required | Aggregate type to fetch |
id |
Required | ID expression ($key, literal, or JSONPath) |
mode |
"single" |
"single" or "array" for fetching multiple |
Array mode
Use mode: "array" when the ID path returns multiple values:
"products": {
"type": "product",
"id": "$.order.line_items[*].product_id",
"mode": "array"
}
This fetches all products referenced by the order's line items.
Include
The include field is for global singleton aggregates that are not in the trigger's state tree:
"include": {
"config": "settings:global",
"pricing": "pricing_rules:global"
}
include is restricted to :global IDs to guide users toward proper patterns. For non-global aggregates, use sources with appropriate ID resolution.
Select expressions
The select field defines the output shape using JSONPath expressions.
Basic paths
Reference any source or include binding:
"select": {
"customer_name": "$.customer.name",
"order_total": "$.order.total"
}
$map transform
Map over arrays to transform each element:
"items": {
"$map": "$.order.line_items",
"as": "$item",
"to": {
"name": "$item.name",
"qty": "$item.quantity"
}
}
$lookup transform
Find and extract from arrays:
"product_name": {
"$lookup": "$.products",
"where": {"id": "$item.product_id"},
"select": "$.name"
}
API endpoints
Admin (JWT authentication)
POST /_admin/projections # Deploy definition
GET /_admin/projections # List all
GET /_admin/projections/:name # Get definition
DELETE /_admin/projections/:name # Remove
Data (API key authentication)
GET /_projections/:name/:id # Read cached
GET /_projections/:name/:id?synchronous # Compute fresh
Use ?synchronous when you need guaranteed fresh data. Otherwise, projections automatically refresh when any source aggregate changes.
Example: User dashboard
{
"name": "user_dashboard",
"for_each": "user",
"sources": {
"user": "$key",
"org": {"type": "organization", "id": "$.user.org_id"},
"recent_orders": {"type": "order", "id": "$.user.recent_order_ids", "mode": "array"}
},
"include": {
"features": "feature_flags:global"
},
"select": {
"user_name": "$.user.name",
"org_name": "$.org.name",
"orders": "$.recent_orders",
"dark_mode_enabled": "$.features.dark_mode"
}
}
Request:
curl https://myapp.j17.dev/_projections/user_dashboard/abc123 \
-H "Authorization: Bearer $API_KEY"
Response:
{
"ok": true,
"data": {
"user_name": "Alice",
"org_name": "Acme Corp",
"orders": [],
"dark_mode_enabled": true
},
"cached_at": 1706745600
}
Deployment example
# Deploy a projection
curl -X POST https://myapp.j17.dev/_admin/projections \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/json" \
-d '{
"projection": {
"name": "order_summary",
"for_each": "order",
"sources": {
"order": "$key",
"customer": {"type": "customer", "id": "$.order.customer_id"}
},
"select": {
"id": "$.order.id",
"total": "$.order.total",
"customer_name": "$.customer.name"
}
}
}'
# Read a projection (API key auth)
curl https://myapp.j17.dev/_projections/order_summary/ord_12345 \
-H "Authorization: Bearer $API_KEY"
Performance
Projections automatically cache and refresh when source data changes. No manual invalidation required.
Guidelines:
- Keep to 3-4 sources max
- Use array mode sparingly (each ID is a separate fetch)
- For high-traffic projections, use the cached endpoint (default) rather than ?synchronous
Limitations
- No joins across instances
- No aggregation across all aggregates (use analytics export)
- Max 10 sources per projection
includeis restricted to:globalIDs
Compared to read models
Traditional event sourcing uses "read models" -- separate databases updated by event handlers. Projections are simpler: - No separate data store - No eventual consistency lag (computed from live data) - Computed on demand with automatic caching
But projections are not a substitute for heavy analytics. If you need complex cross-aggregate queries, export to a data warehouse.
See also
- Spec reference - Projection definition syntax
- Caching guide - How caching interacts with projections