My previous issue talked about how I prefer to avoid adding additional categorization to the classes and objects that I build in Rails apps. In it, I touched on some of my gripes with the service object pattern. Let’s explore the subject a little more.
I mentioned Avdi’s article, Enough With the Service Objects Already
. He suggests that service objects are only appropriate for situations where you’re modelling something procedure-like. For everything else, you’re better off falling back on more traditional object-oriented domain modelling strategies.
This is roughly aligned with my thoughts on the pattern. I’ve encountered many styles of service object
. They all look like functions/procedures that are wearing an ill-fitting object-oriented programming disguise.
Service objects take some arguments, do something, and optionally return a value. The third style in the gist above isn’t even different in either API or testability from a Proc, the closest thing to a first-class function in Ruby.
The structure of software should generally reflect its function. Most object-oriented design approaches come down to that idea, however nebulous. Heavy use of service objects results in very procedural applications, chains of procedure-like units calling other procedure-like units.
Domain-driven design urges us to model the concepts and ideas from our application’s domain as objects within our system. The idea of immediately reaching for a callable interface when trying to model almost anything seems absurd to me. It’s antithetical to most object-oriented design philosophies.
When I asked my team for examples of styles of service objects, they all pointed to the version that takes the dependencies to the constructor and other parameters to the
call method as their preference. I agree that it’s the best option, as itt supports dependency injection, an important pattern for creating composable objects. Hypothetically you can construct this style of service object with different implementations of its dependencies.
Consider the public interface of such a service. In my experience with the pattern, all the call sites in the application are going to invoke the
call class method. The only way for the tests to inject fake dependencies is to construct the object directly, bypassing the class-level interface.
This instantly breaks with the idea that tests are meant to demonstrate how to use the unit under test. On top of losing this documentation quality, the tests no longer reflect how difficult it is to construct the object, so you’re also losing the important feedback about how easy your object is to construct and use. This feedback is core to TDD; it is meant to guide you to a cohesive design with as little coupling as is reasonable.
Additionally, nested service object calls are very difficult to untangle. Since services eschew trying to model the “nouns” in the system, they’re prone to picking up new responsibilities.
When building a web “normal” objects, you create all these tightly focused (cohesive) objects that couple together data with the behaviour that relies on that data. You have a variety of mechanisms at your disposal for information hiding between these objects, so when new behaviour needs to be added, you can often isolate it within your system.
Heavy use of service objects leads to systems built out of database models and data objects with many services that interact with them in a variety of different ways. The service objects are not composable in the traditional sense and inevitably end up cutting across concerns, making it difficult to refactor your way to a more cohesive implementation.
Simple objects that have only one public method and accept their dependencies to their constructor are just as good a pattern as the same but with many public methods, but they aren’t the next evolution of Rails application design after fat models and controllers.
Only reach for service objects when you have some procedure-like code and aren’t yet sure what to do with it. Don’t unnecessarily constrain your design by overusing the pattern. Avoid structuring your application like a glorified C program by constructing the whole system as procedures calling other procedures. You have better design patterns at your disposal.