Service Objects on Rails

Thilo Rusche
6 min readMar 11, 2023

--

I’ve been re-reading “Sustainable Web Development with Ruby on Rails” by David Bryan Copeland, an in-depth guide to real-world Rails development I cannot recommend enough. While every re-read surfaces some new ways in which to improve our code base, at SportsKey we have been using service objects to isolate our business logic for quite some time. I’d like to share how we do it, and what I consider the benefits of this approach.

Where, oh where does it go?

Where and how to implement the business logic in a Rails application has been hotly debated over the years. First we got fat controllers as an anti-pattern, with the fat models, skinny controllers philosophy as the solution. When our models got too bloated with multiple responsibilities, it became fashionable to extract logic into concerns, which in my opinion doesn’t accomplish much more than spreading logic into separate files that *still* bloat models at runtime, and make it much harder to easily understand the capabilities of a class.

(Don’t get me wrong here, concerns are perfect for sharing functionality across classes. But they are frequently abused as a lazy way to reduce perceived complexity of a single class.)

Then service objects became the way to go. But even those seem to be getting a bad rap these days. I don’t think that’s warranted, and at SportsKey, we’re sticking with service objects (or whatever you wish to call them) for encapsulating and isolating our business logic.

There are gems and whole sub-frameworks out there that aim to solve the problem of implementing business logic, most notably trailblazer. However, after several years of enthusiastically embracing such addons and then painfully ripping them out again, I am of the firm belief that service objects should not add another layer of complexity or semi-understood third-party magic to your codebase.

There’s really not much involved in rolling your own service objects, and the considerable upside is that you understand everything going on under the hood. You don’t need that level of understanding of, for example, how request parameters are parsed — by all means let Rails handle that for you, and trust that it Just Works™️. Business logic is a different beast altogether — there’s no convention over configuration here to hold your hand, nor can you stand on the shoulder of a thousand apps that came before you and had to do the same thing.

So how do we structure and use service objects then?

A service call in action

Let’s take it from the top — here’s a typical example of how we might call a service in a controller action. I’m omitting our use of form objects, authorization checks, I18n, namespacing and some other niceties for clarity. None of them make the average controller method much fatter than this.

This example shows a few basic conventions for our controller methods:

  • No more than a single flow control statement
  • One and only one data processing call — usually to a service object, unless the processing is as trivial as calling save on a model of form object
  • No more than one assignment (plus a flash message if warranted)
  • No more than one redirect

So it’s basically: parse and validate input, delegate processing, output based on the result. That’s all controllers should do in MVC, and it ensures they stay as skinny as possible.

By convention, our services assume that their primary arguments have been validated beforehand. Validation can be complex, and we actually handle those with form objects using dry-validation, which I’ve omitted from the example because it’s not really relevant here.

Of course, validation also implements some requirements of the business domain, as does authorization. So strictly speaking, our use of service objects implements our business *processes* only, and the totality of business logic is distributed across two additional areas — form objects and access policies. But all three have well-defined boundaries and quite different responsibilities, which makes this approach highly flexible.

These conventions allow us to call our services with the bang! version and treating anything going wrong as an actual exception (i.e. the payment API is down) rather than a run-of-the-mill error that might happen under normal circumstances (such as a card declined error, which in this example would have been caught by the frontend, in most scenarios).

For us at SportsKey, making a booking and processing the associated payment involves a lot of steps and some complex validation, decision making and error handling. Many, many commercial Rails applications process orders and payments, and every single one of them will do it slightly differently, according to the needs and requirements of their business. That’s why there are no built-in Rails conventions for this stuff — it’s simply not possible or even desirable to standardize any of it.

Let’s take a look under the hood

Here’s how we might implement the service for processing a booking with a payment. (Or rather, a reference to a pre-authorized payment — we don’t handle credit card data ourselves, ever. Stripe does that for us.) This is a made-up example, but it accurately reflects our conventions:

This class does a lot, but that’s the nature of business logic — it’s complex but usually quite sequential. It also doesn’t look very ruby-like — it’s not neatly object-oriented code. It’s not SOLID! It looks rather… procedural, doesn’t it? This is fully intentional.

No matter what the cool kids say, a procedural style of coding has its place even in an object-oriented or functional language, and naturally sequential steps like these are prime candidates. Just by glancing at the only public method of this service object, anyone who has never seen this code before can quickly understand *what it does*. Not how, but what. It reflects the actual business domain. In fact, our sales team could probably understand what it does.

To me, that’s the secret sauce to finding your way around the business logic part of a Rails application. As a developer unfamiliar with our code base, you may have seen a dozen other Rails apps before, and know your way around controllers, views, models and even helpers blindfolded in the dark, thanks to the ubiquitous and well-established Rails conventions. But you’ve never seen our business logic before. That’s our unique snowflake, and being able to get the gist of it at a glance is extremely valuable.

Playing with bricks

A nice side effect of using service objects like this is the ability to nest them. Over time, we’ve encapsulated most processes this way, and the private methods become pretty trivial:

In fact, at this point, these tiny methods become just another pointless indirection, and can just be ditched in favor of calling the other services directly:

Maybe no longer as readable to our sales team, but still :)

This makes our whole suite of domain logic both easily testable and reusable in different contexts. In other words, highly composable. After a while, implementing new logic becomes just a game of Lego.

But wait, there’s still some magic in there

Right. What’s this ApplicationService we’re inheriting from here? Glad you asked. This is the base class for our services in all its g̶l̶o̶r̶y simplicity:

It allows use to invoke a service with a bang!, raising exceptions if they occur, or without one, which will always return a simple and well-defined result object which can be checked for success for failure, as well as a payload and or error if we need to process the result further.

This implementation also provides some syntactic sugar for invoking services via a class method instead of the slightly longer way of calling an instance method on an actual object, i.e. SomeService.call(...)instead of SomeService.new(...).call. We could even just use ruby's dot-parentheses shorthand SomeService.(...) for the non-bang version if we wanted to, but I’m not a fan of that — I like the expressiveness of the slightly more verbose version.

Just let it flow

Speaking of the non-bang version, its primary use is to handle commonly encountered failures that are not really exceptions. Using exceptions for flow control is an anti-pattern. A typical use case for the non-bang version might look like this:

But ultimately, if you validate input as a separate step before processing, like we do with form objects, anything going wrong inside a service object is in all likelihood an actual exception worth investigating, and should raise an error notification to your dev team. (Handling this exception gracefully in the UI so your users don’t see a standard 500 error is another matter.) So we use the bang! version by default.

So this is how we do it, and it’s served us well. Of course it’s not the only way, nor necessarily the best, it’s just what works for us, at our particular scale (which is small-ish, in the grand scheme of things).

What works for you? Share your thoughts in the comments!

--

--

Thilo Rusche

Software Developer, Ruby on Rails enthusiast, CTO @ SportsKey.