Skinny Models, Skinny Controllers, Fat Services
It is likely you have heard ‘Skinny Controllers, Fat Models’ as a cute little best-practice-saying to follow for your MVC application. While I agree with that sentiment for the most part, what to do when it’s clear that your model has become too fat? Services to the rescue!
First, why thin out your models?
The key motivator to clean up a fat model is code readability. As your application grows, it becomes more and more important that it is not hard to find where your functionality is actually taking place in your codebase. I don’t want to dig through a 1000-line model looking for methods that call other methods.
And… why Services?
There are other approaches to clean up bloated models, but we favored services as they are easy to write tests for. More on that below.
Let’s dive into a use case:
Our app needs to calculate how much to pay out to vendors. It gets a bit complex because we have custom orders, non-custom orders, payout overrides, fees, and so on.
This gnarly method (sorry Sandy Metz) was on our line_item.rb model.
Way too many lines, way too many nested conditionals, and difficult to test.
Let’s replace it with a service that does one thing: calculates what to pay a vendor from a line_item on an order. We are about to thin out the LineItem model by moving the functionality completely out of the model. Yay! Remember, for the sake of code readability, name your service by what it actually does:
(This lives in our services directory, app/services):
Ok, great. But wait, ‘wha happen?!’
We’ve taken the super long method completely out of the LineItem model, and moved that functionality into a service.
Let’s dissect the code in our new service a little bit.
First thing to notice is, our service is just a Ruby class. A nice, simple object, easy to work with.
Notice that our first method in this class is a class method, not an instance method. We can call ‘calculate’ directly on the class, like so (instead of having to chain .new into the call):
CalculateVendorPayoutService.calculate(line_item, vendor, line_item.order.time)
And then we can call new inside of the class method to instantiate the class:
def self.calculate(line_item, vendor, time)
new(line_item, vendor, time).calculate
We decided to use this pattern for all of our services.
Calling new of course would then run our initialize method:
def initialize(line_item, vendor, time)
@line_item = line_item
@vendor = vendor
@time = time
@payout = 0
We get the price from the line_item, the split (their cut of the sale) from the vendor, the time of when the order was processed (for some further split calculation… thats another story), and finally, payout, which in the end is what this service will return, as a Float object.
But wait, what’s the deal with the attr_reader?
attr_reader :line_item, :vendor, :time
We add that so we don’t have to prepend the @ symbol before all those instance variables, that’s all.
So, we have a class method named calculate AND an instance method named calculate? Yep. I made the instance meth0d the same as the class method, simply because that’s what that method will do (return the calculation). I could have called it wha_happen. Doesn’t matter. We know that Ruby reads lines top to bottom, and the last line is what is returned, which is a method called rounded_total.
total_from_license if payable_from_music_license?
add_fees if line_item.fees.any?
Sometimes a calculated split may return a decimal with more than two places, say 74.333. We are dealing with currency here, so, there is no such thing as 33.3 cents. We just round it a penny so that when it gets sent to our financial software, it knows what to pay out to a vendor.
I won’t go deep into the business logic, but a quick scan over all the other methods in the new service class shows each methods purpose. total_from_license gets the value from the sale and adds it to the payout variable. add_fees will increment the payout var with its value(s), if the fees exist.
And then there’s a bunch of other methods in there, but I’ll let you read over them if you care to.
You may think “that’s a lot of methods to just return a decimal”. Yes, but we can see that it is necessary for readability. Each method has a distinct purpose. In this service we can handle the many cases of what affects a vendor payout amount, in a much cleaner fashion.
So, what did we get in the end? We are now working with a PORO (Plain Old Ruby Object), which makes it easier to debug and write tests for. We’ve also thinned out our bloated model a little bit, and made a well-named service that will hopefully be self-explanatory to any future developers who may be new to our codebase.
Because I talked about how services are easy to write tests for, I’ll include some of the test cases we now have for this service and just point out a few high level things about the test.
- we describe the name of the our new service (line 3)
- we call described_class.calculate, which is the same as saying CalculateVendorPayoutService.calculate (lines 15, 20, 29, etc)
- You’ll notice each test (every test is within its own “it” block), is short and concise. We have the setup, (like on line 14), where we set the price of the line_item’s price, we call described_class.calculate with some args, and we expect that to return a float 99.5. Runs green. Easy setup, easy to call.
That’s all folks. I hope you found this example helpful. Hack on, Obi-wan.