Design
Design decisions
The main choices behind Wyrd, each as a problem, a decision, what it gives you and what it costs, so you can judge whether they fit your system.
Commands, domain events and integration events are different things
Implemented
Problem. Teams subscribe to another service's internal events. The owner renames a field and production breaks somewhere else. Intentions, internal facts and public promises get the same type, so nothing tells you which can change.
Decision. Three kinds of message, enforced by types and analyzers. A command is an intention with exactly one owner; it is sent and can be rejected. A domain event is a private fact inside one context. An integration event is a public, versioned contract, produced only through an explicit publication mapping.
What it gives you. You can refactor domain events freely, because only the publication mapping has to keep producing a valid contract. Nobody couples to another context's internals, and the contract catalog can be generated as AsyncAPI.
What it costs. More types and a mapping to write for each public event. For a small application with one deployable, that ceremony may add more than it returns.
One owner per command
Implemented
Problem. If two handlers can answer one intention, nobody owns the rule, and the result depends on which handler happens to run.
Decision. A command has exactly one owning slice. The runtime rejects a second owner, and analyzer WYRD0002 reports it at compile time.
What it gives you. A single place to read, test and change each decision, and a precise target for retries and the inbox.
What it costs. Fan-out is not done with commands. If several parties must react to a fact, that is an event, and each reaction is its own command.
Pure decisions, framework-owned effects
Implementedintegrations are the only I/O, on every host
Problem. A decision that also calls a payment provider cannot know whether a timeout happened before or after the provider accepted the request. Its result then depends on more than its inputs, so it cannot be reproduced, simulated or replayed.
Decision. Deciders and process steps are pure functions. They return facts or intentions. Wyrd owns delivery, persistence, deduplication, retries, timers and compensation, and there is no API that lets user code publish, persist or schedule directly.
What it gives you. Decisions can be tested without a database or broker, the same code runs in tests and in simulation, and a failing run can be replayed exactly.
What it costs. You cannot call an external API from a decision. An external call has its own narrow shape, an integration, with an idempotency key and a recorded outcome, and a call that never answered is undone on compensation in case it acted.
Retries are explicit, per step, with no default
Implemented
Problem. A default retry count is a hidden business decision. Retrying a payment is safe only if the owner deduplicates; retrying a booking may not be. Skuld also showed what a borrowed policy costs: an undo that inherited its step's lack of retries lost stock after a single transient failure.
Decision. Every step declares its policy: .Retry(attempts, backoff) or .NoRetry("why"). There is no default, and an analyzer (WYRD0014) reports a step that declares neither. Retries re-send the same message id, so the owner's inbox makes them safe.
.Then(Stock.Reserve).Retry(3.Times).Undo(Stock.Release)
.Then(Payments.Charge).Retry(3.Times, Backoff.Exponential).Undo(Payments.Refund, 20.Times)
.Then(Shipping.Ship).NoRetry("a human books the carrier; a second attempt would double-book")Why undos always insist. An undo is the last thing between a half-done process and an inconsistent world, so its 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. An undo does not inherit the step's policy; a step that must not be retried can still have an undo that persists.
What it gives you. Reviewers see the retry decision for every step, and a missing one is a build warning instead of a surprise in production.
What it costs. One more line per step, and you have to be able to say why a step is safe to retry (or not).
One retry layer
Implementedin memory and on the EF Core host
Problem. When a process retries three times and the transport also retries three times, one failure becomes nine attempts that nobody configured and nobody can explain.
Decision. A failure of a process command reaches the process as a failure, and the step's policy is the only decision. Hosts do not multiply attempts behind the process's back; a command that cannot be delivered or handled ends as a failure reply. A business rejection is never retried, because a pure decision would answer the same.
What it gives you. The number of attempts you read in the process is the number that happens, and simulation and production agree.
What it costs. Durable hosts are built to this rule (a command they cannot handle reaches its process as a failure, never a silent dead letter), and you cannot lean on broker-level retries to hide a missing step policy.
Suspend instead of failing silently
Implementedin the in-memory host
Problem. When retries run out, there are two bad options: cancel the order (even though the cause may be an outage or a bug in your code) or leave the instance stuck where nobody looks.
Decision. An instance whose step retries are exhausted, whose undo retries are exhausted, whose outcome command fails, or whose definition changed becomes Suspended with a reason. Operators use ResumeAsync or CompensateAsync, a late success resumes the instance on its own, and .Retry(3.Times).OrCompensate() opts a step into compensating automatically instead.
What it gives you. Nothing is lost and nothing stays stuck in silence: the situation is visible (status, reason, alert) and recoverable.
What it costs. Suspending does not make the world consistent; the instance stays half-way until someone acts. Someone has to watch for suspended instances.
Fencing for unknown outcomes
Implemented
Problem. When a process gives up on a step it cannot know whether the step ran. A delayed copy of the command may still execute later and leave, for example, stock reserved forever. Guessing either way is wrong, and a domain rejection of the undo ("not reserved") only describes the current state.
Decision. The undo carries the id of the original command. The owner decides in the same transaction as its inbox: if the original ran, the undo runs; if it never ran, the original is fenced so any late copy finds the fence and never executes.
What it gives you. The process can give up and compensate without knowing, and the world still ends consistent. Skuld found this in Wyrd's own sample shop.
What it costs. An undo must be handled by the same service (the same inbox) as its step.
Time limits are business decisions only
Implementedslow steps are observed on the EF Core host's Wyrd meter
Problem. Technical timeouts turn a slow answer into a failure. A payment provider that takes 40 seconds then triggers compensation of an order that would have succeeded.
Decision. Steps have no technical timeouts. Durable messaging waits, and a slow third party only makes a process wait. A deadline is a business choice, modelled as an explicit wait with .Within(duration), and when it expires the process compensates on purpose. A wait without .Within is reported by WYRD0012. Latency is observed, never turned into a failure.
What it gives you. Every compensation has a business reason you can read in the process.
What it costs. A step that never answers waits forever unless you model a confirmation with .Within. The EF Core host publishes the age of the oldest pending step, so an alert can see it.
A structure fingerprint per instance
Implementedmigrating in-flight instances is planned for v1.0
Problem. Long-running instances outlive deployments. If someone inserts a step, an instance stored at step 3 would continue at what is now a different step 3.
Decision. Each instance records a fingerprint of the structure it started with: the steps, their order and the waits. Configuration such as retries is not part of it. If the deployed structure differs, the instance is suspended ("definition changed"), keeps what it receives meanwhile, and can be resumed once the original definition is deployed again.
What it gives you. You can change retry counts and backoff freely, and a structural change cannot silently corrupt running instances.
What it costs. Until process versioning exists, a structural change suspends in-flight instances and someone has to deal with them.
Processes are linear
Conscious limit
Problem. Branching makes processes hard to read, simulate and version.
Decision. A process is a trigger, steps and waits in order, with an optional outcome when compensated. An alternative path is another process.
What it gives you. A process reads top to bottom, draws as one diagram and can be simulated and versioned.
What it costs. Some workflows need more than one process, which you then connect through events.
Snapshot state, not event sourcing
Conscious limit
Problem. Rebuilding state from the full history on every command is a bigger commitment (storage, versioning of old events, replay cost) than many teams need.
Decision. State is stored as a snapshot and events are kept as history. State is not rebuilt from them, so changing Evolve does not change states already stored.
What it gives you. Simple storage and fast reads. Using events does not require event sourcing.
What it costs. Fixing a bug in Evolve does not repair existing states. Event sourcing is a possible future option and is listed as an open decision.
One event per decision
Conscious limit
Problem. A decision that returns several events makes it unclear which fact each part of the system reacts to.
Decision. A decision produces at most one event. Two facts are modelled as one event, or a union of events.
What it gives you. Simple, explicit decisions, and a clear mapping from a command to the single fact it caused.
What it costs. Some decisions need a union type to express more than one outcome.
At-least-once delivery, effectively-once decisions, one transaction per message
Implemented
Problem. Networks deliver messages twice. If state and outgoing messages are saved separately, a crash loses one of them.
Decision. Every message is handled in one transaction: state, events, instances, inbox, outbox, timers and read models commit together. Delivery is at least once, and the owner's inbox remembers each command it decided and replays the outcome to duplicates.
What it gives you. A duplicate never decides twice, and a crash never leaves a state change without its messages.
What it costs. Isolation between processes is not guaranteed: others may see intermediate states, such as reserved stock. That is the usual saga trade-off.
Source-generated JSON for Native AOT
Implemented
Problem. Messages and states cross the queue and the store as JSON. Resolving types by name at runtime with reflection breaks trimming and Native AOT.
Decision. No runtime code generation and no Type.GetType. Types are resolved by registered name and serialized with the app's [WyrdJsonContext]. Reflection lives only behind explicit entry points for development.
[WyrdJsonContext]
[JsonSerializable(typeof(ChargePayment))]
[JsonSerializable(typeof(Payment))] // unions are stored polymorphically
public partial class ShopJson : JsonSerializerContext;
var shop = new InMemoryRuntime(ShopJson.Default);What it gives you. The sample shop publishes and runs as a native executable, checked in CI, and analyzers (WYRD0300 to WYRD0302) keep the context complete.
What it costs. You register your message and state types in a JsonSerializerContext.
Versioned contracts with snapshots
Implemented
Problem. A public event changes shape and subscribers fail to read it, usually discovered in production.
Decision. Every integration event schema is snapshotted in contracts.wyrd.txt and reviewed in pull requests. A breaking change (a removed or renamed field, a changed type) fails the build with WYRD0201. Evolution is a new version plus an upcaster, so each subscriber receives a fact once, in the version it understands.
What it gives you. Breaking a public contract by accident is not possible, and the contract catalog is documented for free.
What it costs. The check is structural. It cannot see a change in what a field means to the business; that stays in the conversation and the review.