I recognise the gem aasm is one of the most useful gems when we want to explain how our objects or logic will change among states and which conditions will be. However, after many years I’ve seen how many developers are still shooting themselves with this gem.
The original code has been done in collaboration with Martin Madsen, the code present in this blog is a generalization from the original, still, it is a similar concept.
I’ve seen with this gem is how they use the ActiveRecord implementation for every case. Let’s be clear, this implementation is great when the state and dependencies related are only models and you are not creating circular dependencies.
Multiple times we are working with the next scenario:
- We have a booking for a room when the user is starting this booking its state is draft.
- Later on, the user is confirming the date we will change it by pre-reserved.
- When the payment has been done the state will be booked. Finally, when the booking has been fulfilled, the status will be fulfilled.
Of course, we might have more transitions between those cases, but with just 4 this example will be enough.
ActiveRecord Implementation
Usually, your code if you are implementing the ActiveRecord approach your code it will look
class Booking < ApplicationRecord
include AASM
belongs_to :provider
belongs_to :client
aasm do
state :draft, initial: true, after: :notify_draft
state :pre_reserved
state :booked, after: :notify_reservation
state :fulfilled
event :pre_reserve! do
transitions from: :draft,
to: :pre_reserved,
after: :notify_draft
end
event :book do
transitions from: :pre_reserved,
to: :booked,
after: :process_book
end
end
def notify_draft
Bookings::Email.send(provider, 'Someone is interested')
end
def process_book
Bookings::Payments.release(self)
Bookings::Email.send(client, 'your reservation has been done')
Bookings::Email.send(provider, 'you got a new reservation')
end
end
Pros and Cons
- Booking model is prone to become a god object.
- Single responsibility principle is being violated. After changing states we are adding business logic. Booking is responsible of persistency, changing states(we can consider this as persistency if we don’t want to be strict), sending emails -when sending emails-, and releasing payments.
- Testing will become harder, more items to stub.
- Wrong dependency direction. Entities now will know about other services. Therefore, more dependencies.
State machine as a service.
A great approach is implementing the state machine as a service and injecting the booking we want to modify.
module Bookings
class StateService
include AASM
attr_reader :booking
def initialize(booking)
@booking = booking
# we need to initialize the state machine base on the current booking
aasm.current_state = booking.status.to_sym
end
aasm do
state :draft, initial: true, after: :notify_draft
state :pre_reserved
state :booked, after: :notify_reservation
state :fulfilled
# This callback could be something different, will dependent of your code
after_all_transitions :save_state
event :pre_reserve! do
transitions from: :draft,
to: :pre_reserved,
after: :notify_draft
end
event :book do
transitions from: :pre_reserved,
to: :booked,
after: :process_book
end
end
def notify_draft
Bookings::Email.send(provider, 'Someone is interested')
end
def process_book
Bookings::Payments.release(self)
Bookings::Email.send(booking.client, 'your reservation has been done')
Bookings::Email.send(booking.provider, "you've got a new reservation")
end
def save_state
booking.update_attributes!(status: aasm.to_state)
end
end
end
To achieve this implementation we only need to be aware of 2 sections. Everything else is your own code
The first piece of code is your initialiser, you will need to indicate what is the current state for you machine, your instance should have the value. Don't let you misguide you as a default value, it is the value for your entity.
def initialize(instance)
aasm.current_state = instance.status.to_sym
end
Also when your changes are done, you need to make sure you will make it persistent, it is not mandatory this hook, but saving is a must.
aasm do
after_all_transitions :save_state
end
def save_state
instance.update_attributes!(status: aasm.to_state)
end
Pros
Considering this approach we will get two huge benefits:
- Booking model is responsible only data persistency
- This StateService is our actual State machine and the dependency direction is proper, because is a service knowing about other services, and the model booking won’t know about Email or Payments
Top comments (2)
Do you have an example of how to call the events/transitions in a controller?
I'm having trouble calling bang methods after moving AASM block outside of a model class.
Sorry I haven't been active here. I will provide you an example soon