Scaling Your Ruby Service Objects with LightService

Riaz Virani
WIP It, Ship It
Published in
4 min readJun 10, 2019
light-service gem GitHub page

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 CalculatePricemight 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?

--

--