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:
| 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
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:
- 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
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:
- 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
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
By default, facts route to the aggregate before persistence:
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
The following examples show the pattern for external system integration. Your implementation will vary based on your webhook provider and domain.
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
# 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
# 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
- Compensation flow — What happens when commands fail
- Components: Commands vs Facts — Technical deep-dive