Skip to main content

Commands vs Facts

This document explores a fundamental tension in event-sourced systems: the difference between commands (requests that can be rejected) and facts (external realities that must be recorded).


The Problem

In traditional CQRS/ES, the flow is clear:

illustrative - command flow
Client → Command → Aggregate → Accept/Reject → Event

Commands express intent. The aggregate validates business rules and either:

  • Accepts: Emits events recording what happened
  • Rejects: Returns an error

But what happens when the thing has already happened? Consider:

ScenarioSourceCan You Reject It?
Stripe processed a paymentExternal webhookNo — money moved
FedEx delivered the packageExternal trackingNo — it's delivered
Game timer expiredClock/schedulerNo — time passed
Regulatory ruling issuedLegal systemNo — it's binding

These aren't requests. They're facts about external reality. The aggregate can't reject them — the world has already changed.


The Terminology Problem

Traditional CQRS conflates two distinct concepts:

ConceptTraditional TermActual Meaning
Request for actionCommand"Please do X" — rejectable
Notification of factCommand"X happened" — not rejectable

When a saga receives PaymentProcessedByStripe and needs to inform the Order aggregate, it sends a "command" like RecordPaymentReceived. But this isn't really a command — the aggregate can't refuse to acknowledge that payment occurred.

This creates awkward patterns:

  • Aggregates with commands that "can never fail"
  • Validation logic that always returns success
  • Optimistic concurrency that doesn't make sense (sequence numbers guard against concurrent decisions, not concurrent fact recording)

How Other Frameworks Handle This

The Decider Pattern (Jérémie Chassaing)

The Functional Event Sourcing Decider separates concerns:

illustrative - decider pattern
decide: Command → State → Event list    # Decision logic
evolve: State → Event → State # State transitions

Some implementations extend this with a separate path for facts:

illustrative - fact recording path
record: Fact → State → Event list       # No validation, just acknowledge

Axon Framework

Axon distinguishes between:

  • Decision commands: Standard validation, can reject
  • Notification commands: Minimal validation, expected to succeed

The aggregate handler checks which type and adjusts validation accordingly.

Martin Fowler's Recording Pattern

From Event Sourcing:

"Turn the interaction into events at the boundary of the system and use the record of events to remember what happened."

External interactions become *Recorded events:

  • PaymentReceived (internal decision)
  • ExternalPaymentRecorded (external fact)

Anti-Corruption Layer

Many systems use an ACL to translate external facts into internal domain concepts at the system boundary, before they reach aggregates.


Angzarr's Solution

Angzarr addresses this by allowing sagas and external systems to pass events (not commands) to aggregates, distinguished by a fact sequence indicator.

Commands vs Fact Events

Message TypePageHeader.sequence_typeValidationConcurrency
Command (client)sequence (integer)Full business rulesOptimistic locking
Command (saga)angzarr_deferredFull business rulesFramework-managed
Fact (external)external_deferredIdempotency onlyAppend-only

When the aggregate coordinator receives a fact event:

  1. No business validation — the fact already happened
  2. Idempotency check — prevent duplicate recording via PageHeader.external_deferred.external_id
  3. Direct append — no optimistic concurrency conflict possible
  4. State transition — the evolve function updates aggregate state

Protocol Structure

The PageHeader uses a oneof to distinguish sequence types:

// Shared header for CommandPage and EventPage.
// Encodes sequence type and provenance for framework processing.
message PageHeader {
oneof sequence_type {
uint32 sequence = 1; // Explicit sequence (aggregate handlers, legacy)
ExternalDeferredSequence external_deferred = 2; // External fact (Stripe, FedEx, etc.)
AngzarrDeferredSequence angzarr_deferred = 3; // Saga-produced command/fact
}
}

// For facts from external systems (webhooks, integrations).
// Framework stamps sequence on delivery; idempotency via external_id.
message ExternalDeferredSequence {
string external_id = 1; // Idempotency key from external system (e.g., "pi_1234" from Stripe)
string description = 2; // Human-readable origin (e.g., "Stripe webhook")
}

// For saga-produced commands and facts.
// Framework stamps sequence on delivery; idempotency derived from source info.
// Rejections route back to source aggregate.
message AngzarrDeferredSequence {
Cover source = 1; // Full source aggregate (domain + root + edition) - rejection routes here
uint32 source_seq = 2; // Sequence of the triggering event
}

Key design: The idempotency key (external_id) lives in PageHeader.external_deferred, keeping Cover focused on aggregate identity while PageHeader handles sequencing. The ExternalDeferredSequence carries both the idempotency key and a human-readable description. This ensures:

  • Consistent deduplication at the coordinator
  • Clear provenance tracking for audit trails
  • Human-readable context for debugging

Flow Comparison

Traditional command flow (saga-to-aggregate):

illustrative - traditional command flow
Saga receives: OrderCompleted
Saga emits: RecordPayment command (sequence=5)
Aggregate: Validate → Accept/Reject → Emit PaymentRecorded

Angzarr saga flow (command with deferred sequence):

illustrative - Angzarr saga flow
Saga receives: OrderCompleted
Saga emits: StartFulfillment command
- PageHeader.angzarr_deferred (framework-stamped)
Coordinator: Validate → Accept/Reject → Assign sequence → Persist

Angzarr external fact flow (webhook injection):

illustrative - Angzarr fact flow
Stripe webhook: PaymentReceived event
- PageHeader.external_deferred.external_id = "pi_xxx"
- PageHeader.external_deferred.description = "Stripe webhook"
Coordinator: Check external_id → Assign sequence → Append → Publish

Fact Processing Pipeline

The coordinator handles fact events through a configurable pipeline:

illustrative - fact processing pipeline
Fact Event arrives (with ExternalDeferredSequence)

Check idempotency (external_deferred.external_id)

[If route_to_handler = true] ←── Default: true

Route to aggregate for state update

Aggregate returns event

Coordinator assigns real sequence number

Persist event (sequence assigned in PageHeader)

Publish event (with valid sequence)

Key behavior: The ExternalDeferredSequence marker triggers deferred sequence assignment. When the aggregate returns events, the coordinator:

  1. Takes the next available sequence number for the aggregate root
  2. Replaces external_deferred with sequence in the PageHeader
  3. Persists and publishes the event with a valid sequence

Downstream consumers (sagas, projectors, process managers) always receive events with proper sequence numbers. The deferred sequence is purely an ingestion-time marker.

Configuration

SettingDefaultDescription
route_to_handlertrueWhen true, fact events are routed to the aggregate for state updates before persistence. When false, facts are persisted directly without aggregate involvement.

Setting route_to_handler = true (the default) allows aggregates to:

  • Update their internal state based on the fact
  • Emit additional events in response to the fact
  • Maintain consistency with their domain model

Setting it to false is useful for pure append-only fact logging where aggregate state isn't needed.

Benefits

  1. Semantic clarity: Facts are events, not commands. The type system reflects reality.

  2. No fake validation: Aggregates don't need "commands that can't fail."

  3. Correct concurrency: Facts don't compete with decisions — they're additive observations.

  4. Idempotency by design: External IDs naturally deduplicate (Stripe payment ID, tracking number, etc.).

  5. Audit trail accuracy: Events are labeled as externally-sourced vs internally-decided.


When to Use Each

Use Commands (with sequence) when:

  • The aggregate is making a decision
  • Business rules can reject the request
  • Concurrent commands should conflict (optimistic concurrency)
  • The source is an internal actor with intent

Examples:

  • CreateOrder — validate inventory, customer status
  • ReserveFunds — check available balance
  • PlaceBet — validate game state, bet limits

Use Fact Events (with external_deferred) when:

  • Something already happened in the external world
  • The aggregate must acknowledge, not decide
  • Multiple notifications of the same fact should deduplicate
  • The source is an external system or physical reality

Examples:

  • PaymentReceived — Stripe confirmed payment
  • PackageDelivered — FedEx tracking update
  • GameTimeExpired — clock exhausted
  • RegulatoryHoldPlaced — compliance system action

External Fact Injection

External systems inject facts via the HandleEvent RPC:

illustrative - external fact injection
// Stripe webhook handler injects payment fact
async fn handle_stripe_webhook(payload: StripeEvent) {
let event_request = EventRequest {
events: Some(EventBook {
cover: Some(Cover {
domain: "order".into(),
root: order_id.into(),
..Default::default()
}),
pages: vec![EventPage {
header: Some(PageHeader {
sequence_type: Some(ExternalDeferred(ExternalDeferredSequence {
external_id: payload.payment_intent_id.clone(), // Stripe ID for idempotency
description: "Stripe webhook".into(),
})),
}),
payload: Some(Event(Any::pack(PaymentReceived {
order_id,
amount: payload.amount,
}))),
..Default::default()
}],
..Default::default()
}),
route_to_handler: true,
..Default::default()
};

client.handle_event(event_request).await;
}

The aggregate coordinator handles fact events differently:

  • Checks idempotency via PageHeader.external_deferred.external_id
  • Skips business validation (fact already happened)
  • Assigns sequence number and appends to event stream
  • Publishes with assigned sequence to downstream consumers

Saga-Produced Commands

Sagas translate events between domains. They return SagaResponse containing commands (not facts):

illustrative - saga producing commands
impl SagaHandler for OrderFulfillmentSaga {
async fn handle(&self, source: &EventBook) -> Result<SagaResponse, Status> {
let mut commands = Vec::new();

for page in &source.pages {
if let Some(order_completed) = extract_event::<OrderCompleted>(&page) {
// Saga produces command for fulfillment domain
// Framework stamps angzarr_deferred with source info
commands.push(CommandBook {
cover: Some(Cover {
domain: "fulfillment".into(),
root: order_completed.order_id.into(),
..Default::default()
}),
pages: vec![CommandPage {
header: Some(PageHeader::default()), // Framework fills angzarr_deferred
command: Some(Any::pack(StartFulfillment {
order_id: order_completed.order_id,
items: order_completed.items.clone(),
})),
..Default::default()
}],
..Default::default()
});
}
}

Ok(SagaResponse { commands, events: vec![] })
}
}

The framework stamps angzarr_deferred on saga-produced commands with source aggregate info for:

  • Provenance tracking (which event triggered this command)
  • Compensation routing (rejection flows back to source aggregate)

  • Command — Requests that may be rejected
  • Event — Immutable facts (internal or external)
  • Notification — Transient signals (not persisted)
  • Saga — Domain bridges that emit commands (with angzarr_deferred)
  • Sequence — Optimistic concurrency for commands

Further Reading