Events
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.