SRP Series · 1 of 7
Service Objects
Where the pattern comes from, what "one reason to change" actually means inside a Rails app, and how Mastodon uses PostStatusService to keep its controllers thin.
Where this rule comes from
The Single Responsibility Principle is the S in SOLID. Robert C. Martin named it in his 2003 book Agile Software Development, Principles, Patterns and Practices, but the underlying idea is older than that. In the 1970s, computer scientists like David Parnas and Larry Constantine wrote about cohesion (how tightly the contents of a module belong together) and coupling (how many other modules a module depends on). The rule of thumb they arrived at was: build modules that are high in cohesion and low in coupling. A module whose pieces all serve one purpose is easier to read, easier to test, and easier to change without breaking the rest of the system.
Martin's contribution was to give the rule a memorable single-sentence form: a class should have only one reason to change. A "reason to change" here does not mean a code commit. It means a stakeholder, a feature area, a part of the business. If the marketing team changes its mind about the welcome-email copy, that should touch one file. If the payments team changes the charging rules, that should touch a different file. When the same class gets edited for both, the class is mixing two reasons to change, and every edit risks breaking the other one's behavior.
In a Rails app, the place this rule fails first is the controller. Rails gives you a default home for any logic that does not naturally belong to a single model: the controller action. Over time, the controller action grows. It saves a record, parses some input, fires a job, sends a notification, updates a counter, queues an email. Each of those pieces has a different team behind it, a different reason it might change, a different failure mode. The class still has one name and one file path, but it now answers to six different stakeholders.
The service object is the simplest tool senior Rails developers reach for to clean this up. The rest of this lesson is about exactly what it looks like and when to use it.
The anti-pattern
Picture a small social app, a few months in. A user posts a status. The product team has been adding features: mentions notify the people mentioned, posts fan out to followers, a counter on the user's profile shows their post count, mentions also trigger a delayed email digest at the end of the day. The controller that handles posting looks something like this:
class StatusesController < ApplicationController
def create
@status = current_user.statuses.build(status_params)
if @status.save
# detect mentions in text
mentioned_logins = @status.text.scan(/@([a-z0-9_]+)/i).flatten
mentioned_logins.each do |login|
target = User.find_by(login: login)
Notification.create!(user: target, status: @status, kind: :mention) if target
end
# schedule fan-out to followers
FanOutWorker.perform_async(@status.id)
# bump counter cache
current_user.increment!(:statuses_count)
# schedule grouped email
MentionMailerWorker.perform_in(5.minutes, @status.id)
redirect_to @status
else
render :new
end
end
end Read through it. The controller is doing six things: persisting a status, parsing mentions out of free-form text, creating notification records, scheduling fan-out, bumping a counter, scheduling a mailer. Six things means six possible reasons this file gets edited in the future, and each of those reasons belongs to a different person in the company. When the email team wants to change the delay from five minutes to ten, they end up reading a controller action that also handles counter caches and string parsing.
There is a second problem that becomes obvious the day someone wants to post a status from somewhere other than this HTTP endpoint. An importer wants to backfill historical statuses from another platform. A daily job auto-posts a "what you did yesterday" recap on the user's behalf. A Rake task seeds demo data. None of those callers want to instantiate a controller and pretend to be an HTTP request, but the only place the "posting a status" logic lives is inside that controller action. The choice becomes: either duplicate the logic, or call the controller from a non-HTTP context, both of which are bad.
How Mastodon solves it
Mastodon has the same problem on a much larger scale (millions of statuses posted across the federated network), and they reach for a service object. The whole "post a status" operation lives in a single class at app/services/post_status_service.rb. Here is the start of that file:
# mastodon/app/services/post_status_service.rb · @bdad4f78
# License: AGPL-3.0
class PostStatusService < BaseService
include Redisable
include Lockable
include LanguagesHelper
# How much to delay sending an e-mail about a new post,
# to allow grouping multiple posts
EMAIL_DISTRIBUTION_DELAY = 5.minutes.freeze
# Post a text status update, fetch and notify remote users mentioned
# @param [Account] account Account from which to post
# @param [Hash] options
# ...
end
A few things to notice. The class inherits from BaseService, a tiny parent class Mastodon uses to give every service the same shape. The constant EMAIL_DISTRIBUTION_DELAY sits where it is actually used, not buried in the controller or a random initializer. The mixins (Redisable, Lockable) give this one class the helpers it needs without polluting unrelated classes.
The real entry point is a method called #call(account, options). Every Mastodon service has the same surface: one verb in the class name, one #call, the actor as the first argument, options as the second, and the resulting record as the return value. Once you have read three of these files, you can guess the shape of any of them without opening it.
The controller, with the service in place, gets thin:
class StatusesController < ApplicationController
def create
@status = PostStatusService.new.call(current_user.account, status_params)
redirect_to @status
rescue ActiveRecord::RecordInvalid
render :new
end
end Three lines of orchestration. The controller's job is now exactly what its name implies: translate an HTTP request into a service call and translate the result back into an HTTP response. The 60 lines of mention parsing, fan-out scheduling, counter bumping, and email queueing moved into the service. The controller is back to having one reason to change: the way HTTP requests and responses are shaped.
Why this design holds up
Three concrete benefits come out of the move, and each one maps to a specific senior pain that the original controller had.
The service is callable from anywhere. The importer that backfills statuses from another platform calls PostStatusService.new.call(some_account, text: ...). The daily auto-post job does the same. The Rake task does the same. There is no temptation to fake an HTTP request, and no risk of one caller silently skipping the fan-out because they did not know about it. Every entry point gets the same business logic, because there is only one.
Tests get fast and focused. A service-object test looks like PostStatusService.new.call(alice, text: "hi") followed by assertions on the returned record, the notifications that were created, and the jobs that were enqueued. No request spec, no controller cycle, no view rendering. The same coverage runs in roughly a tenth of the time. The team learns to add coverage for new behavior at the service level, because that is where the cost of doing so is lowest.
Each reason to change has a home. When the email team wants to retune the grouping delay, the file they open is PostStatusService and the lines they read are only about email grouping. The mention parser is in a different region of the same file (or, more often, in a small collaborator class). The counter cache update is in another. Each piece can be changed with confidence that the others stay intact, because the responsibilities are physically separated inside the class.
The convention that makes it scale
Mastodon has hundreds of service objects under app/services/. The reason that scales is a small but unbroken convention: every service in the directory has the same shape, so a developer can predict the surface of one they have never seen. The convention is roughly:
- The class name is a verb followed by
Service:PostStatusService,FollowService,DeleteAccountService,UpdateStatusService. The class describes an action, not a thing. - The public entry point is one method,
#call. The actor (the user or account performing the action) is the first argument. Options come second. - The return value is the resulting record, or a small result struct if there are multiple useful values. It is not a boolean. Callers should know what was created, not only that something was.
- Errors that should bubble (validation failures, authorization problems) are raised as exceptions, not returned as
false. The controller catches the ones it cares about; everything else propagates.
The specific convention matters less than the consistency. Pick one shape for your app and hold the line. New teammates can open any service file and read it in seconds because they already know where #call is going to be and what it is going to return.
When you should not extract
A real failure mode of seniors who like patterns is extracting service objects too early. If the controller action is genuinely four lines long (find a record, update it, save it, redirect), extracting UpdateRecordService adds a layer of indirection that someone reading the code has to traverse, without making anything else better. The pattern is not "the file is too long, move it"; the pattern is "this action mixes reasons to change, separate them."
Signals that an action is earning a service object:
- It coordinates more than one model. Posting a status touches the Status record, the Notification records, the User counter, the FanOutWorker job, the MentionMailerWorker job. Five collaborators, four of them background.
- The same action is invoked from more than one entry point. Controller, plus a background job that does it on a schedule, plus a Rake task, plus a console session. Each new caller is a reason the action needs to live somewhere reusable.
- The action is hard to test without booting a full controller cycle. If the test would otherwise need to fake a request, sign in a user, render a view, and read flash messages, you are paying that cost for the wrong reason.
One nuance: service objects are not the rule "if the model is fat, move stuff out of it." If the logic is genuinely about a single User and only a single User, an instance method on User is often the better home. The service is for actions that pull together multiple collaborators, where no single model is the obvious owner.
The principle at play
The Single Responsibility Principle is a coupling rule dressed up as a class-design rule. When a class has two reasons to change, two stakeholders are coupled through that file, even if their concerns have nothing to do with each other. A change to one can break the other. The service object breaks that accidental coupling by giving each reason to change its own physical home.
The deeper move, the one Parnas was pointing at in the 1970s, is that software is easier to change when the change boundaries match the world's change boundaries. When the marketing team's decisions and the payments team's decisions live in different files, both teams can move at their own speed. When their decisions live in the same controller action, every change becomes a coordination problem.
Service objects are a Rails-flavored way of putting that principle into practice. Pick the verb the business uses ("post a status," "issue a refund," "invite a teammate"), make it a class, give it one #call method, and let every caller in the system route through that one entry point.
Practice exercise
Find a real extraction candidate in code you have access to.
- Run
find app/controllers -name "*_controller.rb" | xargs wc -l | sort -rn | head -5to find your longest controllers. - Open the longest one. Find the longest action.
- Count the things that action does that are not "translate HTTP into a model call, translate the result back into a response." Mention parsing, querying other models, enqueuing jobs, sending mail, updating counters all count.
- If the count is three or more, that action is a service-object candidate. Sketch the service name (a verb followed by
Service) and what its#callsignature would be. - Bonus: search for
grep -rn "current_user\." app/sidekiq app/jobs lib/tasks. If a Sidekiq job or a Rake task is reaching into the same logic the controller uses, that is direct evidence the service should already exist.
The point of the exercise is not to refactor. It is to train your eye to see "this is more than one reason to change" without thinking about it. Once you can spot it, the extraction part takes about an afternoon.