Summary#
Many entities have a status that moves through states in a fixed order. An order goes from placed to paid to shipped to delivered, and may be cancelled or lapse on the way. Two shapes handle this well, and both let you say which moves are legal in one place:
- A workflow that tracks the field.
Tracks = Order.Status;hands the field to the workflow. Each state lists the events it accepts and where each one leads. Nothing else may write the field, a state can carry a deadline, andOrderLife.For(order).Transitionsgives you the legal next moves for buttons. Use this when a step waits on a person or a clock. - One function per move.
Ship(parcel, tracking)andDeliver(parcel)each check the current state, then assign the field themselves.deny update Status except via Ship, Deliver;names every function that may change it. Use this for a short, immediate lifecycle with nothing to wait for.
Avoid the third shape: one Advance(string orderId, string to, …) that takes the target state as text, looks it up by
name, and takes the arguments of every move. Its callers fill the arguments they don't need with "", and a misspelt
state name either fails or, if the lookup falls back to a member, moves the row somewhere real.
Signature#
workflow <Entity>Life {
Tracks = <Entity>.<StatusField>;
Autostart = true;
Initial = <FirstState>;
event <Move>(<what the move needs>);
state <State> { subscribe <Move>(…); on <Move> { goto <NextState>; } }
terminal success <End> { }
}
// …or, with nothing to wait for:
deny update <StatusField> except via <Move1>, <Move2>;
[Server, Authorize(<policy>)] void <Move1>(<Entity> row, <what it needs>) { …check…; row.<StatusField> = <Enum>.<Next>; }Description#
Which shape do I want?#
| the lifecycle | write |
|---|---|
| a step waits on a person, a payment, a reply, or a time limit ("lapses after 48 hours") | a workflow tracking the field |
| a screen should show only the moves this row can make now | a workflow: <Wf>.For(row).Transitions lists them |
| every move happens at once, in one request, with no waiting | one function per move, named in except via |
| the moves share a rule ("only from Packed") | a helper that checks and returns a reason, called by each move. A helper may not write the field: except via excepts only the named function's own assignments |
Both shapes are enforced on the server. A workflow's field is written only by its transitions. With except via, the
field is written only by the functions the rule names, so a browser, an import, or any other function trying to set
Status is refused.
Why not one function that takes the state as a string?#
It looks economical, but it moves three jobs into the caller:
- The move is chosen by text.
Advance(id, "Shiped", …)compiles. A lookup that falls back to a member, such asreturn OrderState.Cancelled;after a loop over the names, turns the typo into a real state. UseEnum.Parse<OrderState>(text), which refuses an unknown name (see Enum.Parse<T> and Enum.TryParse<T> — text into a member). Better, take anOrderStateparameter, or write one function per move, so a wrong name cannot be written. - The signature can't say what each move needs. Shipping needs a carrier and a tracking number, a refund needs an
amount, and cancelling needs neither. One signature for all of them means every call passes
""and0for the rest, and nothing tells the caller which blanks matter. - The legal-move table is written by hand.
(from == Paid && to == Accepted) || …is the graph a workflow already has, without its deadlines orTransitions.
The linter reports each of these: correctness-enum-name-with-a-silent-default,
correctness-call-padded-for-another-move, and, as a suggestion, workflow-lifecycle-written-by-hand.
"Only one function may change the status"#
If you want the database to enforce the status, you don't need a single function to do it. except via accepts
several names, and the rule is just as tight with many: deny update Status except via Pay, Ship, Deliver, Cancel;
lists every way the field changes, where a reader of the entity will see it. Each named function must make the
assignment itself, so the compiler refuses a name whose helper does the writing. The refusal names the helper and
shows this shape.
Examples#
A workflow that owns the order's status#
using Osysharp.Workflow;
enum OrderStatus { Placed, Paid, Shipped, Delivered, Lapsed, Cancelled }
entity Order {
[Required, MaxLength(40)] string Reference;
OrderStatus Status; // no default: the workflow supplies it
[MaxLength(80)] string? Tracking;
security { allow read, create, update when IsAuthenticated || IsAnonymous; }
}
workflow OrderLife {
Tracks = Order.Status;
Autostart = true;
Initial = Placed;
event Pay();
event Ship(string tracking);
event Deliver();
event Cancel();
state Placed {
Expire = TimeSpan.FromDays(2); // unpaid for two days: the clock moves it
subscribe Pay();
subscribe Cancel();
on Pay { goto Paid; }
on Cancel { goto Cancelled; }
on Expire { goto Lapsed; }
}
state Paid {
subscribe Ship(string tracking);
on Ship(string tracking) {
this.Item.Tracking = tracking;
goto Shipped;
}
}
state Shipped {
subscribe Deliver();
on Deliver { goto Delivered; }
}
terminal success Delivered { }
terminal cancel Lapsed { Message = "not paid in time"; }
terminal cancel Cancelled { Message = "cancelled before payment"; }
}
/// The buttons a screen shows: only the moves this order can make now.
List<string> NextMoves(Order order) =>
OrderLife.For(order).Transitions.Where(t => t.Allowed).Select(t => t.Event).ToList();
[Test]
void An_order_moves_only_along_its_workflow() {
var order = new Order { Reference = "A-1" };
UnitOfWork.Commit();
Assert.Equal(OrderStatus.Placed, order.Status);
Assert.False(NextMoves(order).Contains("Ship")); // not offered before it is paid
OrderLife.RaisePay(order);
Workflow.Settle(order);
Assert.True(NextMoves(order).Contains("Ship"));
OrderLife.RaiseShip(order, "TRK-9");
Workflow.Settle(order);
Assert.Equal(OrderStatus.Shipped, order.Status);
Assert.Equal("TRK-9", order.Tracking);
}
[Test]
void An_unpaid_order_lapses_on_its_own() {
var order = new Order { Reference = "A-2" };
UnitOfWork.Commit();
TestClock.Advance(TimeSpan.FromDays(3));
Workflow.Settle(order);
Assert.Equal(OrderStatus.Lapsed, order.Status);
}One function per move, when nothing waits#
enum ParcelStatus { Packed, Shipped, Delivered, SentBack }
[Principal] entity Clerk {
[Unique, MaxLength(80)] string Name;
security { allow read when IsAuthenticated; }
}
entity Parcel {
[Required, MaxLength(40)] string Reference;
ParcelStatus Status = ParcelStatus.Packed;
[MaxLength(80)] string Tracking = "";
security {
allow read, create, update when IsAuthenticated;
deny update Status except via Ship, Deliver, SendBack; // every way the status changes, in one place
}
}
/// The rule the moves share. It only checks and never writes, so it needs no exception of its own.
string NotIn(Parcel parcel, ParcelStatus expected) =>
parcel.Status == expected ? "" : $"{parcel.Reference} is {parcel.Status.Label}, not {expected.Label}.";
/// Each move takes only what it needs and assigns the status itself.
[Server, Authorize(IsAuthenticated)]
void Ship(Parcel parcel, string tracking) {
var why = NotIn(parcel, ParcelStatus.Packed);
if (why != "") { throw new Exception(why); }
parcel.Tracking = tracking;
parcel.Status = ParcelStatus.Shipped;
}
[Server, Authorize(IsAuthenticated)]
void Deliver(Parcel parcel) {
var why = NotIn(parcel, ParcelStatus.Shipped);
if (why != "") { throw new Exception(why); }
parcel.Status = ParcelStatus.Delivered;
}
[Server, Authorize(IsAuthenticated)]
void SendBack(Parcel parcel) {
var why = NotIn(parcel, ParcelStatus.Shipped);
if (why != "") { throw new Exception(why); }
parcel.Status = ParcelStatus.SentBack;
}
/// Not one of the moves, so its write is refused.
void Shortcut(Parcel parcel) { parcel.Status = ParcelStatus.Delivered; }
[TestFixture]
void Depot() {
new Clerk { Name = "Ida" };
new Parcel { Reference = "P-1" };
}
principal Ida => Clerk.Single(c => c.Name == "Ida");
[Test(Depot)]
[runas(Ida)]
void A_parcel_changes_status_only_through_its_moves() {
var parcel = Parcel.Single(p => p.Reference == "P-1");
Assert.Denied(() => Shortcut(parcel));
Ship(parcel, "TRK-1");
Deliver(parcel);
UnitOfWork.Commit();
Assert.Equal(ParcelStatus.Delivered, Parcel.Single(p => p.Reference == "P-1").Status);
}
[Test(Depot)]
[runas(Ida)]
void A_move_from_the_wrong_state_is_refused() {
var parcel = Parcel.Single(p => p.Reference == "P-1");
Assert.Throws(() => Deliver(parcel)); // not shipped yet
}The shop kit's OrderLifecycle (osy source OrderLifecycle) is a full-size example of the workflow shape: payment
by webhook, a hold that lapses, picking, packing and refunds, all on one tracked Order.Status.
See also#
- Tracks and Initial (the field a workflow drives) —
TracksandInitial: handing the field to the workflow - Transitions — where this item may go next — the moves a row can make now, for a screen's buttons
- Raising a workflow event — making a move from code
- deny … except via — naming the functions that may change a guarded field
- Enum.Parse<T> and Enum.TryParse<T> — text into a member — turning text into a member, refusing an unknown name
- Workflows (the run that outlives the request) — what a workflow is, and the first-day mistakes