The Spec

The spec is a JSON document that defines your entire data model: aggregate types, events, handlers, validation schemas, and platform features. It's declarative, version-controlled, and the single source of truth for your backend.

Top-level structure

{
  "aggregate_types": {},
  "agent_types": [],
  "target_types": [],
  "modules": {},
  "geo_types": {},
  "projections": {},
  "singletons": []
}

Only aggregate_types and agent_types are required. Everything else is optional.

aggregate_types

The core of your spec. Each aggregate type defines what events can happen to it, a JSON Schema for each event's data, and tick handlers that transform state.

{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": {
            "type": "object",
            "properties": {
              "items": { "type": "array" },
              "total": { "type": "number" }
            },
            "required": ["items", "total"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } }
          ]
        },
        "had_item_added": {
          "schema": {
            "type": "object",
            "properties": {
              "item": { "type": "object" }
            },
            "required": ["item"]
          },
          "handler": [
            { "append": { "target": "items", "value": "$.data.item" } }
          ]
        }
      }
    }
  }
}

Event definitions

Each event needs:

Field Type Required Description
schema JSON Schema Yes* Validates event data
handler Tick array Yes Transforms aggregate state
implications Array No Reactive events to emit

*Reserved event types (see below) must not define a schema.

The handler can be a single operation or an array of operations. Use [] for events that don't modify aggregate state (audit-only or projection-only events). See Tick reference for all available operations.

Schema design

Declare every field your events will carry, even fields your handlers don't reference. This ensures typos and unexpected data are caught at write time rather than silently stored. If your handler references $.data.email but an event arrives with $.data.emai, the schema rejects it immediately.

If you don't set additionalProperties: false, events may include fields beyond what's declared in properties. This can be useful for attaching extra context (audit metadata, analytics tags) that handlers don't process but is available when querying the event stream directly. Undeclared fields are stored as-is but aren't validated.

See JSON Schema reference for supported keywords.

Schema evolution

Events are immutable, but your understanding changes. Use optional fields and sensible defaults:

{
  "was_placed": {
    "schema": {
      "type": "object",
      "properties": {
        "items": { "type": "array" },
        "shipping_address": { "type": "object" },
        "gift_message": { "type": "string" }
      },
      "required": ["items", "shipping_address"]
    }
  }
}

Old events without gift_message validate fine. New events can include it. This approach lets you evolve your schema without breaking existing event streams.

Reserved event types

Event types starting with _ are reserved for system-generated events (e.g., _was_tombstoned for GDPR erasure). These events:

  • Cannot be written via the API -- writes with _* event types are rejected
  • Must not define a schema -- the system owns the event format
  • May define a handler to update aggregate state when replayed, or omit it (the reader skips events with no handler)
  • Can use [] as a no-op handler if you want the event registered but don't need state changes
{
  "user": {
    "events": {
      "was_created": {
        "schema": { "type": "object", "required": ["name"] },
        "handler": [{ "set": { "target": "", "value": "$.data" } }]
      },
      "_was_tombstoned": {
        "handler": []
      }
    }
  }
}

agent_types

Defines who can trigger events. Every event requires an actor, and the actor's type must appear in this list.

{
  "agent_types": ["user", "admin", "staff", "webhook"]
}

When writing events, the actor must have a type from your agent_types list and an id that is a valid UUID:

{
  "metadata": {
    "actor": {
      "type": "user",
      "id": "550e8400-e29b-41d4-a716-446655440000"
    }
  }
}

Common patterns: - user -- end users of your application - admin -- staff and administrators - webhook -- external services calling your webhooks - integration -- third-party API integrations

System agents

System agents handle platform operations. They use the reserved system_agent type and are not part of your agent_types list.

Agent ID Purpose Details
event_scheduler Fires scheduled events Adds metadata.scheduled block for audit trail
test_data_importer Test data injection (non-prod only) Used when injecting test data without an explicit actor

When a scheduled event fires:

{
  "metadata": {
    "actor": { "type": "system_agent", "id": "event_scheduler" },
    "scheduled": {
      "scheduled_id": "a1b2c3d4-...",
      "fire_at": "2024-01-15T12:00:00Z"
    }
  }
}

Reserved prefixes

Reserved Description
system_* Actor type prefix, reserved for platform operations
_* Event type prefix, reserved for system-generated events
global Aggregate key ID for singletons (one per type)

Your agent_types cannot include any type starting with system_ -- this is enforced at spec deploy time.

target_types

Optional. Used when events affect something other than the primary aggregate.

{
  "target_types": ["user", "order", "product"]
}

Include in event metadata:

{
  "metadata": {
    "actor": { "type": "user", "id": "..." },
    "target": { "type": "order", "id": "..." }
  }
}

Useful for cross-aggregate queries: "show me all events where the target was this order."

modules

Optional configuration for platform features.

{
  "modules": {
    "cache": ["user", "order"],
    "sagas": {
      "order_fulfillment": { ... }
    }
  }
}
Module Type Description
cache Array of strings Aggregate types whose computed state is cached in Redis
sagas Object Long-running process definitions

The cache array lists aggregate types that benefit from caching. Cached aggregates are served from Redis instead of being recomputed from the event stream on every read.

See Implications guide for saga definitions.

geo_types

Define geographic shapes for geospatial queries.

{
  "geo_types": {
    "delivery_zone": {
      "type": "polygon",
      "properties": ["name", "delivery_fee"]
    }
  }
}

Attach to aggregates for location-based lookups:

{
  "store": {
    "events": {
      "was_registered": {
        "schema": {
          "type": "object",
          "properties": {
            "name": { "type": "string" },
            "location": { "type": "object" }
          },
          "required": ["name", "location"]
        },
        "handler": [
          { "set": { "target": "", "value": "$.data" } }
        ],
        "geo": {
          "type": "point",
          "path": "$.data.location"
        }
      }
    }
  }
}

Query stores near a location:

curl "https://myapp.j17.dev/store/nearby?lat=40.7&lng=-74.0&radius=5000"

projections

Multi-aggregate views that combine data from multiple sources.

{
  "projections": {
    "user_dashboard": {
      "sources": [
        { "aggregate": "user", "id": "$.user_id" },
        { "aggregate": "order", "query": "user_id = $.user_id" }
      ]
    }
  }
}

Projections are read-only and recomputed on demand. Use them when you need data from multiple aggregates in one call.

See Projections guide for details.

singletons

Custom singleton identifiers beyond the default global.

{
  "singletons": ["config", "rate_limits", "feature_flags"]
}

Every aggregate type implicitly supports the global key for a singleton instance. Custom singletons let you define additional well-known keys.

Query without an ID:

curl https://myapp.j17.dev/config \
  -H "Authorization: Bearer $J17_API_KEY"

Use sparingly. Singletons are convenient but can become bottlenecks under high write concurrency.

Validation at spec deploy

When you deploy a spec, j17 validates it in full before accepting it:

  1. Structure -- required fields present, correct types, no unknown keys
  2. agent_types -- non-empty array, no system_* prefix
  3. JSON Schema -- only supported keywords, valid Draft 2020-12 syntax
  4. Handlers -- valid tick operations and JSONPath expressions
  5. Implications -- valid emit templates and JSONPath expressions

Invalid specs are rejected with detailed error messages pointing to the exact location of the problem.

Backward compatibility

When deploying a spec update to an environment that already has a spec, j17 checks that the new spec is backward-compatible with the existing one. This prevents changes that would make existing events unreadable or fail validation.

Blocked changes (checked recursively through nested object schemas): - Removing an aggregate type (existing event streams would become unreadable) - Removing an event type (existing events would fail aggregation) - Adding a new required field to an existing event schema (existing events may not have it) - Changing the type of an existing property (existing events have the old type) - Setting additionalProperties: false on a schema that previously allowed them (existing events with extra fields would fail)

Always allowed: - Adding new aggregate types - Adding new event types to existing aggregates - Adding optional fields to existing event schemas - Changing handlers (aggregates are recomputed from events, not stored)

If you need to make an incompatible change (e.g., during early development before you have real data), an operator can force-deploy via the API with "force": true in the request body. This bypasses the compatibility check entirely -- use with extreme care in environments with existing events.

Cache invalidation

When a spec is deployed, j17 automatically invalidates cached aggregates that were computed with the old spec. This ensures handler changes take effect immediately without manual cache flushing.

Validation at event write

When an event is written, j17 validates six things:

  1. Aggregate type -- must exist in spec's aggregate_types
  2. Event type -- must exist for that aggregate type
  3. Actor -- type must be in agent_types (or be system_agent), id must be a valid UUID
  4. Target type -- if present, must be in target_types
  5. Event data -- must validate against the event's JSON Schema
  6. Key ID -- must be a valid UUID, humane code, or configured singleton

All six checks must pass before the event is persisted. Failures return a structured error response with the specific check that failed.

Complete example

A task management app with projects, tasks, system events, and implications:

{
  "aggregate_types": {
    "project": {
      "events": {
        "was_created": {
          "schema": {
            "type": "object",
            "properties": {
              "name": { "type": "string", "minLength": 1 },
              "description": { "type": "string" }
            },
            "required": ["name"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } },
            { "set": { "target": "status", "value": "active" } },
            { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
          ]
        },
        "was_archived": {
          "schema": { "type": "object" },
          "handler": [
            { "set": { "target": "status", "value": "archived" } },
            { "set": { "target": "archived_at", "value": "$.metadata.timestamp" } }
          ]
        }
      }
    },
    "task": {
      "events": {
        "was_created": {
          "schema": {
            "type": "object",
            "properties": {
              "project_id": { "type": "string", "format": "uuid" },
              "title": { "type": "string", "minLength": 1 },
              "description": { "type": "string" }
            },
            "required": ["project_id", "title"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } },
            { "set": { "target": "status", "value": "todo" } },
            { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
          ]
        },
        "had_status_changed": {
          "schema": {
            "type": "object",
            "properties": {
              "status": { "enum": ["todo", "in_progress", "done"] }
            },
            "required": ["status"]
          },
          "handler": [
            { "set": { "target": "status", "value": "$.data.status" } },
            {
              "if": { "eq": ["$.data.status", "done"] },
              "then": [
                { "set": { "target": "completed_at", "value": "$.metadata.timestamp" } }
              ]
            }
          ]
        },
        "had_assignee_changed": {
          "schema": {
            "type": "object",
            "properties": {
              "assignee_id": { "type": "string", "format": "uuid" }
            },
            "required": ["assignee_id"]
          },
          "handler": [
            { "set": { "target": "assignee_id", "value": "$.data.assignee_id" } }
          ]
        },
        "_was_tombstoned": {
          "handler": []
        }
      }
    }
  },
  "agent_types": ["user", "admin"],
  "target_types": ["project", "task"],
  "modules": {
    "cache": ["task"]
  }
}

Best practices

Start small. Define one aggregate type with 2-3 events. Add complexity as needed.

Use past tense for event names. was_created, not create. Events are facts about the past. Use snake_case: had_profile_updated, was_placed, had_item_added.

Declare complete schemas. Include every field in your schema, even ones your handlers don't use. This catches typos and malformed data at write time.

Keep handlers simple. If you need more than 3-4 operations per handler, consider whether you're modeling the right thing. Complex logic might need a saga or multiple events.

Version in git. The spec is code. Track it, review it, deploy it like any other code change.

Validate before deploying. Use the CLI to catch errors early:

j17 spec validate spec.json

See also

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