Effects
A machine's one consequential transition (send a letter, charge a card, provision a resource) binds an
ISnapshotEffect. Trax runs it exactly once when that transition is sent, records its receipt in the
snapshot, and never runs it twice, even across a crash and retry.
public interface ISnapshotEffect
{
Task<string> Run(Snapshot snapshot, CancellationToken cancellationToken = default);
}Run performs the side effect and returns a receipt, the downstream id (a message-log id, a charge id) that
proves it happened. Throwing means the effect did not complete: the claim is released, the transition is not
applied, and the client can retry from the same state.
An OperationCanceledException is different: a charge can land and its response time out, so a cancellation
does not say whether the effect happened. The claim stays in flight until its lease passes, and a send before then
is refused as effect-in-progress instead of running the effect again. The send reports it as delivery-failed
unless the request itself was cancelled. The send passes Run CancellationToken.None, not the request's token:
a client that disconnects mid-charge does not cancel the charge. Bound a slow downstream call with its own
timeout, and never after the irreversible step.
Binding it
Bind the effect inline on the transition with RunsOnce<TEffect>,
and implement it in the host:
m.In(CheckoutState.Review)
.On(CheckoutTrigger.Pay)
.When(Field((ReviewContext c) => c.Items).CountAtLeast(1))
.RunsOnce<ICharge>() // keyPrefix defaults to "checkout:Pay"
.Reduce(Set((PaidContext p) => p.Receipt).FromInput((PayInput i) => i.Receipt))
.To(CheckoutState.Paid);public interface ICharge : ISnapshotEffect;
public sealed class StripeCharge(IPaymentGateway gateway) : ICharge
{
public async Task<string> Run(Snapshot snapshot, CancellationToken ct)
{
var chargeId = await gateway.Charge(snapshot.Context);
return chargeId; // becomes the receipt
}
}Register the implementation like any service; the machine resolves TEffect from DI, so nothing is wired in
the composition root by hand:
services.AddScoped<ICharge, StripeCharge>();Exactly-once and the receipt
The effect runs through the persistence layer's idempotent path: a claim is taken before the effect, held
under a lease with a fence token, and a crash mid-flight replays without re-running a completed effect. The
key is {keyPrefix}:{userKey}:{id}, so it is scoped per draft per user. A draft deleted by the draft TTL releases
the key, and so does a reset to the initial state once the effect's outcome is settled on the draft: a reset while
the effect runs, or after a receipt that never reached the draft, keeps the claim, and the next send replays its
receipt. See what each path may write.
Once the effect has returned, its receipt is recorded and the draft advanced on a token the request cannot
cancel. A client that disconnects right after a charge still leaves the draft showing the charge, and the next
send replays it instead of charging again. The receipt is recorded only on the draft exactly as the effect loaded
it: if the draft was saved, reset or advanced while the effect ran, the send reports conflict and records
nothing, and the claim keeps the receipt.
The claim also records a fingerprint of the content the effect ran on: the SHA-256 of the draft's canonical wire,
SnapshotFingerprint.Of(service.Serialize(snapshot)). A later send replays the receipt only onto a draft whose
content has that fingerprint. If the draft now holds anything else, the send is refused as draft-changed: the
receipt is not recorded and the effect does not run again. Restoring the content the effect ran on, for example by
autosaving the snapshot the client sent, makes the next send replay the receipt, so a client keeps that snapshot
until its send settles. A claim recorded before the fingerprint existed has none and replays onto whatever the
draft holds.
The transition itself is fired only by the send. An advance of its trigger is refused as effect-bound, and an
autosave cannot put a draft into its destination state; see
what each path may write.
The receipt Run returns is handed to the transition's reducer as input["receipt"], which is how the send
gets recorded in the destination context. It must be non-empty. The ledger reads a claim with no receipt as
still in flight, so a null or "" receipt is treated as a failed effect: the claim is released,
IdempotentEffect.RunOnce throws InvalidOperationException, and the send returns delivery-failed with the
draft unchanged, exactly as if Run had thrown. A guard on the same edge can require it (so the transition only
completes once the effect has produced a receipt).
Cancelling and starting over
A draft is not deleted by the client. There is no delete mutation: a draft that is abandoned is left to the draft TTL, and what a cancellation means is declared in the machine, because only the domain knows what it undoes. What to do depends on where the draft is relative to its effect.
Before the effect runs, nothing irreversible has happened. To start over, save a new draft under a new id: an effect's claim is keyed by the draft id, so the new draft starts clean and the old one expires. A machine can also declare a reset edge back to its initial state.
While the effect runs, leave the draft alone until the send settles: show the user it is processing and do not
autosave. An edit in that window is what makes a send report conflict or draft-changed, and the recovery is to
restore the snapshot that was sent, as described above.
After the effect ran, the draft in its committed state is the record that it happened, and it stays as it is. Undoing the effect is a second irreversible action (a refund, a void, releasing stock), not a delete. A machine runs exactly one effect, so the compensation is a machine of its own whose one effect is the undo, started from the receipt the first one recorded:
public sealed class RefundMachine : Machine<RefundState, RefundTrigger>
{
protected override void Configure(IMachineBuilder<RefundState, RefundTrigger> m)
{
m.Id("refund").Version(1).StartsAt(RefundState.Requested, () => new JsonObject());
m.In(RefundState.Requested)
.On(RefundTrigger.Refund)
.RunsOnce<IRefund>()
.Reduce((context, input) => new JsonObject
{
["chargeId"] = context["chargeId"]?.DeepClone(),
["refundId"] = input?["receipt"]?.DeepClone(),
})
.To(RefundState.Refunded);
m.In(RefundState.Refunded).Committed();
}
}
public interface IRefund : ISnapshotEffect;
public sealed class StripeRefund(IPaymentGateway gateway) : IRefund
{
public async Task<string> Run(Snapshot snapshot, CancellationToken ct)
{
var chargeId = snapshot.Context["chargeId"]?.GetValue<string>()
?? throw new InvalidOperationException("A refund names the charge it refunds.");
return await gateway.Refund(chargeId, idempotencyKey: $"refund:{chargeId}");
}
}The client autosaves a refund draft whose context holds the checkout's receipt as chargeId, then sends
Refund. A refund draft with no charge throws, so the effect has not run and the send can be retried. Trax runs the
refund once per refund draft. Two refund drafts for one charge are two drafts, so pass the downstream service an
idempotency key derived from the charge, as above, to make the refund once per charge. The checkout draft keeps
showing the charge, and the refund draft shows the refund; together they are the history a support request needs.