Backend

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:

order.yaml
- name: status
type: select
options:
- { 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: pending
terminal: [delivered, cancelled]
transitions:
- action: mark_paid
from: [pending]
to: paid
- action: pack
from: [paid]
to: packed
permission: orders.fulfil
- action: ship
from: [packed]
to: shipped
permission: orders.fulfil
- action: deliver
from: [shipped]
to: delivered
- action: cancel
from: [pending, paid, packed]
to: cancelled
confirm: true
grit g resource Order --from order.yaml --force
grit migrate

The shape of it

pending
initial
paid
mark_paid
packed
pack · orders.fulfil
ship · orders.fulfil
shipped
deliver
delivered
terminal
cancelled
terminal · from pending, paid, packed

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: reopen
from: [paid]
to: pending

Only three things are actually enforced:

  • Nothing leaves a terminal state. Whatever you list under terminal is an end.
  • An empty from means from anywhere. Useful for something like archive that 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_paid
POST /api/v1/orders/:id/transitions/pack
POST /api/v1/orders/:id/transitions/cancel
GET /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.

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.