Let’s talk about state. Or actually, let’s not talk about state. I think we have talked enough about state. Let me just say that it’s very easy to think use state as a solution in OO programming. Especially when Rails give you tools such as
There is a trap though. Things can become very messy, very quick. I have seen this in code bases I inherited as well as ones I helped create. While working at a project with a colleague we ran into state and wanted to curtail the increasing amount of code it started to bleed.
A short example to explain what kind of state I am talking about:
class Order enum status: %i(open paid delivered cancelled) end
The initial thought was to have conditional validations, but as we worked on the project and reviewed eachothers code we knew this was not the way.
Control Your Controllers
Instead we decided to return to a very RESTful way of doing things. Instead of having just one controller we implemented several controllers
This was already a relief and helped us keep the controllers simple. But then the controller action, namely
create started to expand. Both annoyed and grateful to Rubocop, we had to look for different solutions.
We came up with a
class PaidOrdersController < ApplicationController def create begin @order.assign_attributes(order_params) FinalizeOrderPayment.new(@order).call rescue ActiveRecord::RecordInvalid render 'something' end end end
There was a bit more going on, but you get the point. We delegate all the logic very specific to paying an order to a new item, a finalizer as we started to call it.
Finalizer in turn looks somewhat like this:
class FinalizeOrderPayment attr_reader :order def initialize(order) @order = order end def call unless OrderPaymentValidator.new(order).valid? fail ActiveRecord::RecordInvalid, order end order.paid_on = Date.current order.send_to_shipping order.save end end
Again, the first iteration had the validations right in there, but it became clear fast that it was doing too much. So we created the custom validator. It is a very simple object:
class OrderPaymentValidator include ActiveModel::Validations delegate :buyer, :payment_confirmation, :product, :errors, to: :assessment validates :buyer,:payment_confirmation,:product, presence: true validates :address, presence: true, if: :ship_order? attr_reader :order def initialize(order) @order = order end private def ship_order? # Something very specific end end
This is it really. We did this for each type of
Order we could have. It kept things very clear. Instead of filling the
Order class with lots of different validations we make a clean cut selection of what we want to validate and when without having to resort to conditional callbacks.
I came to see the
Finalizer and the
Validator objects as guards for changing state. Of course I wrote plenty of specs to make sure this code was solid.
On a project with legacy code I inherited I found a great mess whent it came to state and conditional validations as well. The conditions were simpler, so I didn’t make a
Finalizer object, only a custom
Validator which I call directly in the controller.
Something like the following. Note that I chose a different naming style here. It was another project and I am only using this as an example. I try to be consistent within a project though, just wanted to show another take
class CancelledOrderController < ApplicationController def create @order.assign_attributes(order_params) if CancelledOrderValidator.new(@order).valid? @order.cancel flash[:notice] = 'Order cancelled' redirect_to orders_path else render :new end end end
This is a more simple approach when you don’t need to do do much else to transition an
Order to another state, but it still allows you to treat each
Order state as its own resource. I found it very helpful in cleaning things up.
Truth be told, in future projects I will probably avoid status altogether and go straight into creating different models for each state which may or may not point to the same database table. We’ll see.
Only after we implemented it we were informed on changesets within the Phoenix framework for Elixir. My colleague Tom Kruijsen wrote about it in his own blogpost. This seems a very similar approach, but isn’t available as such in Rails.