Instantiating Service Objects

… and check why 5600+ Rails engineers read also this

Instantiating Service Objects

In my last blogpost about adapters I promised a more detailed insight into instantiating Adapters & Service Objects. So here we go.

Boring style

class ProductsController
  def create
    metrics = MetricsAdapter.new(METRICS_CONFIG.fetch(Rails.env))
    service = CreateProductService.new(metrics)
    product = service.call(params[:product])
    redirect_to product_path(product), notice: "Product created"
  rescue CreateProductService::Failed => failure
    # ... probably render ...
  end
end

This is the simplest way, nothing new under the sun. When your needs are small, dependencies simple or non-existing (or created inside service, or you use globals, in other words: not passed explicitely) you might not need anything more.

Testing

Ideally we want to test our controllers in simplest possible way. In Rails codebase, unlike in desktop application, every controller action is an entry point into the system. Its our main() method. So we want our controllers to be very thin, instantiating the right kind of objects, giving them access to the input, and putting the whole world in motion. The simplest, the better, because controllers are the hardest beasts when it come to testing.

Controller


describe ProductsController do
  specify "#create" do
    product_attributes = {
      "name" =>"Product Name",
      "price"=>"123.45",
    }

    expect(MetricsAdapter).to receive(:new).with("testApiKey").and_return(
      metrics = double(:metrics)
    )
    expect(CreateProductService).to receive(:new).with(metrics).and_return(
      create_product_service = double(:register_user_service,
        call: Product.new.tap{|p| p.id = 10 },
      )
    )

    expect(create_product_service).to receive(:call).with(product_attributes)

    post :create, {"product"=> product_attributes}

    expect(flash[:notice]).to be_present
    expect(subject).to redirect_to("/products/10")
  end
end

It’s up to you whether you want to mock the service or not. Remember that the purpose of this test is not to determine whether the service is doing its job, but whether controller is. And the controller concers are

These are the things you should be testing, nothing less, nothing more.

However mocking adapters might be necessary because we don’t want to be sending or collecting our data from test environment.

Service

When testing the service you need to instantiate it and its dependencies manually as well.


describe CreateProductService do
  let(:metrics_adapter) do
    FakeMetricsAdapter.new
  end

  subject(:create_product_service) do
    described_class.new(metrics_adapter)
  end

  specify "something something" do
    create_product_service.call(..)
    expect(..)
  end
end

Modules

When instantiating becomes more complicated I extract the process of creating the full object into an injector. The purpose is to make it easy to create new instance everywhere and to make it trivial for people to overwrite the dependencies by overwriting methods.

module CreateProductServiceInjector
  def metrics_adapter
    @metrics_adapter ||= MetricsAdapter.new( METRICS_CONFIG.fetch(Rails.env) )
  end

  def create_product_service
    @create_product_service ||= CreateProductService.new(metrics_adapter)
  end
end
class ProductsController
  include CreateProductServiceInjector

  def create
    product = create_product_service.call(params[:product])
    redirect_to product_path(product), notice: "Product created"
  rescue CreateProductService::Failed => failure
    # ... probably render ...
  end
end

Testing

The nice thing is you can test the instantiating process itself easily with injector (or skip it completely if you consider it to be typo-testing that provides very little value) and don’t bother much with it anymore.

Injector

Here we only test that we can inject the objects and change the dependencies.

describe CreateProductServiceInjector do
  subject(:injected) do
    Object.new.extend(described_class)
  end

  specify "#metrics_adapter" do
    expect(MetricsAdapter).to receive(:new).with("testApiKey").and_return(
      metrics = double(:metrics)
    )
    expect(injected.metrics_adapter).to eq(metrics)
  end

  specify "#create_product_service" do
    expect(injected).to receive(:metrics_adapter).and_return(
      metrics = double(:metrics)
    )
    expect(CreateProductService).to receive(:new).with(metrics).and_return(
      service = double(:register_user_service)
    )

    expect(injected.create_product_service).to eq(service)
  end
end

Is it worth it? Well, it depends how complicated setting your object is. Some of my colleagues just test that the object can be constructed (hopefully this has no side effects in your codebase):

describe CreateProductServiceInjector do
  subject(:injected) do
    Object.new.extend(described_class)
  end

  specify "can instantiate service" do
    expect{ injected.create_product_service }.not_to raise_error
  end
end

Controller

Our controller is only interested in cooperating with create_product_service. It doesn’t care what needs to be done to fully set it up. It’s the job of Injector. We can throw away the code for creating the service.


describe ProductsController do
  specify "#create" do
    product_attributes = {
      "name" =>"Product Name",
      "price"=>"123.45",
    }

    expect(controller.create_product_service).to receive(:call).
      with(product_attributes).
      and_return( Product.new.tap{|p| p.id = 10 } )

    post :create, {"product"=> product_attributes}

    expect(flash[:notice]).to be_present
    expect(subject).to redirect_to("/products/10")
  end
end

Service Object

You can use the injector in your tests as well. Just include it. Rspec is a DSL that is just creating classes and method for you. You can overwrite the metrics_adapter dependency using Rspec DSL with let or just by defining metrics_adapter method yourself.

Just remember that let is adding memoization for you automatically. If you use your own method definition make sure to memoize as well (in some cases it is not necessary, but when you start stubbing/mocking it is).

describe CreateProductService do
  include CreateProductServiceInjector

  specify "something something" do
    create_product_service.call(..)
    expect(..)
  end

  let(:metrics_adapter) do
    FakeMetricsAdapter.new
  end

  #or

  def metrics_adapter
    @adapter ||= FakeMetricsAdapter.new
  end
end

There is nothing preventing you from mixing classic ruby OOP with Rspec DSL. You can use it to your advantage.

The downside that I see is that you can’t easily say from reading the code that metrics_adapter is a dependency of our class under test (CreateProductService). As I said in simplest case it might not be worthy, in more complicated ones it might be however.

Example

Here is a more complicated example from one of our project.

require 'notifications_center/db/active_record_sagas_db'
require "notifications_center/schedulers/resque_scheduler"
require "notifications_center/clocks/real"

module NotificationsCenterInjector
  def notifications_center
    @notifications_center ||= begin
      apns_adapter     = Rails.configuration.apns_adapter
      policy           = Rails.configuration.apns_push_notifications_policy
      mixpanel_adapter = Rails.configuration.mixpanel_adapter
      url_helpers      = Rails.application.routes_url_helpers

      db               = NotificationsCenter::DB::ActiveRecordSagasDb.new
      scheduler        = NotificationsCenter::Schedulers::ResqueScheduler.new
      clock            = NotificationsCenter::Clocks::Real.new

      push = PushNotificationService.new(
        url_helpers,
        apns_adapter,
        policy,
        mixpanel_adapter
      )
      NotificationsCenter.new(db, push, scheduler, clock)
    end
  end
end

Dependor

You might also consider using dependor gem for this.

class Injector
  extend Dependor::Let

  let(:metrics_adapter) do
    MetricsAdapter.new( METRICS_CONFIG.fetch(Rails.env) )
  end

  let(:create_product_service)
    CreateProductService.new(metrics_adapter)
  end
end
class ProductsController
  extend Dependor::Injectable
  inject_from Injector

  inject :create_product_service
  def create
    product = create_product_service.call(params[:product])
    redirect_to product_path(product), notice: "Product created"
  rescue CreateProductService::Failed => failure
    # ... probably render ...
  end
end

The nice thing about dependor is that it provides a lot of small APIs and doesn’t force you to use any of them. Some of them do more magic (I am looking at you Dependor::AutoInject) and some of medium level (Dependor::Injectable) and some almost none magic whatsoever(Dependor::Shorty). You can use only the parts that you like and are comfortable with.

Testing

Injector

The simple way that just checks if things don’t crash and nothing more.

require 'dependor/rspec'

describe Injector do
  let(:injector) { described_class.new }

  specify do
    expect{injector.create_product_service}.to_not raise_error
  end
end

Service

For testing the service you go whatever way you want. Create new instance manually or use Dependor::Isolate.

require 'dependor/rspec'

describe CreateProductService do
  let(:metrics_adapter) do
    FakeMetricsAdapter.new
  end
  subject(:create_product_service) { isolate(CreateProductService) }

  specify "something something" do
    create_product_service.call(..)
    expect(..)
  end
end

That’s it

Thanks for reading. If you liked it and you wanna find out more subscribe to course below.

You might also like