ruby 84 lines · 3 tabs

Order State Machine With Guarded Transitions and an Audit Trail in Rails

Shared by codesnips Jul 2026
3 tabs
class Order < ApplicationRecord
  class InvalidTransition < StandardError; end

  enum status: { pending: 0, paid: 1, shipped: 2, cancelled: 3 }

  has_many :order_transitions, -> { order(:created_at) }, dependent: :destroy

  TRANSITIONS = {
    "pending"   => %w[paid cancelled],
    "paid"      => %w[shipped cancelled],
    "shipped"   => %w[],
    "cancelled" => %w[]
  }.freeze

  def transition_to!(target, actor:, reason: nil)
    target = target.to_s

    transaction do
      lock!

      unless TRANSITIONS.fetch(status, []).include?(target)
        raise InvalidTransition, "cannot move order ##{id} from #{status} to #{target}"
      end

      previous = status
      update!(status: target)
      order_transitions.create!(
        from_status: previous,
        to_status: target,
        actor: actor,
        reason: reason
      )
    end

    self
  end

  def pay!(actor:)
    transition_to!(:paid, actor: actor)
  end

  def ship!(actor:)
    transition_to!(:shipped, actor: actor)
  end

  def cancel!(actor:, reason: nil)
    transition_to!(:cancelled, actor: actor, reason: reason)
  end
end
3 files · ruby Explain with highlit

This snippet models an order lifecycle as an explicit finite state machine backed by ActiveRecord, avoiding the temptation to scatter status checks across controllers. The core idea is that only a small, declared set of transitions is legal, each transition is guarded, and every successful move is recorded as an immutable audit row so the history of an order is reconstructable.

In Order model, status is a Rails enum mapping symbolic states to integer columns, which keeps the storage compact while giving scopes and predicate methods for free. The TRANSITIONS constant is the single source of truth: it maps each state to the states reachable from it. The transition_to! method is the only sanctioned way to change status. It rejects illegal moves with InvalidTransition, wraps the change and the audit write in a single transaction so they succeed or fail together, and uses lock! to take a row-level SELECT ... FOR UPDATE. That pessimistic lock is what makes concurrent transition attempts safe: two workers trying to pay! the same pending order will serialize, and the loser re-reads a state that no longer allows the move.

The convenience methods pay!, ship!, and cancel! are thin wrappers that name the business intent while delegating the actual guarding to transition_to!. Because the guard lives in one place, adding a new state means editing TRANSITIONS rather than hunting for conditionals.

In OrderTransition model, each row captures from_status, to_status, an actor, and a free-form reason. Marking it effectively append-only (readonly? returns true after creation, and there is no update path) turns the table into a trustworthy audit log; nothing rewrites history. The belongs_to :order plus the ordered has_many on the order side let the timeline be queried directly.

In OrdersController#cancel, the HTTP layer stays thin: it loads the order, calls the domain method, and translates the Order::InvalidTransition exception into a 422 rather than leaking a 500. This separation means the controller never decides what a legal cancellation is; it only reports the outcome. The pattern is worth reaching for whenever an entity has meaningful lifecycle rules, since it centralizes legality, gives concurrency safety, and produces an audit trail almost for free. The main trade-off is the pessimistic lock's contention cost, acceptable here because transitions are infrequent relative to reads.


Related snips

Share this code

Here's the card — post it anywhere.

Order State Machine With Guarded Transitions and an Audit Trail in Rails — share card
Link copied