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.
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.
Related Articles
Rails insert_all en upsert_all: Bulk Database-operaties die ORM-overhead Omzeilen
Rails insert_all en upsert_all voor bulk-imports: callbacks overslaan, conflicten afhandelen met on_duplicate, ID's r...
Rails PgBouncer: Transaction Pooling, Prepared Statements en Connection Sizing in Productie
Rails PgBouncer transaction pooling goed opgezet: prepared statements, pool sizing, advisory locks, LISTEN/NOTIFY en ...
Rack Mini Profiler: Prestatieprofiling voor Rails in Development en Productie
Rack Mini Profiler voor Rails: profileer SQL-queries, partials, geheugen en GC in development en productie. Vind N+1s...