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
  • include is restricted to :global IDs

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

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