Side Effects
Side effects are operations that fire after core rules succeed and after the event is committed to the ledger. They handle fan-out: emitting events to other streams, submitting intents to other entities, and scheduling delayed events.
Side effects never write entity state directly. State mutation is strictly the domain of core rules.
Why Side Effects Are Separate from Core
The separation enforces a clean architecture:
- Core rules: deterministic state mutations — the “what happened” record
- Side effects: fan-out — “what should happen next as a result”
This split makes rules deterministic and replayable. Core rules can be re-applied to the ledger at any time to rebuild state. Side effects are not replayed during snapshot rebuild (by design — you don’t want to re-send events or re-trigger downstream services).
op: emit — Emit a Domain Event
Emits an event to another stream. Used to notify downstream consumers about a state change.
side_effects:
rules:
- name: notify_purchase_complete
when:
- path: /status
op: eq
value: "complete"
then:
- op: emit
event_type: TICKET_PURCHASE_COMPLETED
payload:
user_id: intent.user_id
show_id: intent.show_id
ticket_id: intent.ticket_id
amount: intent.amountThe emitted event is written to the ledger_events table and published to Kafka. Any consumer subscribed to that event type — Causet projections, external services, analytics pipelines — can pick it up.
op: emit_each — Emit per Array Item
Iterates over an array field and emits one event per item.
side_effects:
rules:
- name: notify_each_attendee
when: {}
then:
- op: emit_each
source: intent.attendee_ids
event_type: ATTENDEE_NOTIFIED
payload:
attendee_id: item
show_id: intent.show_id
message: intent.notification_messagesource is the array to iterate over. item is the current element in the payload expression.
op: submit — Submit a Follow-Up Intent
Submits a new intent to another entity. The submitted intent runs through its own full rules evaluation (preflight, core, side_effects).
side_effects:
rules:
- name: trigger_enrichment
when:
- path: /status
op: eq
value: "parsed"
then:
- op: submit
intent_type: ENRICH_TICKET_METADATA
payload:
ticket_id: intent.ticket_id
artist_id: intent.artist_id
venue_id: intent.venue_idSubmit is fire-and-forget from the perspective of the originating entity. The originating entity is not notified of success or failure unless the submitted intent emits an event back that the originating entity is watching via a saga.
Multiple submits in the same rule fire in parallel:
then:
- op: submit
intent_type: FETCH_VENUE_DATA
payload:
venue_id: intent.venue_id
import_id: intent.import_id
- op: submit
intent_type: FETCH_ARTIST_BIO
payload:
artist_id: intent.artist_id
import_id: intent.import_idop: schedule — Delayed Emit
Enqueues an event to fire after a delay. See Timers & Scheduling for full coverage.
then:
- op: schedule
event_type: IMPORT_TIMEOUT_CHECK
delay_seconds: 3600
payload:
import_id: intent.import_idMultiple Side Effects in One Rule
A single rule can contain any mix of emit, emit_each, submit, and schedule operations. All fire after core succeeds, and all fire regardless of the outcome of each other (no short-circuiting within a side_effects rule).
side_effects:
rules:
- name: post_purchase_fan_out
when: {}
then:
- op: emit
event_type: TICKET_PURCHASED
payload:
user_id: intent.user_id
show_id: intent.show_id
- op: submit
intent_type: UPDATE_SHOW_AVAILABILITY
payload:
show_id: intent.show_id
quantity: 1
- op: schedule
event_type: PURCHASE_FOLLOWUP_TRIGGERED
delay_seconds: 259200
payload:
purchase_id: intent.purchase_id
user_id: intent.user_idExternal I/O Is Not in Side Effects
Side effects do not support HTTP calls, email sending, webhook calls, or any other external I/O. This is intentional.
Why no external I/O in rules:
- Determinism: rules must be deterministic for replay to work. HTTP calls are non-deterministic (network failures, changed responses).
- Replayability: during snapshot rebuild, rules are re-evaluated against the ledger. You don’t want external API calls to fire again during replay.
- Isolation: the rules engine should not have external dependencies that can fail the intent processing pipeline.
The correct pattern for external I/O:
Emit a domain event from Causet. An external consumer (not part of Causet) subscribes to the Kafka topic and handles the external call. This consumer can have its own retry logic, rate limiting, and failure handling without affecting the Causet rules engine.
Example: Email Notification via Kafka Consumer
side_effects:
rules:
- name: emit_purchase_for_email
when: {}
then:
- op: emit
event_type: TICKET_PURCHASED
payload:
user_id: intent.user_id
user_email: intent.user_email
show_name: intent.show_name
show_date: intent.show_date
venue: intent.venue
ticket_id: intent.ticket_idYour email service subscribes to the TICKET_PURCHASED event type on Kafka and sends the confirmation email. If the email service is down, it retries independently — Causet rules are unaffected.
Side Effects Failure Handling
Infrastructure-level failures in emit/submit operations (Kafka unavailable, downstream service down) are retried at the infrastructure layer. The rules engine does not retry side effects at the application level.
If a op: submit fails because the downstream intent’s preflight rejects it, the error is not propagated back to the originating entity. See Workflow Error Handling for recovery patterns.