Decoupling from Rails [Part 1] - Repository and UseCase

Subscribe to receive new articles. No spam. Quality content.

Uncle Bob (Robert C Martin) said:

Date frameworks, but not to marry them.

Many Ruby/Rails developers married to Rails. This article will show how to start dating with Rails.

I've created a GitHub repository which should help to understand this step by step guide on how to decouple business logic from Rails.

Let's say we have basic scaffolding for User in Rails application. Typically code of controller would look like this.

We have seen this code many times. Let's try to identify and split application-specific logic and Rails-specific things.

Let's consider create action:

def create
    @user = User.new(user_params)
    if @user.save
      redirect_to user_path(@user), notice: 'User has been created'
    else
      render :new
    end
  end

user_params it's a Rails-specific thing, as well as redirects and rendering. We're going to start decoupling by using Repository pattern to abstract the way we interact with database.

Let's create class UserRepository in "app/repositories" folder which would be responsible for interaction with database:

class UserRepository
  def all
    User.all
  end

  def find(id)
    User.find(id)
  end

  def new_entity(attrs = nil)
    User.new(attrs)
  end

  def save(user)
    user.save
  end

  def delete(user)
    user.destroy
  end
end

Now we can use it in action:

class UsersController < ApplicationController
  # ...
  def create
    @user = repo.new_entity(user_params)
    if repo.save(@user)
      redirect_to user_path(@user), notice: 'User has been created'
    else
      render :new
    end
  end
  # ...
  private
  # ...
  def repo
    @repo ||= UserRepository.new
  end
end

I have specs for UsersController and they're still green. I highly recommend to cover important parts by specs before making such changes.

By using repository we achieved the following benefits:

  • there is no direct reference to User in our code
  • abstracted out a persistence layer
  • we depend on interface of repository, but don't worry about implementation

Let's check what else we can do to split Rails-specific things from business logic.

In fact all we want to do here is: either create user profile if user provided all required params or show error messages.

  def create
    @user = repo.new_entity(user_params)
    if repo.save(@user)
      # redirect_to user_path(@user), notice: 'User has been created'
    else
      # render :new
    end
  end

I've commented out Rails-specific code. Just to show it.

Let's try to rewrite it a little bit:

def create
  success = ->(user) { redirect_to user_path(user), notice: 'User has been created' }
  error = ->(user) { @user = user; render :new }

  @user = repo.new_entity(user_params)
  repo.save(@user) ? success.call(@user) : error.call(@user)
end

We extracted Rails-specific logic into lambdas as possible strategies. Success strategy and error strategy. If we look into these 2 lines of code - there is no code related to Rails:

@user = repo.new_entity(user_params)
repo.save(@user) ? success.call(@user) : error.call(@user)

It's a pure business logic that we want to have: we try to create user and call strategy depending on result. In great talk Architecture the Lost Years by Uncle Bob he calls that part Interactor, I would like to call it UseCase. Some developers prefer to call it ServiceObject, but in general it should be some "doer" that would run business logic. You can think about it as one story that you might have in your backlog: "User should be able to sign up".

Let's extract that business logic into separate class app/use_cases/use_case/user/sign_up.rb and use it in controller:

def create
  success = ->(user) { redirect_to user_path(user), notice: 'User has been created' }
  error = ->(user) { @user = user; render :new }

  UseCase::User::SignUp.new(repo).call(user_params, success: success, failure: error)
end

In this code we still have 2 strategies to call, but we're going to use PORO UseCase::User::SignUp. Naming is a key here, because even from class name we can understand its responsibility: user sign up. It has just one method .call which accepts strategies. Also we passed repository to initializer.

Let's look into implementation of use case:

# app/use_cases/use_case/base.rb
module UseCase
  class Base
    attr_reader :repository

    def initialize(repo)
      @repository = repo
    end
  end
end

# app/use_cases/use_case/user/sign_up.rb
module UseCase
  module User
    class SignUp < UseCase::Base
      def call(attrs, callbacks)
        user = repository.new_entity(attrs)
        repository.save(user) ? callbacks[:success].call(user) : callbacks[:failure].call(user)
      end
    end
  end
end

I've created base class for use case which is responsible for handling repository. But we can extend that if we need so. SignUp use case is responsible for handling our business logic. We can use that from Sinatra or Hanami, it's not Rails-specific code. It's Plain Old Ruby Object. It's testable and flexible enough. It accepts strategies that should respond to call method. In this case it's lambdas, but it could be any object that respond to call method. Use case depends on interfaces, but not on implementation.

Instead of passing strategies we could use wisper gem which allows to implement pub/sub. In that case Use Case would publish events and controller would listen to those events and handle them properly. It's even more flexible way.

You can find implemented use cases for UsersController here.

I'm just playing with this concept, but so far it seems very promising. Use cases have good naming, they do one thing. They don't depend on Rails. It's reusable and testable. We depend just on interfaces.

In next article I'm going to add a little bit of Domain-Driven Design and Hexagonal architecture into this solution. For example we could extract entity User into domain folder, the same we could do for Repository interface. Because currently implementation of Repository depends on ActiveRecord.

I've tried to commit changes as we go, so you can find it here:

You can see all commits here: https://github.com/smakagon/decoupling/commits/master

Thanks for reading and let me know what you think about this idea!

UPDATE

After a great response I got from readers I refactored this implementation a little bit. Here is a list of changes:

  • Removed UseCase::Base
  • Made UserRepository default for all User-related use cases. No need to pass it from controller
  • Added class method call to use cases which creates new instance and executes call method on it

You can see diff here.

Thanks again for those who suggested ways to improve this code!

Subscribe to receive new articles. No spam. Quality content.

Comments