Commands vs Facts
Not everything is a request. Some things are reality.
The Poker Floor Example
Section titled “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
Section titled “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
Section titled “Why This Matters”External Systems Don’t Ask Permission
Section titled “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
Section titled “Audit Trail Clarity”Commands and facts have different semantics in your audit trail:
| Type | Semantics | Example |
|---|---|---|
| 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
Section titled “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
Section titled “How It Works”Facts need two things that regular commands don’t:
- An idempotency key — Stripe might retry that webhook five times. You need to record the payment exactly once.
- 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
Section titled “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 // Client-supplied extension slot. Most consumers pack a nested // ``Cover`` here to express parent-aggregate relationships (e.g. // table events carry the tournament Cover; hand events carry the // table Cover). Helper ``unpack_parent_cover`` extracts the typed // Cover when present. Framework code treats this as opaque routing // metadata — it propagates without inspecting. // // WARNING — mass-propagating: the framework stamps this slot onto // EVERY event a child aggregate emits, so the payload is paid per // event. Keep what you pack small. The intended workload is a single // nested Cover (~50–80 bytes packed); larger payloads multiply // across the event stream and the bus. google.protobuf.Any ext = 6;}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
Section titled “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 } // Per-command override of the enclosing CommandRequest's sync_mode. // Only meaningful on CommandPage headers; ignored on EventPage headers. // PMs emit a `repeated CommandBook` and cannot reach the request wrapper // themselves; setting sync_mode here lets a PM tag a single emitted // command (e.g. SYNC_MODE_DECISION when its accept/reject must surface // synchronously) while the surrounding flow stays whatever the original // caller asked for. When unset (the common case) coordinators inherit // CommandRequest.sync_mode unchanged. optional SyncMode sync_mode = 4;}
// 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
Section titled “Processing Flow”The coordinator handles facts in five steps:
- Checks idempotency via
external_idinExternalDeferredSequence - Routes to aggregate for state update (if configured)
- Replaces the
external_deferredwith a realsequencenumber - Persists the event
- Publishes to downstream consumers
Idempotency in Practice
Section titled “Idempotency in Practice”Here’s how you emit a fact with proper idempotency:
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, replacingexternal_deferred)
Webhook retries? Network hiccups? Duplicate deliveries? The coordinator checks external_id, finds it already exists, and silently ignores the duplicate.
Configuration
Section titled “Configuration”By default, facts route to the aggregate before persistence:
route_facts_to_aggregate: trueThis 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
Section titled “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
Section titled “Payment Webhooks”# 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
Section titled “IoT Sensor Data”# Temperature reading from sensoremit_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
Section titled “External Rulings”# Floor manager ruling in pokeremit_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
Section titled “See Also”- Compensation flow — What happens when commands fail
- Components: Commands vs Facts — Technical deep-dive