We’ve given up on “fat models, skinny controllers” as a design style for our Rails apps—in fact we abandoned it before we started. Instead, we factor our code into special-purpose classes, commonly called service objects. We’ve thrashed on exactly how these classes should be written, so this post is going to outline what I think is the most successful way to create a service object.
Purpose of a Service Object
A service object’s job is to hold the code for a particular bit of business logic. For example, when we process a customer’s returned items in the warehouse, we use an instance of
ReturnProcessor to handle that. Unlike the “fat model” style, where a small number of objects contain many, many methods for all necessary logic, using service objects results in many classes, each of which are single purpose.
Where a classic Rails design would add Yet Another Method™ to the nearest ActiveRecord object (in this case,
Shipment), using service objects allows us to keep all of our code separate and organized. This makes it easy to understand, modify, and test our business logic. It also alleviates some pain when extracting this code into separate HTTP services.
Aren’t these still models, in the classic sense?
In Domain-driven design, pretty much everything is considered a model. In effect, our
ReturnProcessor is modeling the returns processing activity.
But, like it or not (and intended or not), Rails has co-opted the term model to mean “a class that extends
ActiveRecord::Base”. Even more modern interpretations in the Rails world still view a model as a data container or stateful class of some sort.
Because of this, I believe it makes sense to think of classes that model a process as different, and service object seems to be the term most will understand.
What about Concerns?
Concerns (i.e. modules that you mixin to the classes where they are needed) do serve a similar function to service objects, but they have many of the same problems as fat-model/skinny-controller.
The main problem with putting all business logic into modules is that you end up bloating the classes where they are mixed-in. You also end up creating complex dependencies between a model’s internals and your mixin. And, mixin modules fall victim to some of the issues we’ll discuss here, namely that they are global to the VM and can have confusing side-effects.
Let’s dive into designing a service object.
Designing a Service Object
Designing the class for a service object is relatively straightforward, since you need no special gems, don’t have to learn a new DSL, and can more or less rely on the software design skills you already posses. That said, a service object isn’t just any old class—it exists to implement business logic or a business process.
That means that we can realize some advantages by following a few simple rules when creating the class for our service object. Here are mine:
- Do not store state
- Use instance methods, not class methods
- There should be very few public methods
- Method parameters should be value objects, either to be operated on or needed as input
- Methods should return rich result objects and not booleans
- Dependent service objects should be accessible via private methods, and created either in the constructor or lazily
Let’s go through each one of these rules to understand the benefit it provides.
Do not store state
Service objects should be as functional as possible—the methods should operate on only what’s passed to them, and the results should be completely describable in the return value. In other words, calling a method should not affect the internal state of the service object in any way.
We have a few service objects that store state, and it’s been a nightmare to use them. Many of them cannot effectively be used in multi-threaded code because race conditions could squash the internal state of these objects, leading to hard-to-diagnose errors.
This isn’t to say your service object can’t have instance variables, but that their values should not ever change. A common example would be configuration or a dependent service object:
class MyService def initialize(timeout: 1000) @helper_service = MyHelper.new @timeout = timeout end end
The code above implies that users of service objects use objects created from a class, and not the class itself.
Use Instance Methods
A class in Ruby is a global symbol, which means that class methods are global symbols. Coding to globals is why we don’t use PHP any more.
A great example of where a service-as-a-global-symbol is problematic is Resque. All Resque methods are available via
Resque, which means that any Ruby VM has exactly one resque it can use.
The real issue is that Resque’s internals also use
Resque. Meaning, if your app needs to communicate with more than one Resque instance, it’s basically impossible, since there is no way to tell Resque what implementation to use.
If, on the other hand, Resque was implemented as an object, instead of a global, any code that needed to access a different Resque instance would not have to change—it would just be given a different object.
If your service object has a lot of configuration—as is the case with API clients, for example—a single VM-wide instance as a global symbol is going to be bad.
By designing our service objects to use instance methods, it means the users of our service will depend on objects, not global symbols. Although we have to type four additional characters—
.new—our code using our service can more easily adapt to change, since we can modify the class of the object we’re using without changing the code that uses it. It also makes it easier to reason about thread-safety, because we have a stateless object instead of a stateful, VM-wide symbol.
To keep our service object focused and cohesive, we want to limit the size of their public APIs.
Have Few Public Methods
Many Rubyists have two very bad habits: using a public
attr_accessor for every instance variable, and declaring all methods public, even if they aren’t part of the class’ actual API. Worse, many Rubyists feel the need to write tests for these private-but-public methods.
This style of coding makes refactoring pretty much impossible, and it also makes it hard to understand how a class is intended to be used. If the intention is that users of your class just call one or two methods, those are the only ones that should be public (and your tests should only use those methods, too).
ReturnProcessor example from above. Suppose the method users call is called
process!, and it does two things: charge the customer for unpaid items, and record the return as having been processed. It uses a private method—
record_return—and another service object—
checkout_service—to do this.
class ReturnProcessor def process!(the_return, user) if unpaid_items(the_return).any? checkout_service.charge!(unpaid_items(the_return)) end record_return(the_return,user) end end
checkout_service public is wrong:
class ReturnProcessor attr_accessor :checkout_service def initialize @checkout_service = CheckoutService.new end def process!(the_return,user) # … end def record_return(the_return,user) end end
This means that instances of
- process a return
- provide access to a
- record the processing of a return without charging customers for unpaid items
This is clearly unintended (and a bad idea—a class named
ReturnProcessor should not be vending instances of
CheckoutService). This is how this class should look:
class ReturnProcessor def initialize @checkout_service = CheckoutService.new end def process!(the_return,user) # … end private attr_reader :checkout_service def record_return(the_return,user) end end
Now, the class is much easier to understand and use, plus we can modify the implementation of
process! as we see fit, without worry that someone, somewhere is calling
You may think that private methods are a code smell. If you believe that, making them public does not solve the problem. Extract private methods to classes if you feel there are too many in your service object’s class (we’ll see how to manage dependent services in a little bit).
Once we’ve identified the very few public methods we need, our next step is to figure out what parameters they should accept and what values they should return.
Method parameters should be value objects
The purpose of your service object’s methods are to operate on some data or perform some process using some data as input. This is the primary differentiator between a service object and other objects. As we saw above, the
process! method in
ReturnProcessor handles marking a return as processed and charging a customer for unpaid items. This means it needs the data for the return as well as the user who processed it.
What you should not be passing to your service object’s methods are other service objects. We saw that
ReturnProcessor will charge customers for unreturned items using an instance of
Passing it in requires all callers to deal with how to create a
CheckoutService properly as well as exposes them to the internal implementation of
process!, thus making it harder to change:
# Bad ReturnProcessor.new.process!(the_return,user,CheckoutService.new)
process! simply accepts the data its operating on and/or needs to read, it makes more sense. The caller is going to have the return being processed and the user processing it.
# Good ReturnProcessor.new.process!(the_return,user)
Passing in dependent service objects puts undue burden on the caller, requiring code duplication and needless coupling to the service object’s implementation.
We’ll see in a second how the service object should get access to dependent service objects. But first, let’s see what sorts of data our methods should be returning.
Methods should return rich result objects, not booleans
Service object methods typically have three possible outcomes:
- The requested action succeeded
- It failed, but in an expected way
- An exceptional condition occurred
Distinguishing failure from exceptions can be subtle, but it’s important. A failure is something the caller is expected to deal with. The distinction between success and failure is also potentially subtle, as both represent “valid things that could happen as a result of this call”. Those valid things could require additional context to interpret.
For example, a successful return might require that some items be set aside for donation, and others returned to inventory. A failed return might need to indicate which items were accounted for incorrectly and a suggestion as to how the user can resolve it. A simple
false doesn’t communicate that information, nor could it ever.
This means that the common pattern of returning
false should be avoided. Neither
false can contain any sort of context. They are also not extensible in any way. If a simple
true is fine today, but you later need to return more information, you’ve got a much bigger re-design on your hands than if you’d used a richer object initially.
For this reason, I prefer returning result objects that have a more fluent API. Creating these objects is trivial with libraries like immutable-struct1:
class ReturnProcessor Result = ImmutableStruct.new(:return_processed?, :error_messages) end
Notice that instead of a method like
success?, I’ve used an explicit name for what happened—
return_processed?. Users of
ReturnProcessor will then benefit from clear, intention-revealing code:
result = return_processor.process!(the_return,user) unless result.return_processed? flash[:error] = result.error_messages.join(",") redirect_to 'new' end
The result object, being a real object and not a primitive, can grow to encapsulate further details and context if the need should arrive. It may seem like wrapping a boolean in an object is YAGNI, but a result object has almost no build cost, and no carry cost.
Now that we know how to design our methods, the last bit is how to access dependent service objects—those objects that our service object needs to get its job done (or that we extract from an existing service object to tame complexity).
Managing dependent service objects
We said earlier that our service object’s methods should not take other service objects as parameters. This leaves open the question of how our service object can get access to other service objects it needs to do its work. Our example had our
ReturnProcessor using a
CheckoutService to charge customers for unreturned items that weren’t paid for.
It’s not uncommon to see code just instantiate the needed objects where needed, but this is not ideal.
def process!(the_return,user) if unpaid_items(the_return).any? # Bad--this business logic is coupled to the # creation of another service object result = CheckoutService.new.charge!(unpaid_items(the_return)) unless result.charge_succeeded? return Result.new(return_processed: false, error_messages: result.error_messages) end end # remainder of the method end
The problem is that
process!—which should just be concerned with the details of processing a return—also has to know how to create a
CheckoutService instance. This means that if we need to change how
CheckoutService is created, we have to change this method (and likely its tests). We don’t want this method to change unless the business process of processing returns changes.
The simplest thing to do is move it to a private method:
class ReturnProcessor def process!(the_return,user) if unpaid_items(the_return).any? # Good--our code just depends on an object # that we can assume has been set up for us result = checkout_service.charge!(unpaid_items(the_return)) unless result.charge_succeeded? return Result.new(return_processed: false, error_messages: result.error_messages) end end # remainder of the method end private def checkout_service @checkout_service ||= CheckoutService.new end end
ReturnProcessor is still coupled to how
CheckoutService gets created, the
process! method no longer is. Meaning it has less reasons to change, meaning our system is overall more resilient to changes.
Another alternative is to allow callers to pass in a
ReturnProcessor’s constructor along with a sensible default.
class ReturnProcessor def initialize(checkout_service: nil) @checkout_service = checkout_service || CheckoutService.new end def process!(the_return,user) if unpaid_items(the_return).any? result = checkout_service.charge!(unpaid_items(the_return)) unless result.charge_succeeded? return Result.new(return_processed: false, error_messages: result.error_messages) end end # remainder of the method end private attr_reader :checkout_service end
This is more verbose, but is a useful pattern if:
- The way
CheckoutServiceis created is unstable and likely to change, and we wish to store that code outside this class.
- We will need different implementations of
CheckoutServicefor different situations.
CheckoutServiceis a singleton or something that’s relatively difficult to construct, and we want that code to life elsewhere.
- We want to fail fast if creating the
CheckoutServiceresults in an error (as opposed to failing when the object is actually needed).
Also note that our
attr_reader is private. As mentioned, callers should not use
ReturnProcessor instances to gain access to
CheckoutService instances, and we communicate that via
That was a bit of a journey, but after a lot of thrashing on service object design, these rules of thumb seem to work the best and produce the simplest code.
The idea is to keep the methods implementing your business logic as focused as possible, so that they only contain business logic, and only need to change when a business process changes.
And, we can do it without any new DSLs or frameworks (other than
immutable-struct, which really only exists due to deficiencies in Ruby’s stdlib). Designing features in Rails using service objects just requires vanilla Ruby and a few rules of thumb.