At Makers Academy we like service objects. They are a handy way to refactor code out of fat models and fat controllers and into easy-to-call components. However, they are also easy to abuse and should be created with discretion.
One of the dangers with service objects is that they are temptingly convenient. Creating a service object can sometimes seem like a positive refactor, when in fact it's just moving code from one place it doesn't belong to another place it doesn't belong then adding a superfluous layer of abstraction over the top.
To demonstrate this, imagine a code base where every responsibility is refactored into a single service object with a static convenience method. This might result in code like this:
result = DoThing.with(param: param) DoAnotherThing.with(param: result.thing) # etc...
As each service object is a class, it is part of the global namespace and - having a static method - can be called without any explicit object instantiation. If everything is a service object, that's suddenly not very OO. Plus, although each service might only be a few lines long, it is wrapped in an entire class and file of its own. This is arguably harder to read and will be difficult to maintain. Although responsibilities are still encapsulated, there may be other more appropriate patterns to choose from.
Service objects should be high-level components
Typically, a service object should occupy a layer close to the application interface. It often wraps a sequence of interactions with lower-level components that satisfy a particular business context, for example
Prefer domain language to name service objects
The domain of the service object should be the domain of the application rather than a lower-level abstraction. So the name of the service can be a clue: it should naturally incorporate domain language. Hence
CreateNewUser might be a good service object candidate, where
InitializeModel might be suspicious. If the code you are refactoring is at a lower level of abstraction than the application domain, then it's probably better to look for an alternative pattern.
Locate service objects in an application folder
For Rails/ Sinatra type projects where you have an
app folder, your service objects should happily live in
app/services. If your service objects are extracted from models and controllers, this should be fairly natural.
Use service objects to wrap procedural steps
Even with well designed object-oriented code, there are often some procedural steps, especially in objects like controllers. A controller's job is to manage that bit between the model and view layers. In Rack-based applications, it is also dealing with elements of the communication layer: sessions, cookies, parameters and so on. Let's take a simple action like creating a new user. This might happen on a post request to a
/users path. The procedural steps might be:
- validate signed-in user has permissions to create a new user
- initialize a new user model with the posted parameters
- validate the model
- save the model
- if the model saves successfully:
- send a welcome email to the user
- render/redirect to the success view/path
- render/redirect to the failure view/path
In a fat controller, all of these steps might be coded in a single method or action. But some of them could be encapsulated in a service. The controller is responsible for rendering and/or redirecting. It's probably also responsible for identifying the signed-in user from the session (usually by implementing an authentication framework). But the other steps could be in a
CreateNewUser service, that takes the current user and params as arguments; e.g.:
result = CreateNewUser.with(current_user: current_user, params: params[:user]) @user = result.user if result.successful? # render/redirect to the success view/path else # render/redirect to the failure view/path end
Here, the controller retains its usual responsibilities, but calls upon the service object to take responsibility for the business logic. The business logic is easier to test in isolation (by testing the service object) and all we need to test of the controller is that it calls the service, passing the correct parameters.
Think of a service object as a concierge. It has all the right connections and knows exactly how to get a particular thing done. You just make the request to it and pass the relevant information.