Skip to main content

Commands vs Facts

Not everything is a request. Some things are reality.


The Poker Floor Example

Mid-hand, the dealer accidentally exposes a card. The floor manager is called. She rules: dead hand, return all bets.

Your hand aggregate doesn't get to vote on this. The ruling happened. The cards were exposed. The decision was made by a human with authority your software doesn't have. Your system records the fact and moves on.

This pattern appears everywhere external authority exists—payment processors, regulatory systems, IoT sensors, human overrides. The External Rulings example shows the implementation.


The Distinction

A command is a request that can be rejected. "Please reserve this inventory." The aggregate validates the request against business rules and either produces events or rejects.

A fact is an announcement of something that already happened externally. "Stripe confirms the payment succeeded." "FedEx scanned the package in Memphis." "The floor manager issued a ruling."

You cannot reject reality. You can only record it.


Why This Matters

External Systems Don't Ask Permission

When Stripe sends a webhook confirming payment, the payment has already happened. Your system's opinion is irrelevant. The event must be recorded.

Audit Trail Clarity

Commands and facts have different semantics in your audit trail:

TypeSemanticsExample
Command"We decided to do X"ReserveInventory → InventoryReserved
Fact"X happened externally"PaymentConfirmed (from Stripe)

Mixing them obscures who made decisions and when.

Idempotency Requirements

Facts from external systems often arrive multiple times (webhook retries, network issues). The system must handle duplicates gracefully without creating duplicate events.


How It Works

Facts need two things that regular commands don't:

  1. An idempotency key — Stripe might retry that webhook five times. You need to record the payment exactly once.
  2. A marker saying "I'm a fact" — So the coordinator knows to assign a sequence number rather than expect one.

These concerns live in different places, and understanding why matters.

The Cover: Aggregate Identity

Every event has a Cover — metadata identifying the aggregate (domain, root, edition) and optional correlation ID for cross-domain tracing.

message Cover {
string domain = 2;
UUID root = 1;
string correlation_id = 3; // Workflow correlation - flows through all commands/events
Edition edition = 4; // Edition for diverged timelines; empty name = main timeline
// Field 5 removed: external_id moved to ExternalDeferredSequence in PageHeader
}

The Cover identifies which aggregate the event belongs to. For facts, the idempotency key lives in the PageHeader's ExternalDeferredSequence, not the Cover.

The PageHeader: Sequence Type Selection

The PageHeader tells the coordinator how to handle sequencing. For facts, use ExternalDeferredSequence:

// 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
}

The external_deferred variant is transient—after processing, it's replaced with a real sequence number. The event then looks like any other sequenced event. The external_id within the deferred sequence provides idempotency.

Processing Flow

The coordinator handles facts in five steps:

  1. Checks idempotency via external_id in ExternalDeferredSequence
  2. Routes to aggregate for state update (if configured)
  3. Replaces the external_deferred with a real sequence number
  4. Persists the event
  5. Publishes to downstream consumers

Idempotency in Practice

Here's how you emit a fact with proper idempotency:

illustrative - fact emission
cover = Cover(
domain="payments",
root=payment_id,
)

fact_event = EventPage(
header=PageHeader(
external_deferred=ExternalDeferredSequence(
external_id="stripe_pi_abc123", # Idempotency key
description="Payment intent succeeded",
),
),
event=Any.pack(PaymentConfirmed(
amount=Money(amount=5000, currency="USD"),
provider="stripe",
)),
)

After processing:

  • header.sequence = 42 (assigned, replacing external_deferred)

Webhook retries? Network hiccups? Duplicate deliveries? The coordinator checks external_id, finds it already exists, and silently ignores the duplicate.


Configuration

By default, facts route to the aggregate before persistence:

illustrative - fact routing config
route_facts_to_aggregate: true

This allows the aggregate to update its state and potentially emit additional events in response to the fact.

Set to false if facts should persist directly without aggregate processing.


Examples

Illustrative Examples

The following examples show the pattern for external system integration. Your implementation will vary based on your webhook provider and domain.

Payment Webhooks

illustrative - payment webhook
# Stripe webhook handler
@webhook("/stripe")
def handle_stripe_webhook(event: StripeEvent):
if event.type == "payment_intent.succeeded":
emit_fact(
domain="orders",
root=order_id_from_metadata(event),
external_id=event.id, # Idempotency key
description="Stripe payment intent succeeded",
event=PaymentConfirmed(
amount=event.data.amount,
provider="stripe",
),
)

IoT Sensor Data

illustrative - IoT sensor fact
# Temperature reading from sensor
emit_fact(
domain="warehouse",
root=zone_id,
external_id=f"{sensor_id}:{timestamp}", # Idempotency key
description="Sensor reading",
event=TemperatureRecorded(
celsius=reading.value,
sensor_id=sensor_id,
),
)

External Rulings

illustrative - external ruling
# Floor manager ruling in poker
emit_fact(
domain="hand",
root=hand_id,
external_id=f"ruling:{ruling_id}", # Idempotency key
description="Floor manager ruling",
event=RulingIssued(
ruling_type=RulingType.DEAD_HAND,
issued_by="floor_manager_jane",
reason="Cards exposed during deal",
),
)

See Also