Event Versioning
Events are stable contracts. Once deployed, an event type’s name and payload shape are referenced by the ledger, projections, listeners, and sagas. Changes require careful planning.
Additive Changes (Safe)
Adding optional payload fields to an existing event is backward compatible:
# Before
events:
TICKET_PURCHASED:
state: user
entity_expr: event.user_id
payload:
user_id: string
show_id: string
price: number
# After — adding optional field
events:
TICKET_PURCHASED:
state: user
entity_expr: event.user_id
payload:
user_id: string
show_id: string
price: number
currency: string # new optional field
purchased_at: datetime # new optional fieldSafe because:
- Existing projection handlers receive
event.currencyasnullfor old events. - Existing ledger entries are unaffected.
- No replay required.
Note: When adding a field used in a projection’s
deriveexpression, guard againstnullfor historical events:derive: currency: { expr: "event.currency != null ? event.currency : 'USD'" }
Breaking Changes
The following changes are breaking — they affect existing ledger entries, projections, or consumers:
| Change | Why It Breaks |
|---|---|
| Removing a payload field | Projections that reference event.removed_field will fail |
| Renaming a payload field | Projections that reference the old name will break |
| Changing a field type | Downstream consumers expecting the old type will fail |
| Renaming the event type | Projections subscribed to the old name stop firing |
Breaking Change Strategy: Add a V2 Event
The recommended strategy for breaking changes is to introduce a new event type and deprecate the old one:
events:
# Deprecated — keep emitting for backward compatibility during transition
TICKET_PURCHASED:
state: user
entity_expr: event.user_id
payload:
user_id: string
show_id: string
price: number # this was the breaking field
# New version — clean payload
TICKET_PURCHASED_V2:
state: user
entity_expr: event.user_id
payload:
user_id: string
show_id: string
price_cents: int # renamed + type change
currency: string
purchased_at: datetimeMigration procedure
- Deploy the new IR version with both
TICKET_PURCHASEDandTICKET_PURCHASED_V2. - Update the action that emits
TICKET_PURCHASEDto emitTICKET_PURCHASED_V2instead. - Update projections to subscribe to both event types during the transition period:
projections: user_purchases: source_events: [TICKET_PURCHASED, TICKET_PURCHASED_V2] mutations: TICKET_PURCHASED: { op: upsert } TICKET_PURCHASED_V2: { op: upsert } derive: # Handle both event shapes price_cents: { expr: "event.price_cents != null ? event.price_cents : event.price * 100" } - Once all historical events are
TICKET_PURCHASED_V2(and old projections are rebuilt), remove the old event type from the DSL.
Projection Compatibility During Migration
While running both old and new event versions, projections must handle both. Use conditional derive expressions to handle both shapes:
derive:
# Normalize price to cents regardless of which event version
price_cents: { expr: "event.price_cents != null ? event.price_cents : (event.price * 100)" }
currency: { expr: "event.currency != null ? event.currency : 'USD'" }This pattern works because:
event.price_centsisnullon oldTICKET_PURCHASEDevents (field didn’t exist).event.priceisnullon newTICKET_PURCHASED_V2events (field was removed).
Compiler Validation
The Causet compiler validates:
- All field references in
deriveexpressions exist in the source event’s payload (or are reserved fields). - All
source_eventsdeclared in projections exist as declared events. emitoperations reference events that exist in the DSL.
If you rename a payload field, the compiler catches all references to the old name:
Error: projection 'user_purchases' references 'event.price' in derive.price_cents,
but TICKET_PURCHASED_V2 has no field 'price'. Did you mean 'price_cents'?IR Versioning
Every compile produces a new irVersion. The irVersion is stored on each ledger event — you can always know which DSL version was in effect when an event was emitted:
SELECT DISTINCT ir_version, event_type, COUNT(*)
FROM ledger_events
WHERE entity_id = 'user-123'
GROUP BY ir_version, event_type;This is essential for debugging payload shape discrepancies — an event emitted with IR v39 may have different fields than the same event type emitted with IR v42.
Deprecation and Cleanup
Once all projections and consumers have migrated to the new event version:
- Remove the old event type from the DSL.
- Compile a new IR version.
- Deploy and activate the new IR version.
- The old event type remains in the ledger (immutable history) but is no longer emitted.
Projections subscribed to the old event type will stop receiving events once the old action no longer emits them. Old events in the ledger are unaffected — they are historical records.
Related Pages
- Defining Events — full event DSL syntax
- Event Naming — naming conventions and stability
- Concepts: Projections — handling multiple event versions in projections
- Concepts: Replay — rebuilding projections after a schema change