Scaling Your Ruby Service Objects with LightService
At LoadUp, we’re a big fan of the light-service gem and use it across our Rails codebase. LightService gives us a simple abstraction to build out our business logic using the railway programming pattern.
What are Service Objects?
Well, it is a fancy term.
It means that we put code related to business rules in independent files, rather than models, controllers, or views. If you’re unfamiliar with the concept, I’d recommend reading the TopTal article on it.
Speaking of fancy terms, the most common way to write service objects is called a PORO, or Plain Ole Ruby Object. It just means you write some code without inheriting from Rails classes like ActiveRecord. It might look something like this.
class CreateOrder
attr_reader :subtotal, :order, :user, :price def initialize(user, subtotal)
@subtotal = subtotal
@user = user
end
def process
verify_active_user
calculate_total
create_order_record
pay_for_order
send_confirmation_email
add_history_item_to_user_history
notify_service_team_of_specialty_order
end private def verify_active_user
# Check the user
# Maybe raise an error if there is a problem
end def create_order_record
# Create the order and save it to self.order
end # ...Create other private methods
end
For the most part, this works OK. It’s better than the old Rails mantra of “Fat Models, Skinny Controllers” since isolates your business logic. However, it gets kind of unwieldy at scale. Here’s a couple of flaws:
First, there are implicit expectations the that private methods run in sequence. Notice how the create_order
method really needs to run before the send_confirmation_email
because you would need that to know what to send an email about. However, it’s kinda hard to know that’s an exact rule. What if there were a dozen other things to do when you created an order? Would the coupling of those private methods be so obvious?
Second, how do you independently test the logic in the private methods? One could argue that you would only test this service object as a whole. However, if the service object did a dozen little interactions, would it be a good idea to test them in one giant test?
Last, it’s kind of clunky to reuse any of this logic. What if you created multiple kinds of orders? Maybe the implementation for most of the methods, i.e. send_confirmation_email
, stayed the same, but a few methods like create_order_record
were different. How do you easily reuse some of the logic in the private methods and not others?
If you’re talking at your screen right now and yelling that there are reasonable solutions to these challenges, then I’d say you’re probably right.
However, there might be an easier way! Enter the light-service gem.
The gem calls each of your service objects “organizers” and each individual step, an “action”. The data passes through the actions with a “context”, basically an object of data. So your organizer for the previous example might look like:
class CreateOrder
extend LightService::Organizer
def self.call(user, subtotal)
with(user: user, subtotal: subtotal).reduce(
VerifyActiveUser,
CalculateTotal,
CreateOrderRecord,
PayForOrder,
SendConfirmationEmail,
AddHistoryItemToUserHistory,
NotifyServiceTeamOfSpecialtyOrder
)
end
end
This organizer could be invoked with code like:
result = CreateOrder.call(user, subtotal)
The user
and subtotal
are put on the context, then that context chains through each of those “action” classes. An individual action like say CalculatePrice
might look like:
class CalculateTotal
extend LightService::Action
TAX_RATE = 0.05 expects :subtotal, :user
promises :total
executed do |context|
tax = context.subtotal * TAX_RATE
context.total = context.subtotal + tax
end
end
If only taxes were so simple! The action states what keys it expects on the context, which are subtotal
and user
. It also claims to add total
as a key onto the context. If this action did not add total
to the context, then LightService would error for you. If subtotal
or user
were missing from the context when the action started, it would also error. This is already much more explicit than our previous PORO service object.
Secondly, if we need to fail any action. We can call context.fail_and_return!(“Some reason")
from any part of the action. If we do, then the whole chain stops. The result of the whole service object is marked as failed. No further actions are invoked. So we don’t have to use exceptions or some other convoluted solution to track error cases.
Thirdly, testing is also just as easy. Regardless of your test suite, each action can be invoked outside of the organizer. In the case of our CalculatesTotal
action above, we can call
result = CalculatesTotal.execute(subtotal: <subtotal>, user: <user>)
total = result.total
<Assert something on total>
We don’t really care that it’s used as a part of our CreateOrder
organizer elsewhere. We can test this piece of logic independently.
Finally, we’re aren’t limited to only using our actions once. If you remember, we had a VerifyActiveUser
action. That’s something we might want to do in dozens of service objects. Well, there is no reason to rewrite it. We can simply keep reusing the same action as long as theexpects
and promises
interfaces are maintained.
In the end, your application becomes comprised of dozens of service objects and hundreds of little actions, which we put in nested directories for organizational purposes. Each action is just a little function disguised as a class and service objects are just compositions of those little actions. Did I hear someone say that’s very functional?