Skip to Content
Course content

171: Practice Exercise: Building a State Machine with AASM

Click on the "Edit" button in the top corner of the screen to edit your slide content.

When you first start using the AASM (Acts As State Machine) gem, it feels like magic. You define a few states, a few events, and suddenly your model has these handy bang methods like order.pay! or order.ship!. But that magic comes with a strict set of rules. If you try to move a record into a state that you haven't explicitly allowed, AASM won't just ignore you—it'll throw a wrench in your gears.

class Order
  include AASM

  aasm do
    state :pending, initial: true
    state :paid, :shipped, :cancelled

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

    event :ship do
      transitions from: :paid, to: :shipped
    end

    event :cancel do
      transitions from: [:pending, :paid], to: :cancelled
    end
  end
end

# Everything looks great... until this happens:
order = Order.new
order.ship! 
# => AASM::InvalidTransition: Event 'ship' cannot transition from 'pending'

The 'Invalid Transition' Crash

I've seen this trip up plenty of developers. In the code above, the logic seems sound: you can't ship something that hasn't been paid for. But the code crashes because I tried to call ship! while the order was still pending.

AASM is designed to protect your data integrity. It assumes that if you didn't explicitly define a transition from pending to shipped, that transition is illegal. In a real production app, this crash would happen the moment a user clicks a button out of order or an API call hits your endpoint in the wrong sequence. You can't just assume the UI will prevent the user from doing this; your model needs to handle it gracefully.

Handling State Transitions Safely

You have two real options here depending on what you want the user experience to be. If the transition is truly illegal, you shouldn't let the app crash. Instead of using the "bang" method (ship!), which raises an exception, you can use the non-bang method (ship). This returns false if the transition is invalid instead of exploding.

order = Order.new

if order.ship
  puts "Order is on its way!"
else
  puts "Wait, this order isn't ready to be shipped yet."
end

However, if you realize that your business logic was actually wrong—say, some "VIP" orders can be shipped before payment—you need to update the transitions definition. I personally prefer using arrays for the from: option to keep things clean when multiple states can lead to the same destination.

event :ship do
  # Now both pending and paid orders can move to shipped
  transitions from: [:pending, :paid], to: :shipped
end

Guarding the Gate with Logic

Sometimes a state transition is legally allowed by the state machine, but only if a certain condition is met—like checking if an order actually has items in the cart. This is where guards come in. I use guards constantly because they keep the "business rules" inside the state machine rather than scattering if/else statements all over my controllers.

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

def items_present?
  # Only allow shipping if the order isn't empty
  self.line_items.any?
end

Now, even if the order is in the paid state, order.ship! will fail if items_present? returns false. It's a much more robust way to build a workflow than just hoping the data is correct.




📋 Practical Task

Exercise: Build a Subscription Lifecycle Machine

You need to build a state machine for a Subscription model. This model tracks whether a user is in a trial period, actively paying, overdue on payment, or has cancelled their service.

Requirements:

  • States: trial (initial), active, past_due, and cancelled.
  • Events:
    • activate: Moves a subscription from trial to active.
    • miss_payment: Moves a subscription from active to past_due.
    • retry_payment: Moves a subscription from past_due back to active.
    • cancel: Moves a subscription to cancelled from any other state.
  • The Guard: The activate event should only work if a method credit_card_present? returns true. (For the sake of this exercise, just define the method to return true).

Your Goal: Implement the Subscription class. Then, write a small script to test that you cannot move a subscription from trial directly to past_due, and verify that the cancel event works regardless of the current state.

Rating
0 0

There are no comments for now.

to be the first to leave a comment.