Decoupling from Rails [Part 1] - Repository and UseCase
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 executescall
method on it
You can see diff here.
Thanks again for those who suggested ways to improve this code!