Using state_machine with event sourced entities
… and check why 5600+ Rails engineers read also this
Using state_machine with event sourced entities
Our event sourced aggregates usually have a lifecycle and they need to protect some business rules. Often they start with guard statements checking if performing given action is even allowed. I was wondering if there was a nice way to remove those conditional and make the code more explicit. I wanted to experiment with porting the code from our book to use state_machine
gem and see if the results are promising.
My starting point was a class looking like this:
class Order
include AggregateRoot
NotAllowed = Class.new(StandardError)
Invalid = Class.new(StandardError)
def initialize(number:)
@number = number
@state = :draft
@items = []
end
def add_item(sku:, quantity:, net_price:, vat_rate:)
raise NotAllowed unless state == :draft
raise ArgumentError unless sku.to_s.present?
raise ArgumentError unless quantity > 0
raise ArgumentError unless net_price > 0
raise ArgumentError if vat_rate < 0 || vat_rate >= 100
# make changes and apply new state
end
def submit(customer_id:)
raise NotAllowed unless state == :draft
raise Invalid if items.empty?
# make changes and apply new state
end
def cancel
raise NotAllowed unless [:draft, :submitted].include?(state)
apply(OrderCancelled.strict(data: {
order_number: number}))
end
def expire
return if [:expired, :shipped].include?(state)
apply(OrderExpired.strict(data: {
order_number: number}))
end
def ship
raise NotAllowed unless state == :submitted
apply(OrderShipped.strict(data: {
order_number: number,
customer_id: customer_id,
}))
end
private
attr_reader :number, :state, :items, :fee_calculator, :customer_id
def apply_strategy
->(_me, event) {
{
Orders::OrderItemAdded => method(:apply_item_added),
Orders::OrderSubmitted => method(:apply_submitted),
Orders::OrderCancelled => method(:apply_cancelled),
Orders::OrderExpired => method(:apply_expired),
Orders::OrderShipped => method(:apply_shipped),
}.fetch(event.class).call(event)
}
end
def apply_item_added(ev)
# ...
end
def apply_submitted(ev)
@state = :submitted
@customer_id = ev.data[:customer_id]
end
def apply_cancelled(ev)
@state = :cancelled
end
def apply_expired(ev)
@state = :expired
end
def apply_shipped(ev)
@state = :shipped
end
end
I removed some parts which are not interesting for this discussion.
As you can see, often methods start with some state
checks.
Sometimes it’s one state
that the Order
must be in:
def ship
raise NotAllowed unless state == :submitted
# ...
end
Sometimes it’s two or more:
def cancel
raise NotAllowed unless [:draft, :submitted].include?(state)
# ...
end
Sometimes we want idempotency instead of an error:
def expire
return if [:expired, :shipped].include?(state)
# ...
end
So when you try to expire
an already expired Order, we will do nothing. This can be in case our app is expiring in a reaction to a message coming from a message queue and we could get duplicated messages.
So I was wondering if using state_machine
would make the code more readable and expressive. I didn’t even need to make the rules exactly the same. I just wanted to get a feeling how it could look like and If I enjoyed it more or less.
One of the things I noticed recently is that the more versions of code I have solving the same problem the better I understand their good and bad sides. I learn what works for me and what not.
So let’s see a version using state_machine
class Order
include AggregateRoot
NotAllowed = Class.new(StandardError)
Invalid = Class.new(StandardError)
def initialize(number:)
@number = number
@state = 'draft'
@items = []
end
state_machine :state do
state 'draft' do
def add_item(sku:, quantity:, net_price:, vat_rate:)
raise ArgumentError unless sku.to_s.present?
raise ArgumentError unless quantity > 0
raise ArgumentError unless net_price > 0
raise ArgumentError if vat_rate < 0 || vat_rate >= 100
# ...
end
def submit(customer_id:)
raise Invalid if items.empty?
# ...
end
end
state 'submitted' do
def ship
apply(OrderShipped.strict(data: {
order_number: number,
customer_id: customer_id,
}))
end
end
state 'expired' do
def expire; end
end
state 'cancelled' do
def cancel; end
end
state all - %w(expired shipped) do
def expire
apply(OrderExpired.strict(data: {
order_number: number
}))
end
end
state *%w(draft submitted) do
def cancel
apply(OrderCancelled.strict(data: {
order_number: number
}))
end
end
end
It is interesting. If a method can be called in one state only you can define it inside that state definition:
state 'submitted' do
def ship
# ...
end
end
If you try to call it in another state, you will get NoMethodError
. On the other hand, the exception won’t be as good as our custom exception which clearly shows line number. It’s easier to have a look at that line of code and understand that a method was called in an invalid state compared to understanding a NoMethodError
without any meaningful info.
It is possible to define a method in 2 states only:
state *%w(draft submitted) do
def cancel
# ...
end
end
But the benefits are not so big in my opinion. You don’t see this method around other methods available for draft
or submitted
states and this meta-programming statement is not much better than my custom if
statement.
I also experimented with:
state all - %w(expired shipped) do
def expire
# ...
end
end
but that was the worst. Computers can understand that but for humans like me, it’s unbearable. I can’t easily recall all possible states in and remove expired
and shipped
from them, to understand where could that method be allowed or not.
Notice that I used only a very small subset of the gem’s features. That’s on purpose.
I looked into event transitions and transition callbacks but I could not find a nice way for them to play with the expectation that the only way to change a state of an event sourced object is via applying events.
I could define expire
method that could transition into expired
state:
event :expire do
transition all - %w(expired shipped) => :expired
end
But that would set the state
directly, instead of indirectly via a domain event that I would later save in a database.
def expire
return if [:expired, :shipped].include?(state)
apply(OrderExpired.strict(data: {
order_number: number}))
end
So at the end, I decided that it’s probably not worth in many use-cases to use this particular gem with our way of writing event sourced entities. The benefits are small and most features of the library cannot be used easily without introducing problems.
Perhaps there is a different library out there for defining state machines that could play nicer with event sourcing and aggregate_root
. But I haven’t found it yet. The struggle for a nice code involves a lot failed experiments.