Replacing the order status column with a state machine
Most e-commerce projects we inherit start with a `status` column on the orders table. It is a string, it holds values like `pending`, `paid`, `shipped`, `delivered`, `cancelled`, and every transition is a raw SQL update somewhere in the codebase. The first sign of trouble is usually a bug report: an order shows as `shipped` but has no tracking number, or a `cancelled` order got marked `delivered` by a cron job that ran late.
The problem is not the column. The problem is that the allowed transitions live nowhere. They are implicit in the scattered `UPDATE` statements and the developer's memory. When a new fulfilment step is added — say, a `ready_for_pickup` state for store pickups — someone has to find every place that touches status and reason about whether the new state fits. Most teams stop touching it. The column grows, the bugs grow, and the status graph stays in no one's head.
We replace it with an explicit state machine. The transitions are declared in one place, and every status change goes through that declaration.
Here is the shape we use, in Ruby with the `state_machines` gem, on a Rails project for a Noida apparel retailer:
class Order < ApplicationRecord
state_machine :status, initial: :pending do
transition pending: :paid
transition pending: :cancelled
transition paid: :processing
transition processing: :shipped
transition processing: :cancelled
transition shipped: :delivered
transition delivered: :returned
end
endThe tradeoff is verbosity. A new state means editing the machine, not just adding a string to an enum. We accept that. The cost of a bad transition — a customer charged for a cancelled order, a shipment sent to a refunded address — is higher than the cost of a few extra lines of declaration.
The machine also gives us a single place to hook side effects. When an order moves to `paid`, we enqueue the invoice generation. When it moves to `shipped`, we send the tracking link. These hooks are guarded by the transition rules, so we never send a tracking link for an order that was never paid.
class Order < ApplicationRecord
state_machine :status, initial: :pending do
after_transition pending: :paid do |order|
InvoiceJob.perform_later(order.id)
end
after_transition processing: :shipped do |order|
OrderMailer.tracking_email(order).deliver_later
end
end
endA common objection is that real e-commerce flows are messier than a clean graph. Partial payments, RTO (return to origin) on COD orders, marketplace cancellations after packing — these do not fit a linear pipeline. That is true, and it is the reason to model it explicitly rather than leave it implicit. A state machine forces you to decide what happens when a packed COD order is returned by the courier. You add a `returned_to_origin` state and a transition from `shipped` to `returned_to_origin`, then a transition from `returned_to_origin` to either `cancelled` or `processing` if the customer still wants the order. The messiness becomes visible, testable, and documented in code.
We write tests for every transition. Not just the happy path — the invalid ones too. An order in `delivered` state should not transition to `shipped`. A `cancelled` order should not transition to anything. These are the bugs that cost money.
test "cannot ship a cancelled order" do
order = orders(:cancelled)
assert_raises(StateMachines::InvalidTransition) do
order.ship!
end
endThe status column does not disappear. It still lives in the database, and it is still useful for queries — `WHERE status = 'paid'` is fine for reporting. What changes is that no code outside the model can set it directly. The only way to change status is to call the transition method, and the transition method checks the rules.
For a three-person team maintaining a client's store over years, this is the difference between a status field we are afraid to touch and one we can extend with confidence. Adding a `quality_check` state before `processing` is a one-line change to the machine, plus the tests for the new transitions. The rest of the codebase does not need to know it exists.
If your orders table has a status column with seven values and no transition logic, that is the first thing we will refactor when we take over the project.