Events

Last updated 2 days ago

Events are immutable facts about something that happened in your system.

Writing an event

Events are written via HTTP POST to /{aggregate_type}/{aggregate_id}/{event_type}:

curl -X POST https://myapp.j17.dev/order/550e8400-e29b-41d4-a716-446655440000/was_placed \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "items": [{ "sku": "WIDGET-1", "quantity": 2 }],
      "total": 59.98
    },
    "metadata": {
      "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440001" }
    }
  }'

Every event requires:

  • aggregate_type - In the URL path (e.g., order)
  • aggregate_id - In the URL path (UUIDv4 or humane code)
  • event_type - In the URL path (e.g., was_placed)
  • data - The event payload
  • metadata.actor - Who or what caused this event

Metadata structure

Every event includes metadata:

Field Required Description
actor Yes { "type": "user", "id": "..." } - who caused this event
target No { "type": "item", "id": "..." } - what the action targeted
previous_length No Expected event count for external OCC

Actor and target types must be declared in your spec's agent_types and target_types arrays.

Events are immutable

Once written, an event can never be changed or deleted. This is the core principle of event sourcing.

To "undo" something, write a new event representing the reversal:

curl -X POST https://myapp.j17.dev/order/550e8400-.../was_cancelled \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": { "reason": "Customer request" },
    "metadata": {
      "actor": { "type": "user", "id": "..." }
    }
  }'

The order isn't deleted -- both was_placed and was_cancelled exist in history. The aggregate state reflects the cancellation because the handler for was_cancelled updates the status.

Naming conventions

Use snake_case past-tense verbs that describe what happened — not commands requesting future action. Use verb voice to signal who caused the change:

Active voice — the aggregate itself was the change agent

placed_order              # e-commerce: customer placed it
submitted_application     # hiring: candidate submitted it
attacked_target           # game: player chose to attack
checked_in                # field service: technician arrived

The entity did something. Reading the event stream, active voice events are the aggregate's own actions.

Passive voice — something external changed the aggregate

was_created               # account lifecycle
was_assigned_to_route     # dispatch system assigned the driver
had_price_adjusted        # admin changed the price
had_inventory_reserved    # checkout saga reserved stock

Use whichever passive form reads naturally — was_ and had_ both indicate an external cause (the system, an implication, an admin, another user). The distinction is grammatical, not semantic.

Why this matters

When reading an event stream, the voice tells you the causal direction:

order:abc123
  was_created                 ← system (checkout flow)
  placed_order                ← customer did this
  had_inventory_reserved      ← saga reserved stock
  had_payment_charged         ← payment processor confirmed
  was_fulfilled               ← warehouse shipped it
character:hero1
  was_created                 ← system (new game)
  equipped_sword              ← player did this
  attacked_target             ← player did this
  was_damaged                 ← combat system applied damage
  leveled_up                  ← player crossed XP threshold
  was_awarded_loot            ← loot system (implication)
vehicle:truck42
  was_created                 ← fleet management
  was_assigned_to_route       ← dispatch system
  checked_in                  ← driver arrived at stop
  reported_issue              ← driver flagged a problem
  had_maintenance_scheduled   ← ops team responded

Without the convention, every event looks the same and you can't tell at a glance whether the aggregate acted or was acted upon.

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