Design
How Wyrd behaves
The rules behind the API: what a message is, what a process promises, and what happens when something goes wrong.
Five principles
- Decisions are pure; effects belong to Wyrd. Business code decides. Wyrd delivers, stores, retries, compensates and times out.
- Explicit over implicit. No hidden defaults that change behaviour. When something must be decided, the compiler (analyzers) asks for the decision instead of assuming one.
- Guarantees are not options. What keeps the system consistent cannot be turned off.
- Nothing fails silently. What Wyrd cannot resolve becomes visible and recoverable, never lost or stuck in silence.
- Time limits are business decisions; slowness is not failure. A deadline is something the business chooses on purpose. A slow answer only makes a process wait.
Messages
Three kinds of message exist because they make different promises. Mixing them is the most common source of coupling in event-driven systems.
| Kind | Meaning | Rules | Status |
|---|---|---|---|
| Command | An intention | Exactly one owner (a slice); sent, never published; can be rejected. | Implemented |
| Domain event | A fact inside one context | Produced only by slices; private; can change freely. | Implemented |
| Integration event | A fact for other contexts | Public, versioned contract; produced only by a publication; snapshot-checked. | Implemented |
// An intention sent to exactly one owner
public sealed record ChargePayment(string OrderId, decimal Amount) : ICommand;
// An internal fact inside one bounded context
public sealed record PaymentCharged(string OrderId, decimal Amount) : IDomainEvent;
// A versioned public contract across boundaries
[IntegrationEvent("payments.payment-charged", Version = 1)]
public sealed record PaymentChargedV1(string OrderId, decimal Amount, string Currency)
: IIntegrationEvent;Slices
A slice owns one command: it decides from the current state and says how the state changes. Nothing else touches that state.
| Rule | Status |
|---|---|
The owner loads the state for KeyOf(command), calls Decide(command, state) and, if accepted, stores Evolve(state, event) together with the event. | Implemented |
| Two commands on the same key use optimistic concurrency: one wins, the other is retried and decides again on the new state. | Implemented |
| One decision produces at most one event. Two facts are modelled as one event, or a union of events. | Conscious limit |
State is stored as a snapshot, not rebuilt from events. Events are kept as history, but changing Evolve does not change states already stored. | Conscious limit |
Processes
A process is linear: a trigger, steps and waits in order, and an optional outcome when it is compensated. There is no branching; an alternative path is another process. That is a Conscious limit: linear processes stay easy to read, simulate and version.
trigger event (correlated by key)
│
▼
step 1: send a command ──► Accepted ──► next step
│ └─► Rejected ──► compensate (never retried)
│ └─► Failed ──► the step's retry policy decides
│ (exhausted: suspend, or .OrCompensate())
▼
wait: Await<Event>(key) ──► fact arrives ──► next step
│ └─► .Within(...) expires ──► compensate on purpose
▼
step 2 ...
▼
last step ──► CompletedInstances and the structure fingerprint
| Rule | Status |
|---|---|
| One instance per process and key, for the lifetime of the store. A second trigger with the same key is a duplicate. | Implemented |
| Each instance records a fingerprint of the structure it started with: the steps, their order and the waits, but not configuration such as retries. If the deployed definition has a different structure, the instance is suspended ("definition changed") instead of continuing at the wrong step. Nothing it receives meanwhile is lost (replies stay in the owner's inbox, awaited events are parked), and it can be resumed once the definition it started with is deployed again. | Implemented |
| Migrating in-flight instances (process versioning). | Planned in v1.0 |
Steps
| Rule | Status |
|---|---|
| A step sends one command and waits for its reply: accepted (next step), rejected (compensate) or failed (the retry policy decides). | Implemented |
A step declares its retry policy: .Retry(attempts, backoff) or .NoRetry("why"). There is no default, and an undeclared policy is reported by an analyzer (WYRD0014). Retries re-send the same message id, so the owner's inbox makes them safe. | Implemented |
A step says how it is undone: .Undo(command) or .NoCompensation("why"). The last step is exempt (WYRD0011). | Implemented |
A step may read the events produced by earlier steps by asking for them as extra parameters, for example Ship(OrderPlaced order, PaymentCharged charged). A step that asks for an event no earlier step produces makes the application fail before the first message. The latest event of each type is used, and awaited events count. An undo reads only events from before its own step, because its step may never have run. | Implemented |
Retries: one layer
| Rule | Status |
|---|---|
| A failure of a process command (the owner cannot handle it) reaches the process as a failure, and the step's policy decides. Hosts do not multiply attempts behind the process's back: a command that cannot be delivered or handled ends as a failure reply. Today: in memory and on the EF Core host. | Implemented |
| A business rejection is never retried: a pure decision would answer the same. | Implemented |
Compensation is a guarantee
Rejected at step 3
undo step 2 (insists until it works, or suspends)
undo step 1
run the outcome command (.OnCompensated(...))
instance: Compensated| Rule | Status |
|---|---|
When a step is rejected, the completed steps are undone in reverse order, then the outcome command (.OnCompensated(...)) runs. | Implemented |
Undos always insist. Their retries are a guarantee, not an option: 10 retries with exponential backoff (waits capped at one minute) by default, adjustable with .Undo(command, 20.Times) and impossible to disable. The outcome command insists the same way. | Implemented |
| A step given up on without knowing whether it ran is fenced by its undo (see below). A domain rejection of an undo is never taken as proof that there was nothing to undo. Requirement: an undo is handled by the same service (inbox) as its step. | Implemented |
Fencing exists because a process that gives up on a step cannot know whether the step ran: a delayed copy may still execute later. The undo carries the id of the original command, and the owner decides atomically with its inbox.
gave up on ChargePayment (m1), outcome unknown
send Undo(original = m1) to the same owner
owner, in one transaction with its inbox:
m1 ran? yes ──► undo it
no ──► fence m1: a late copy never executes
a domain rejection of the undo is NOT proof that nothing ranWaits and time
| Rule | Status |
|---|---|
Await<TEvent>(key) waits for a fact correlated by key. An event that arrives early is parked until the process gets there. | Implemented |
Time limits are business decisions: .Within(duration) on a wait ("the customer has 15 minutes to confirm"). When it expires, the process compensates on purpose. A wait without .Within is reported (WYRD0012). | Implemented |
Slowness is not failure. There are no technical timeouts on steps: durable messaging waits, and a slow third party only makes processes wait. Latency is observed (age of pending steps, alerts), never turned into a failure. A business deadline on a step is modelled as an explicit wait for its confirmation with .Within.Today: steps have no timeouts. On the EF Core host the queue's health (lag, the age of pending steps, suspended instances) is published on the Wyrd meter; a call to the outside world is made with no transaction open. | Implemented |
When Wyrd cannot resolve something: suspend
An instance is suspended, needing attention when:
- a step's retries are exhausted (a failure Wyrd cannot classify: an outage or a bug; suspending never cancels an order because of a bug, and a process may opt into compensating automatically instead with
.Retry(3.Times).OrCompensate()), - an undo's retries are exhausted,
- the outcome command fails after its retries,
- its definition's structure changed.
Suspending does not make the world consistent: the instance stays half-way. It makes the situation visible (status, reason, alert) and recoverable: an operator resumes it after a fix, or compensates it. Nothing is lost and nothing stays stuck in silence. ResumeAsync re-sends what the instance was doing with the same message id, so a command that did run is not run twice. CompensateAsync undoes a forward-suspended instance and fences the step it gave up on. A late success of the pending command resumes the instance on its own. Implemented A changed process structure suspends an instance the same way. Every host, in memory and on EF Core, offers these operators.
Statuses
| Status | Meaning |
|---|---|
Running | Sending a step or compensating. |
Awaiting | Waiting for an outside fact. |
Completed | Every step done. |
Compensated | Undone, and the outcome command ran. |
Suspended | Needs attention, with a reason (ReasonOf); it can be resumed or compensated. It replaced the earlier Failed. |
Suspended carries its reason (ReasonOf) and replaced the earlier Failed status.
┌───────────── fact arrives ─────────────┐
▼ │
trigger ──► Running ──► Awaiting ───────────────────┘
│ │
│ └── .Within expires ──┐
▼ ▼
Completed compensating (Running)
│
▼
Compensated
retries exhausted, undo exhausted,
outcome failed, definition changed ──► Suspended
(a late success also resumes it) │ ResumeAsync or CompensateAsyncDelivery
| Rule | Status |
|---|---|
| Every message is handled in one transaction: state, events, instances, inbox, outbox, timers and read models commit together or not at all. Today: in memory and on the EF Core host (PostgreSQL, SQL Server), where the transaction also takes the message off the queue. | Implemented |
| Delivery is at least once; decisions are effectively once: the owner's inbox remembers every command it decided and replays the outcome to duplicates. | Implemented |
| Integration events reach each subscriber once, in the contract version it understands: older versions are upcast step by step, and a newer version is never downcast. | Implemented |
Read models
| Rule | Status |
|---|---|
| A projection updates a view from events with pure functions, in the transaction that handles the event. The same message is never applied twice. | Implemented |
| Events of one stream (one state key) reach a projection in the order they happened, whatever the delivery order, so views do not defend against reordering by hand. Each domain event carries its stream and version, and one that arrives early is held until the gap is filled. Processes do not wait for read models. | Implemented |
| Across streams there is no order (two orders, or an order and its payment when they are different states), and integration events from outside carry no stream. A view that combines several streams still tolerates reordering between them. | Conscious limit |
Conscious limits
Each of these is a choice with a stated reason and a way out, not an oversight.
| Limit | Why | Way out |
|---|---|---|
| One event per decision | Keeps decisions simple and explicit. | Model it as a union of events. |
| Linear processes | Readable, simulable and versionable. | Another process for the alternative path. |
| Snapshot state | Simple storage and fast reads. | Event sourcing is a possible future option. |
| A new projection starts from current states | There is no event log to replay; only what a state says can be rebuilt. | Keep what a future view needs in the state, or publish it as an event a view already counts. |