Workflows
A status column that anything can set to anything is not a status. It is a text field with suggestions. A workflow turns one select field into a process the server enforces, declared in the same place the field is.
What a status field is without one
Generate an order with a status select and the admin shows a dropdown with every value on every record. Any client that can update an order can send this:
PATCH /api/v1/orders/8f2a{ "status": "delivered" }
On an unpaid order. Nothing stops it. A delivered order can go back to pending, an unpaid one can be marked shipped, and the only thing between your fulfilment process and nonsense is that people mostly click the right option. That is the default behaviour of a status dropdown in every CRUD admin, Grit included, until you declare the process.
Declaring it
A workflow is a block on the select field itself. That needs more structure than a command-line flag, so the resource goes in a YAML file:
- name: statustype: selectoptions:- { value: pending, label: Pending payment }- { value: paid, label: Paid }- { value: packed, label: Packed }- { value: shipped, label: Shipped }- { value: delivered, label: Delivered }- { value: cancelled, label: Cancelled }workflow:initial: pendingterminal: [delivered, cancelled]transitions:- action: mark_paidfrom: [pending]to: paid- action: packfrom: [paid]to: packedpermission: orders.fulfil- action: shipfrom: [packed]to: shippedpermission: orders.fulfil- action: deliverfrom: [shipped]to: delivered- action: cancelfrom: [pending, paid, packed]to: cancelledconfirm: true
grit g resource Order --from order.yaml --forcegrit migrate
The shape of it
Three decisions worth understanding
States come from the field's own options
They are not listed twice. If they were, the dropdown and the state machine would drift, and you would get a transition to a state the UI never offers, or an option nothing can reach.
It is a directed graph, not a ladder
This is the one people get wrong. paid back to pending is illegal only because you did not declare it. Add a transition and it becomes legal:
- action: reopenfrom: [paid]to: pending
Only three things are actually enforced:
- Nothing leaves a terminal state. Whatever you list under
terminalis an end. - An empty
frommeans from anywhere. Useful for something likearchivethat applies whatever the current state is. - Generation refuses a dead end. A non-terminal state with no way out is rejected when you generate, rather than discovered in production when an order lands in it and nobody can move it.
Permission sits on the transition, not the resource
pack and ship require orders.fulfil. deliver does not. Warehouse staff advance fulfilment without being able to refund, which is not expressible if permission is a property of the whole resource.
What it generates
Four things, none of which you write.
1. A guarded service method
The check runs in the service, so a warehouse account calling ship without the permission gets a 403 from the server. Not a hidden button: hiding a button is a UI preference, and the request still works if somebody sends it by hand.
2. Per-action endpoints, and no general status write
POST /api/v1/orders/:id/transitions/mark_paidPOST /api/v1/orders/:id/transitions/packPOST /api/v1/orders/:id/transitions/cancelGET /api/v1/orders/workflow the definition, for a client that draws it
This is the core of the whole feature. There is no endpoint that sets status to an arbitrary value, so an illegal jump is not rejected. It is unrepresentable.
An action that exists but is not legal from here returns a 422 saying what state the record is in and which actions are available from it, so a client can show the next step without a second request.
GET /workflow returns the definition itself, which is what lets a client draw only the legal transitions as buttons instead of a dropdown of everything.
3. A domain event per transition
Each transition emits <resource>.<action>, so orders.mark_paid, on the shared bus. Audit, webhooks and realtime are already subscribers, and so is anything you add:
events.On("orders.mark_paid", events.Async, "send-confirmation", sendOrderConfirmation)
The transition is its own event rather than a generic update, because a subscriber that cares about orders being paid should not have to diff two versions of a record to work out that is what happened. It is also how a confirmation email gets sent without a line of email code in the checkout handler.
4. confirm: true in the admin
Marks the action as needing a confirmation step, because cancel is not undoable and a misclick on a table row is easy.
The trap those events create
Read this before adding a back-edge. Say your orders.mark_paid subscriber sends the customer confirmation email, and you add a reopen transition from paid to pending because a payment sometimes needs redoing.
Now paid → pending → paid sends that email twice. The state machine is behaving exactly as declared. The mistake is reversing into a state whose entry has a side effect.
Use a compensating state instead. A refunded or payment_failed state, with its own transitions and its own event, says what actually happened rather than pretending the order went back in time.
Doing the rest of the work: transition hooks
A transition in a real process is rarely only a status change. Approving a purchase request draws down the department's budget and reserves the stock, and if either cannot happen the approval should not happen either. A subscriber is too late for that: by the time it runs the approval has committed, and it cannot refuse it.
A hook runs inside the transition's own transaction, after the status has changed and before anything commits. What it writes through tx commits or rolls back with the move. Return workflow.Refuse(...) and the caller gets a 422 with your message; any other error is a 500. Either way the move is undone.
func init() {workflow.OnTransition("purchase_requests", "approve", drawDownBudget)workflow.OnTransition("purchase_requests", "approve", reserveStock)}func drawDownBudget(tx *gorm.DB, m workflow.Move) error {pr := m.Record.(*models.PurchaseRequest)res := tx.Model(&models.Budget{}).Where("department = ? AND remaining >= ?", pr.Department, pr.Total).Update("remaining", gorm.Expr("remaining - ?", pr.Total))if res.Error != nil {return res.Error}if res.RowsAffected == 0 {return workflow.Refuse("the %s budget cannot cover %d", pr.Department, pr.Total)}return nil}
If reserveStock then refuses, the budget draw-down rolls back with the approval. Two approvals of one request never both run their hooks: the status update is conditional on the state it read, so the second affects no rows and stops before them. Measured on Postgres: ten simultaneous approvals of 100 against a budget of 600 approved six, refused four, and left the budget at exactly zero; twenty simultaneous approvals of one request drew it down once.
Subscribers that must not miss an event
Sync and Async subscribers live in the process. An Async event still queued is gone if the process stops, dropped if the queue is full, and not retried if the subscriber fails. That is right for a realtime push and wrong for "record this spend in the HR ledger". For those, subscribe Durable:
func init() {events.OnDurable("purchase_requests.fulfill", "record-spend", recordSpend)}func recordSpend(tx *gorm.DB, e events.Event) error {var pr models.PurchaseRequestif err := e.DecodeAfter(&pr); err != nil {return err}// At least once: skip it if this request is already in the ledger....}
- The event is written to
outbox_messagesin the transition's transaction, so it reaches the subscriber if and only if the move commits. - A relay started in
routes.godelivers it, retrying with backoff until the subscriber returns nil and parking it as failed after twelve attempts. A delivery survives a restart, and another replica takes it over if this one dies holding it. OnDurableruns the subscriber in its own transaction, so a failed attempt leaves nothing half written. Delivery is at least once, so make it idempotent.- Events from generated create, update and delete handlers reach Durable subscribers too, written straight after their commit.
events.On and OnDurable can be called from an init() function. Before v3.221.0 a subscription made before the bus started was silently dropped.
What is not built yet
The admin does not render workflow state as a badge with the legal transitions as buttons. Today you get the generated dropdown plus the transition endpoints, and a custom cell is how to draw it properly. The definition endpoint exists so that component can be written without hardcoding the graph.
