Overusing Service Objects

TL;DR Service objects are commonly used as the default unit of business logic in web applications. They’re applied in more situations than they’re a fit for, and they carry costs that are often overlooked. By sticking to a resource-based design backed by integration tests, you and your team will be able to make safe progress at a faster clip.

Suppose we’re working on an app that deals with accounts. We have a very simple controller that handles account creation and updating account information:

class AccountsController < ApplicationController
  def create
    @account = Account.new(account_params)

    if @account.save
      # ...
    end
  end

  def update
    @account = Account.find_by(id: params[:id])

    if @account.update(account_params)
      # ...
    end
  end
end

The product team has asked us to add some new features: account tier upgrades, downgrades and the ability to add funds to an account.

As the code base has begun to grow, our team has decided to avoid “fat” models and controllers in favor of using service objects. If you’re unfamiliar with service objects, have no fear; we’ll see what they look like shortly. We chose this path for a few reasons:

  1. Moving logic out of Rails-specific constructs like models and controllers gives us the flexibility to swap out components and logic as we see fit. Relying on fewer framework dependencies allows us to change things like our persistence layer without needing to change our business logic.
  2. Testing service objects is far easier and faster than writing HTTP-level integration tests. Code that focuses on the pure business logic is easier to reason about and hitting real HTTP endpoints is much slower than testing our logic in isolation. Quick feedback loops are important for making consistent progress.
  3. Service objects provide us with reusable business logic. We don’t have to repeat ourselves all over the app if we can just make a call to a previously written service object.
  4. Controllers and models tend to accumulate logic that doesn’t belong - they become messy junk drawers as the code base grows.

We’ll revisit our reasons a bit later, but for now we have code to write and deadlines to hit. Let’s start by adding account tier upgrades. First, let’s define the API we’d like to use in the controller:

class AccountsController < ApplicationController
  # ...

  def upgrade_tier
    @account = Account.find_by(id: params[:id])
    result = AccountTierUpgrade.call(@account)

    if result.success?
      # ...
    end
  end
end

And the service object itself:

class AccountTierUpgrade
  def self.call(account)
    obj = new(account)
    obj.call
    obj
  end

  def initialize(account)
    @account = account
  end

  def call
    if some_conditions_are_met? && @account.update(tier: next_tier)
      @status = :success
    else
      @status = :failure
    end
  end

  def success?
    @status == :success
  end

  def failure?
    @status == :failure
  end

  private

  def some_conditions_are_met?
    # ...
  end

  def next_tier
    # ...
  end
end

Great! Our controller is a thin wrapper around our business logic, we haven’t polluted our models, and we can easily write a test that doesn’t hit the HTTP stack. We continue to add features and clean up our old code until our controller looks something like this:

class AccountsController < ApplicationController
  before_action :set_account, except: [:create]

  def create
    @account = Account.new(account_params)


    if @account.save
      # ...
    end
  end

  def update
    if @account.update(account_params)
      # ...
    end
  end

  def upgrade_tier
    result = AccountTierUpgrade.call(@account)

    if result.success?
      # ...
    end
  end

  def downgrade_tier
    result = AccountTierDowngrade.call(@account)

    if result.success?
      # ...
    end
  end

  def add_funds
    result = AddFundsToAccount.call(@account)

    if result.success?
      # ...
    end
  end

  private

  def set_account
    @account = Account.find_by(id: params[:id])
  end
end

I like to think of these as “shallow” service objects. Right now, they’re responsible for one or two state changes, but we’re betting that they will take on more responsibility in the future. We use service objects to protect our controller becoming fatter over time as we add more features. As service objects become “deeper”, they make more changes to the state of your app.

Let’s take the earlier example of upgrading an account’s tier. The sales team would like two things to happen when a customer upgrades their account tier: a thank you email is sent to the customer and the CRM is updated to reflect their new tier. Our service object might start to look something like this:

class AccountTierUpgrade
  def self.call(account)
    obj = new(account)

    obj
  end

  def initialize(account)
    @account = account
  end

  def call
    if some_conditions_are_met? && @account.update(tier: next_tier)
      send_thank_you_email
      update_crm
      @status = :success
    else
      @status = :failure
    end
  end

  # ...

  private

  def send_thank_you_email
    SendUpgradeThankYouEmail.call(account)
  end

  def update_crm
    CRMAccountUpdate.call(account)
  end

  # ...
end

Was it worth it?

I have a feeling that it wasn’t. To illustrate, let’s address the reasons for adopting service objects that we talked about earlier.

Moving logic out of Rails-specific constructs like models and controllers gives us the flexibility to swap out components and logic as we see fit.

There are very few situations that I’ve encountered where swapping out fundamental application components was necessary. You’re probably not going to change your ORM, database, or middleware stack. If doing so is truly necessary, you might be in a place where re-building your entire application may cost less. Our issue here seems to be that we find improbable events too salient. It’s not worth spending expensive developer time now to mitigate issues that may never come to pass.

Testing service objects is far easier and faster than writing HTTP-level integration tests.

There’s no doubt that avoiding the HTTP layer will speed up your tests. This will lead to tighter feedback loops when running tests, which can be a big win for ensuring the app isn’t broken as the team quickly adds new features.

However, we discover some major costs to this approach when we examine the claim that it’s “easier” to write tests for service objects. For one thing, we’ve introduced an entirely new, custom convention that may look different across projects. New team members now have a new abstraction layer to deal with and that’s going to take time to learn. This is especially taxing on folks newer to the industry. They’ve probably pushed themselves hard to overcome the conceptual barriers to building a functioning web app - now we’re putting up more barriers to them being productive and providing value.

Also, the service objects are going to be harder to test as they become responsible for more state changes. Eventually, the tests for these service objects need to verify as much state as an HTTP-level integration test would.

Service objects provide us with reusable business logic

Sure, when they are “shallow” and don’t change much state. When they become more powerful and change more state, they become harder and harder to re-use. Our AccountTierUpgrade service object began coordinating work being done in other service objects in order to get its own job done. As the graph of service object dependencies grows, the harder it becomes to re-use any one of them. There will inevitably come a time when I want to upgrade an account’s tier, but not send a thank you email. We’ve effectively just wrapped a unique procedure up in a class and called it a cleaner design.

Controllers and models tend to accumulate logic that doesn’t belong - they become messy junk drawers.

It depends on how you use them! If you take the approach that we did above and add new actions to the same controller for each feature that is related to accounts, then the controller will definitely become unwieldy. We can avoid many of the costs by using a different approach: resource-driven design.

Introducing some healthy constraints

What if we set a rule for ourselves that we aren’t allowed to use non-REST action names for controller actions? This would force us to think about our application in terms of a repeatable, predictable set of available places to put our business logic. Now, you will rarely need to debate where to put things. New team members will be able to navigate the code base quickly, even if they’ve only gone through the Rails Tutorial.

Let’s try re-implementing the features we set out to complete earlier, starting with account tier upgrades.

Could we add the new functionality into the existing update action in the AccountsController? Possibly, but we’d have to introduce conditional logic since the account tier upgrades may be happening on a different page from the normal account update flow. That doesn’t feel right, so where do we go from here? Well if we think in terms of RESTful resources, are we updating something? Deleting something? Creating something? Ah, maybe we’re creating an account upgrade! The next step is easy: create a new controller for the resource we’ve discovered.

class Accounts::TierUpgrades < ApplicationController
  def create
    @account = Account.find_by(id: params[:id])

    # ...
  end
end

Now that we know this controller can’t have more than seven actions (in practice, it’s usually two to three), we don’t need to be too worried about bloat. If we back this controller with HTTP-level integration tests (which isn’t a bad idea), I’d even go so far as to say that we can safely add some private methods that encapsulate the logic that was once stored in the service object:

class Accounts::TierUpgrades < ApplicationController
  def create
    @account = Account.find_by(id: params[:id])

    if some_conditions_are_met? && @account.update(tier: next_tier)
      send_thank_you_email
    end
  end

  private

  def some_conditions_are_met?
    # ...
  end

  def next_tier
    # ...
  end

  def send_thank_you_email
    # ...
  end

  def update_crm
    # ...
  end
end

You may be concerned about the proliferation of controllers that will inevitably happen when taking this approach. That’s ok, and it’s completely normal! Storing our logic in conventional containers, even if it means dealing with many of them, allows the team to make more consistent progress.

Looking to the past

With some good constraints and resource-first thinking, our application became much more predictable. What I’m left wondering is, how did we end up here? Why are service objects over-used? Where did they come from in the first place?

It seems to have started in Eric Evans’ famous book, Domain Driven Design. In it, he introduces the concept of “services”: standalone objects that don’t store instance state. They are defined only in terms of what they can do for a domain object, hence the name. He goes on to explain that most “services” are used as “infrastructure”, like sending an email or a push notification. These actions don’t directly relate to the business logic of you application, but perform side-effects on behalf of that business logic. He also says that it’s possible to use “domain services” but that you should try as hard as you can to find the real domain objects (think models), before trying that.

This illustrates a much more conservative use of service objects than I’ve seen around the web, and I think it’s far more sensible. In fact, Rails has some services (under Eric Evans’ definition) built in! ActiveJob will take any computation that you don’t want to perform inside the request/response cycle and offload it to the background job processor of your choice. ActionMailer sends email from your application. These services are responsible for infrastructure-type tasks that are independent of, and in service to, the actual domain objects in your application.

Last updated:

← Back