12.5 The machine
Shopfront.Core/Machines/Checkout/CheckoutMachine.cs:
using System.Text.Json.Nodes;
using Shopfront.Auth;
using Shopfront.Machines.Checkout.Models;
using Trax.Effect.Attributes;
using Trax.StateMachine;
using Trax.StateMachine.Persistence;
using static Trax.StateMachine.Rules;
namespace Shopfront.Machines.Checkout;
[TraxMachine]
public sealed class CheckoutMachine : Machine<CheckoutState, CheckoutTrigger>
{
private static readonly Rule HasLines = Field((CheckoutContext c) => c.Lines).CountAtLeast(1);
private static readonly Lazy<string?> EmbeddedCorpus = new(() =>
{
using var stream = typeof(CheckoutMachine).Assembly.GetManifestResourceStream(
"machines/checkout/differential.json"
);
return stream is null ? null : new StreamReader(stream).ReadToEnd();
});
// The committed corpus, embedded in the library, so the machine can replay it and check itself.
public override string? Corpus => EmbeddedCorpus.Value;
protected override void Configure(IMachineBuilder<CheckoutState, CheckoutTrigger> m)
{
m.Id("checkout")
.Version(1)
.StartsAt(CheckoutState.Cart, () => new JsonObject())
.Start<CheckoutStart>(start => start.Reduce(Keep()).Requires(Roles.Customer))
.Get(read => read.OwnerOnly())
.Watch(read => read.OwnerOnly())
.Discard(read => read.OwnerOnly());
m.In(CheckoutState.Cart)
.Context<CheckoutContext>()
.After(TimeSpan.FromHours(2))
.GoTo(CheckoutState.Abandoned)
.On(CheckoutTrigger.SetLines)
.AnyAuthenticated()
.WithInput<LinesInput>()
.Reduce(Set((CheckoutContext c) => c.Lines).FromInput((LinesInput i) => i.Lines))
.To(CheckoutState.Cart)
.On(CheckoutTrigger.SetAddress)
.AnyAuthenticated()
.WithInput<AddressInput>()
.When(All(HasLines, Input((AddressInput i) => i.Address).NonEmpty()))
.Because("Put something in the cart and give an address to send it to.")
.Reduce(Set((CheckoutContext c) => c.Address).FromInput((AddressInput i) => i.Address))
.To(CheckoutState.Address);
m.In(CheckoutState.Address)
.Context<CheckoutContext>()
.On(CheckoutTrigger.Back)
.AnyAuthenticated()
.To(CheckoutState.Cart)
.On(CheckoutTrigger.ChoosePayment)
.AnyAuthenticated()
.WithInput<PaymentInput>()
.When(Input((PaymentInput i) => i.PaymentToken).NonEmpty())
.Because("Choose how to pay.")
.Reduce(
Set((CheckoutContext c) => c.PaymentToken).FromInput((PaymentInput i) => i.PaymentToken)
)
.To(CheckoutState.Payment);
m.In(CheckoutState.Payment)
.Context<CheckoutContext>()
.On(CheckoutTrigger.Back)
.AnyAuthenticated()
.To(CheckoutState.Address)
.On(CheckoutTrigger.PlaceOrder)
.Requires(Roles.Customer)
.To(CheckoutState.Placing);
m.In(CheckoutState.Placing).Context<CheckoutContext>();
m.In(CheckoutState.Abandoned)
.Context<CheckoutContext>()
.On(CheckoutTrigger.StartOver)
.AnyAuthenticated()
.Reduce(Reset())
.To(CheckoutState.Cart);
m.Differential(d =>
d.Sample(
CheckoutTrigger.SetLines,
new LinesInput { Lines = [new CartLine { Sku = "MUG", Quantity = 2 }] }
)
.Sample(CheckoutTrigger.SetAddress, new AddressInput { Address = "1 Main St" })
.Sample(CheckoutTrigger.SetAddress, new AddressInput())
.Sample(CheckoutTrigger.ChoosePayment, new PaymentInput { PaymentToken = "tok_visa" })
.EmptySample(CheckoutTrigger.ChoosePayment)
);
}
}From the top:
Id("checkout")names the machine. It names the GraphQL field, the stored instances and every file generated from it.Version(1)is stamped on every stored instance. A change that leaves stored instances unreadable, such as a context field renamed, bumps it, and says withMigrateFromhow to read the old ones.Start<CheckoutStart>says who may start a checkout: a customer. A started checkout belongs to whoever started it.Get,WatchandDiscardsay who may read it, follow it and throw it away: its owner and nobody else. The host refuses to serve a machine that leaves any of them, or any event, without a rule, and refuses one looser than the owner for the three reads.In(state).Context<T>()opens a state and says what its context holds.After(TimeSpan.FromHours(2)).GoTo(...)is a timer. A cart left alone for two hours moves toAbandoned. Every entry toCartrestarts it, so eachSetLinesbuys the customer another two hours. The server fires it, with or without anyone watching.On(trigger)opens an event, and what follows is its rule, its input, its guard, its reduction and its target:AnyAuthenticated()orRequires(Roles.Customer)is who may send it. Only placing the order needs the customer role. Every state a role-gated event enters is reserved, kept from being discarded or expiring, so the cart's own events need only a signed-in owner.When(rule).Because(text)is a guard: the event is refused, with that text, unless the rule holds. Rules are data,Field,Input,All, not code, so the server can export them.Reduce(Set(...).FromInput(...))writes part of the input into the context.Reset()empties it;Keep()leaves it as it is.
Differentiallists sample inputs the definition is checked with, andCorpushands the machine the answers to them, both below.
SetLines replaces the whole cart. There is no rule for "add one line": the browser keeps the cart
and sends it entire, which keeps the machine to rules the server can export and check, with no
code of its own.
SDK Reference
Machine | Rules | AddStateMachines | Machines over GraphQL | UsePersistedOperations | trax CLI
Next: 12.6 Serve it