RUBY ON RAILS · 18 MIN READ ·

Rails State Machine: AASM-patronen voor orders, abonnementen en workflows in productie

Rails state machine met AASM: productiepatronen voor orders, abonnementen en workflows. Guards, callbacks, optimistic locking en de valkuilen die bijten.

Rails State Machine: AASM-patronen voor orders, abonnementen en workflows in productie

De bugmelding luidde: “Klant heeft twee keer betaald.” Het was een SaaS-platform op Rails, het soort startup in een midstadium dat sneller was gegroeid dan de codebase was meegekomen. Ik dook erin en vond de oorzaak binnen een uur. Het Order-model had een status-kolom die werd gemuteerd vanuit zeven verschillende controllers, twee background jobs en een Stripe-webhookhandler. Elk had zijn eigen mening over welke statusovergangen toegestaan waren. De webhook had stilletjes een reeds betaalde order teruggezet naar pending omdat de ontwikkelaar die hem had geschreven was vergeten dat paid orders voor die tak definitief horen te zijn.

Na negentien jaar Rails is de meest voorkomende architectonische zonde die ik zie in groeiende codebases: status als vrije-tekstkolom zonder regels. Een Rails state machine verandert die rotzooi in één enkele, auditeerbare definitie van hoe een object door zijn levenscyclus mag bewegen. De gem die ik pak is AASM, en deze post gaat over hoe je hem goed inzet in productie.

Waarom je een Rails state machine nodig hebt

Elk betekenisvol bedrijfsobject heeft een levenscyclus. Orders bewegen door pending, paid, shipped, delivered, refunded. Abonnementen bewegen door trialing, active, past_due, canceled, expired. Documenten bewegen door draft, in_review, approved, published. Jobs bewegen door queued, running, completed, failed.

De naïeve Rails-implementatie is een status-stringkolom met een paar constanten en een verzameling if order.status == "paid"-checks verspreid over de codebase. Dat werkt een week en rot dan weg. Nieuwe ontwikkelaars voegen nieuwe statussen toe. Oude overgangen worden omzeild. Webhooks zetten state direct zonder guards. Tests slagen omdat niemand een integratietest schreef voor “wat gebeurt er als Stripe een charge.succeeded stuurt nadat we al gerefund hebben?”

Een Rails state machine geeft je drie dingen die je niet makkelijk anders krijgt:

  • Eén declaratieve definitie van toegestane states en overgangen.
  • Afgedwongen guards die illegale overgangen op modelniveau voorkomen, ongeacht welke controller of job ze initieerde.
  • Hooks voor side effects (mail sturen, jobs queuen, audit logs schrijven) gekoppeld aan specifieke overgangen, niet verspreid over callsites.

Het Rails-ecosysteem heeft drie geloofwaardige opties. Ik heb alle drie gebruikt. Laat me uitleggen waarom AASM mijn default is.

AASM vs Statesman vs state_machines

state_machines is de spirituele opvolger van de originele state_machine-gem die Aaron Pfeifer schreef. Hij is feature-compleet, ondersteunt meerdere state machines per model en werkt prima. De DSL is wijdlopig en de documentatie is matig. Ik gebruik hem als ik een codebase erf die hem al gebruikt.

Statesman komt van GoCardless en heeft een event-sourcing-smaak. In plaats van een kolom te muteren schrijft hij elke overgang als rij in een *_transitions-tabel. Je krijgt een perfect audit log cadeau. Het nadeel is dat je nu een apart model en joins hebt om de huidige state te bevragen, en de DSL is breedsprakiger dan AASM. Ik pak Statesman als het audit log een harde eis is en ik het niet wil aanbouwen met PaperTrail.

AASM is het werkpaard. Het is de populairste state machine-gem voor Rails met afstand, de DSL is de meest leesbare en de integratie met ActiveRecord is schoon. Hij doet de basis uitstekend en blijft uit je weg. Voor negentig procent van het Rails state machine-werk is AASM het juiste antwoord.

Deze post focust op AASM. De patronen vertalen mechanisch naar de andere twee.

AASM-basis: order state machine

Hier is een kleine maar realistische order state machine. Hij dekt het pad dat de meeste e-commerce-orders volgen en de faalmodi die in productie pijn doen.

# Gemfile
gem "aasm"
# app/models/order.rb
class Order < ApplicationRecord
  include AASM

  aasm column: :status, whiny_transitions: false, no_direct_assignment: true do
    state :pending, initial: true
    state :paid, :shipped, :delivered, :canceled, :refunded

    event :pay do
      transitions from: :pending, to: :paid, guard: :payment_successful?
      after do
        OrderMailer.payment_received(self).deliver_later
        FulfillmentJob.perform_later(id)
      end
    end

    event :ship do
      transitions from: :paid, to: :shipped, guard: :has_tracking_number?
    end

    event :deliver do
      transitions from: :shipped, to: :delivered
    end

    event :cancel do
      transitions from: [:pending, :paid], to: :canceled
      after do
        IssueRefundJob.perform_later(id) if status_was == "paid"
      end
    end

    event :refund do
      transitions from: [:paid, :shipped, :delivered], to: :refunded
    end
  end

  private

  def payment_successful?
    stripe_charge_id.present? && payment_intent.status == "succeeded"
  end

  def has_tracking_number?
    tracking_number.present?
  end
end

Een paar details die er toe doen.

column: :status vertelt AASM welke kolom de state bevat. Default is aasm_state, wat lelijke databasetabellen oplevert. Zet dit altijd expliciet.

whiny_transitions: false zorgt dat ongeldige overgangen false retourneren in plaats van AASM::InvalidTransition te raisen. Ik geef de voorkeur aan false omdat controllers daarop kunnen branchen zonder exceptions te rescuen. Zet het op true als je luide failures wilt in development en bug-by-design gedrag in productie. Kies één lijn en blijf consistent.

no_direct_assignment: true is de regel die de meeste mensen overslaan en waar ze later spijt van krijgen. Zonder dit kan iedereen order.status = "shipped" schrijven en wordt de state machine stilletjes omzeild. Mét deze instelling krijg je een RuntimeError als iets de kolom direct probeert te assignen. Dit is de regel die de dubbele-afschrijving-bug uit de oorlogsverhalen hierboven überhaupt had voorkomen.

State persisteren en guards

AASM levert drie methodes per event: pay, pay! en may_pay?. De bang-versie persisteert de wijziging in een transactie. De niet-bang-versie muteert alleen het in-memory object. De may_*?-predikaat retourneert true als de overgang nu legaal is.

In productiecode wil je pay!. De Rails-conventie van ! als “schrijft naar de database, kan raisen” zet zich hier netjes door.

# goed — persisteert en triggert callbacks
order.pay!

# ook goed — check voor de overgang
if order.may_pay?
  order.pay!
else
  redirect_to order, alert: "Kan deze order in huidige status niet betalen"
end

# slecht — muteert het object alleen in memory, save omzeilt guards
order.pay
order.save

Guards zijn predikaten die true moeten retourneren wil de overgang doorgaan. Ze draaien voor de overgang. Als een guard faalt retourneert de overgang false (of raised, afhankelijk van whiny_transitions). Guards zijn de juiste plek voor invarianten: “een order kan niet verzonden worden zonder tracking number”, “een abonnement kan niet geactiveerd worden zonder betaalmiddel”.

Wat guards niet zijn: een plek voor side effects. Guards moeten idempotent en zonder side effects zijn. Als je guard een mail stuurt of een externe API aanroept, gebruik je AASM verkeerd en word je om 3 uur ‘s nachts gepaged wanneer een wankele externe service legitieme statusovergangen kapot maakt.

Callbacks en side effects

AASM ondersteunt before-, after-, before_transaction- en after_commit-hooks op zowel event- als transitieniveau. Het belangrijkste onderscheid is tussen after en after_commit.

after draait na de overgang maar nog binnen de database-transactie. after_commit draait nadat de transactie commit. Het verschil telt wanneer je background jobs queue.

event :pay do
  transitions from: :pending, to: :paid

  after do
    # SLECHT: job kan dit oppakken voor de transactie commit,
    # de order nog als pending zien en niets doen.
    FulfillmentJob.perform_later(id)
  end

  after_commit do
    # GOED: transactie is gecommit, job ziet de paid-state.
    FulfillmentJob.perform_later(id)
  end
end

Dit is dezelfde les die Rails-ontwikkelaars leren als ze ontdekken dat after_create- en after_save-callbacks vuren voordat de transactie commit. ActiveJob-backends die uit dezelfde database trekken (Solid Queue, GoodJob, delayed_job) zien bijna altijd gecommitte state omdat ze in een nieuwe transactie lezen. ActiveJob-backends die uit externe infrastructuur trekken (Sidekiq met Redis, Amazon SQS) kunnen absoluut racen met de commit. Gebruik after_commit voor alles wat een background job queue. Voor de dieperliggende reden om altijd database-backed jobs te kiezen voor dit soort werk, zie mijn post over Solid Queue background jobs.

AASM state-overgangen testen

De snelste weg naar brittle state machine-tests is het mocken van het model. De juiste manier is om daadwerkelijk te transitioneren en op het resultaat te asserten. Hier is het RSpec-patroon dat ik gebruik.

# spec/models/order_spec.rb
require "rails_helper"

RSpec.describe Order, type: :model do
  describe "state machine" do
    let(:order) { create(:order, :pending, stripe_charge_id: "ch_123") }

    it "transitioneert van pending naar paid bij succesvolle betaling" do
      allow(order).to receive(:payment_successful?).and_return(true)

      expect { order.pay! }.to change(order, :status)
        .from("pending").to("paid")
    end

    it "weigert te betalen als payment_successful? false is" do
      allow(order).to receive(:payment_successful?).and_return(false)

      expect(order.may_pay?).to be false
      expect(order.pay).to be false
      expect(order.reload.status).to eq("pending")
    end

    it "weigert te verzenden zonder tracking number" do
      order.update!(status: "paid", tracking_number: nil)

      expect { order.ship! }.to raise_error(AASM::InvalidTransition)
    end

    it "queue fulfillment na commit" do
      allow(order).to receive(:payment_successful?).and_return(true)

      expect { order.pay! }.to have_enqueued_job(FulfillmentJob).with(order.id)
    end
  end
end

Als je factory no_direct_assignment: true gebruikt, moet je of de initiële state zetten via het normale mechanisme van de factory (wat AASM toestaat voor initial: true-states) of order.send(:write_attribute, :status, "paid") gebruiken voor setup in tests. Ik verkies de laatste met een duidelijk gelabelde _for_test-helper omdat het de testsetup expliciet maakt. Voor meer productiewaardige RSpec-patronen, zie mijn post over RSpec, FactoryBot en VCR testing.

Concurrency: optimistic locking en race conditions

De dubbele-afschrijving-bug uit de opening was een concurrency-bug. Twee Stripe-webhookleveringen kwamen binnen 50 milliseconden van elkaar binnen voor dezelfde order. Beide webhookhandlers laadden de order, beide zagen hem als pending, beide riepen order.pay! aan. AASM’s guard liep in beide, retourneerde true in beide, en beide transacties committen.

De fix is optimistic locking gecombineerd met idempotent webhook handling. Voeg een lock_version-kolom toe aan de orders-tabel en self.locking_optimistic = true is niet nodig — Rails activeert het automatisch als de kolom bestaat.

class AddLockVersionToOrders < ActiveRecord::Migration[8.0]
  def change
    add_column :orders, :lock_version, :integer, default: 0, null: false
  end
end

Nu loopt de tweede concurrent webhook tegen ActiveRecord::StaleObjectError als hij probeert te saven, omdat de versie die hij laadde niet meer actueel is. Wrap de webhook handler in een retry-met-reload-patroon.

class StripeWebhooksController < ApplicationController
  def create
    event = Stripe::Webhook.construct_event(request.raw_post, ...)

    case event.type
    when "charge.succeeded"
      handle_payment(event.data.object)
    end

    head :ok
  end

  private

  def handle_payment(charge)
    Order.transaction do
      order = Order.lock.find_by!(stripe_charge_id: charge.id)
      order.pay! if order.may_pay?
    end
  rescue ActiveRecord::StaleObjectError
    retry
  end
end

Er gebeuren twee dingen. Order.lock.find_by! neemt een pessimistische row-level lock, wat concurrent handlers serieel maakt. order.pay! if order.may_pay? is de idempotente guard — de tweede handler ziet dat de order al betaald is, may_pay? retourneert false, en de call is een no-op. Kies één benadering of gebruik beide als bretels-en-riem, maar nooit geen van beide. Voor diepere patronen rond webhook-idempotentie, zie Rails webhook processing.

Subscription state machine: een echt voorbeeld

Abonnementen zijn moeilijker dan orders omdat de state kan veranderen op basis van tijd, niet alleen door gebruikersactie. Een trialing-abonnement wordt active als de trial eindigt. Een active-abonnement wordt past_due als een verlengingsbetaling faalt. Een past_due-abonnement wordt canceled nadat de dunningreeks verloopt.

Hier is de productie-state machine die ik een half dozijn keer heb uitgerold.

class Subscription < ApplicationRecord
  include AASM

  aasm column: :status, no_direct_assignment: true, whiny_transitions: false do
    state :trialing, initial: true
    state :active, :past_due, :canceled, :expired

    event :activate do
      transitions from: [:trialing, :past_due], to: :active,
                  guard: :has_valid_payment_method?
      after_commit { record_activation_metric }
    end

    event :mark_past_due do
      transitions from: :active, to: :past_due
      after_commit { DunningJob.perform_later(id) }
    end

    event :cancel do
      transitions from: [:trialing, :active, :past_due], to: :canceled
      after_commit { ScheduleAccessRevocationJob.perform_later(id, at: ends_at) }
    end

    event :expire do
      transitions from: [:past_due, :canceled], to: :expired,
                  guard: :access_period_ended?
    end
  end

  scope :due_for_expiration, lambda {
    where(status: [:past_due, :canceled])
      .where("ends_at < ?", Time.current)
  }

  def access_period_ended?
    ends_at.present? && ends_at < Time.current
  end
end

De recurring job die tijdsgebaseerde overgangen aanstuurt is een simpele cron job:

class ExpireSubscriptionsJob < ApplicationJob
  queue_as :default

  def perform
    Subscription.due_for_expiration.find_each(&:expire!)
  end
end

Plan hem dagelijks met Solid Queue’s recurring tasks (zie mijn post over Solid Queue recurring jobs). De cron job is dom. De intelligentie zit in de guard van de state machine. Expiration vuurt alleen als zowel access_period_ended? true is als het abonnement in een geldige bronstate zit. Een abonnement dat handmatig is gereactiveerd zal niet expireren alleen omdat de cron hem mid-flight oppikt.

Wanneer je GEEN state machine moet gebruiken

State machines zijn niet gratis. Ze voegen een dependency toe, een leercurve voor nieuwe ontwikkelaars, en een neiging tot over-modelleren. Drie signalen dat je er geen nodig hebt:

Het object heeft twee states, en het zullen altijd twee states blijven. Een boolean-kolom is prima. published: true | false is geen state machine.

De overgangen zijn lineair en hebben geen guards. Als status altijd voorwaarts marcheert van A naar B naar C zonder bedrijfsregels, is een enum met een next_status!-helper simpeler.

De state is afgeleid, niet opgeslagen. Als je de state op elk moment kunt berekenen uit andere velden (een paid_at-timestamp plus een refunded_at-timestamp), heb je geen state machine nodig — je hebt een methode nodig die de huidige state retourneert.

Het juiste moment om een state machine te introduceren is wanneer je jezelf de derde of vierde if status == X && other_thing-check ziet schrijven verspreid over de codebase. Tegen die tijd is de refactoringkost nog klein en het voordeel groot.

FAQ

Wat is de beste Ruby state machine-gem voor Rails in 2026?

Voor de meeste Rails-apps blijft AASM in 2026 de default. Hij heeft de schoonste DSL, het meest actieve onderhoud en de beste ActiveRecord-integratie. Pak Statesman als audit logging van elke overgang een harde eis is. Pak state_machines als je een codebase erft die hem al gebruikt.

Moet ik een enum of AASM gebruiken voor status?

Gebruik een Rails enum als je een vaste lijst string-naar-integer-mappings nodig hebt zonder bedrijfslogica rond overgangen. Gebruik AASM als er regels zijn over welke states naar welke andere states mogen, als overgangen guards nodig hebben, of als overgangen side effects triggeren. De twee combineren goed: AASM kan een enum-backed kolom gebruiken als state-kolom.

Hoe ga ik om met race conditions in een Rails state machine?

Combineer optimistic locking (een lock_version-kolom op het model) met idempotente transitie-guards (may_event?-predikaten die false retourneren als de overgang al gevuurd heeft). Voor webhookhandlers neem je daarbovenop een pessimistische row-level lock met Model.lock.find_by! binnen een transactie. De combinatie voorkomt de dubbele-afschrijvingklasse van bugs.

Kunnen AASM-overgangen veilig background jobs triggeren?

Ja, maar alleen vanuit after_commit-callbacks, niet after. Jobs die in after-callbacks gequeued worden kunnen door een worker opgepakt worden voordat de transactie commit, waardoor de worker oude state ziet. after_commit draait nadat de database commit en is de veilige keuze voor alles dat werk queue om asynchroon opgepakt te worden.


Bouw je een Rails-app waar de status-kolom begint te voelen als een vrije speeltuin? TTB Software is gespecialiseerd in fractional CTO-werk voor groeiende Rails-teams. We hebben negentien jaar besteed aan het opruimen van status-kolommen en het bouwen van de state machines die er vanaf dag één hadden moeten zijn.

#rails-state-machine-aasm #rails-aasm-tutorial #aasm-vs-statesman-vs-state-machines #rails-order-state-machine #rails-subscription-state-machine #ruby-state-machine-gem

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software