How I Put Rails Models on a Diet
Improving my Rails applications with a Service Layer.
I first discovered Rails in 2005. I’ve been developing web applications in Rails since 2006. It’s fair to say that the majority of my income since graduating from University has been Rails related work.
Slowly, Rails projects became less enjoyable. Multi-person greenfield projects would start off great, but would often end up as being difficult to understand, modify, and collaborate on. Brownfield projects were worse: fat controllers; fat models; and tests that were minimal, brittle, and frustratingly slow.
Jamis Buck’s “Skinny Controller, Fat Model” pattern did a lot to improve things, though for the most part it was ignored in the projects I had the chance to work on. I liked that the application logic was in the model, where testing it didn’t involve the controller lifecycle. Still, fat models were now the burden, the logic being closely coupled to the persistence layer. Could the application logic go elsewhere?
The late James Golick had the solution I was looking for: Services. Instead of packing all the logic into the models, it could exist in it’s own Ruby classes. Free from the shackles of persistence, services are just Plain Old Objects, where their purpose was clear, and testing fast due to their focussed purpose.
Time for an example! We have an e-commerce application, and wish to create an order from a request, calculate the tax on that order, and persist the order.
Below is an example of a controller action which would create an order:
service = OrderCreator.new
status, @order = service.create(params[:order])
if status == :succeeded
render action: ‘new’
The controller only deals with passing the parameters to the service (or session information if required), and carrying out HTTP actions on the response. It makes no decisions on it’s own. The double-assignment isn’t the neatest (I’ll perhaps blog about different patterns I have tried for this in a future post), but it’s clear and understandable.
The OrderCreator service is what I would categorise as an Orchestrator; it mediates between the controller and the rest of the application. This, along with other services, would go in the app/services directory at the root of a Rails application.
order = order_klass.new(order_params)
tax_service = tax_calculator_klass.new
order = tax_service.calculate(order)
The first thing to note is the initialize method. Yes, that is dependency injection. No, you are not reading Java code from 1998. The use of DI here allows us to swap out the classes for testing purposes, so we can isolate the service and test it on it’s own. If you’d like to see how this works in practice, check out the James Golick post linked above. Whilst passing arguments to initialize works well, I like to use Brandon Keepers’ Morphine gem, which provides a nice API for doing simple dependency injection.
The second thing is that this service calls another service. Each service should be as simple as necessary, while allowing the services to be composed and re-used. It’s likely that sales tax would need to be re-calculated when editing an order, so extracting that functionality to a separate service makes sense.
Thirdly, we have the response. Depending on whether the order is persisted successfully, we pass back a symbol communicating the success state, and the order itself.
The Auxiliary Service
Lastly, we have a service for calculating tax on our order.
TAX_RATE = 20.freeze # percent
order.tax = order.subtotal * (TAX_RATE / 100)
order.total = order.subtotal + order.tax
The TaxCalculator service calculates the sales tax, assigns the relevant attributes, and passes the order back. The order doesn’t even have to be an ActiveRecord instance, it could be any Struct or Object which responds to subtotal, tax, tax= and total=. Indeed, this is how this service would be tested, without instantiating a model, or even loading the Rails stack. The statelessness of this service removes side-effects, and allows for fast testing and easy debugging.
This is the sort of code that would previous have been encapsulated in a model callback or class method. Where the service becomes really adventagous is when tax gets more complicated, being related to geography, different rates of tax, etc. (Shout out to VAT MOSS!). The model no longer requires logic implementing these things, and mudding it’s persistence role.
So what is left in a model? Not much. The following is what I put in models:
- ActiveRecord Relationships
- Class methods which wrap ActiveRecord API methods.
- Instance methods which modify the state of the instance without any application logic.
What is out?
- No public scopes. Wrap these.
- No using ActiveRecord API methods outside of the model.
- No display methods (example: a full_name() instance method which concatenates first_name and last_name attributes). This is a view concern. Either use a helper method, or use the Decorator/Presenter pattern to wrap the model instance for display.
- No callbacks. Samuel Mullen has written about this, but prescribes a less aggressive callback prohibition than I follow.
The core functionality of the the Distrify application is now easier to understand. No longer does debugging involve following chains of callbacks in the model, or other logic tied to the persistence. This speeds up the development of new features, as the risk of unintended regressions to existing features is much reduced.
Isolated testing of the services comprising the main logic of the application allows them to be run exceptionally fast, reducing developer downtime during development. Currently, the Distrify application has 34 controllers, 44 models (many of these are non-ActiveRecord models), and 130 services. There are 1869 Spec examples, which run in 1.58 seconds (plus 4.07 seconds for the test suite to load) on my late 2013 dual-core Macbook Pro. That test runtime is not a typo.
I should note that reduced test runtime was not a goal of using the service pattern. Increased understandability, reduced side-effects, and clearer ownership of functionality were the original goals. The tests running in single-digit seconds was just a nice side effect.
This started out as an experiment, but after more than a year productivity still seems to be high, and we are very pleased with how this has turned out.
Not so much reading, but I can highly recommend Gary Bernhardt’s Destroy All Software screencasts. Season 4 covers a lot of what I’ve talked about in great detail, plus you’ll learn a bunch of other stuff too.
Controllers do HTTP, models do persistence, decorators provide logic to views, services do everything else.