Are you putting your business logic at correct place?
Putting business logic at correct place is a nightmare for beginners
This blog is a continuation of the last one where we built an
expense manager
application with business logic scattered in the controller.
Design pattern
Design pattern is a set of rules that encourage us to arrange our code in a way that makes it more readable and well structured. It not only helps new developers onboard smoothly but also helps to find bugs. ๐
In Rails' world, there are a lot of design patterns followed like Service Objects, Form Objects, Decorator, Interactor, and a lot more.
Interactor
In this blog, we are going to look at Interactor using interactor gem. It is quite easy to integrate into an existing project.
- every
interactor
should follow SRP(single responsibility principle). interactor
is provided with a context which contains everything that theinteractor
needs to run as an independent unit.- every
interactor
has to implement acall
method which will be exposed to the external world. - if the business logic is composed of several independent steps, it can have multiple
interactors
and oneorganizer
that will call all theinteractors
serially in the order they are written. context.something = value
can be used to set something in the context.context.fail!
makes the interactor cease execution.context.failure?
& andcontext.success?
can be used to verify the failure and success status.- in case of
organizers
if one of theorganized interactors
fails, the execution is stopped and the laterinteractors
are not executed at all.
Let's refactor our expense manager
We can create interactors for the following:
- create user
- authenticate user
- process a transaction
- create a transaction record
- update user's balance
Create a directory named interactors
under app
to keep the interactors
.
app/interactors/create_user.rb
class CreateUser
include Interactor
def call
user = User.new(context.create_params)
user.auth_token = SecureRandom.hex
if user.save
context.message = 'User created successfully!'
else
context.fail!(error: user.errors.full_messages.join(' and '))
end
end
end
app/interactors/authenticate_user.rb
class AuthenticateUser
include Interactor
def call
user = User.find_by(email: context.email)
if user.authenticate(context.password)
context.user = user
context.token = user.auth_token
else
context.fail!(message: "Email & Password did not match.")
end
end
end
app/interactors/process_transaction.rb
class ProcessTransaction
include Interactor::Organizer
organize CreateTransaction, UpdateUserBalance
end
app/interactors/create_transaction.rb
class CreateTransaction
include Interactor
def call
current_user = context.user
user_transaction = current_user.user_transactions.build(context.params)
if user_transaction.save
context.transaction = user_transaction
else
context.fail!(error: user_transaction.errors.full_messages.join(' and '))
end
end
end
app/interactors/update_user_balance.rb
class UpdateUserBalance
include Interactor
def call
transaction = context.transaction
current_user = context.user
existing_balance = current_user.balance
if context.transaction.debit?
current_user.update(balance: existing_balance - transaction.amount)
else
current_user.update(balance: existing_balance + transaction.amount)
end
end
end
app/interactors/fetch_transactions.rb
class FetchTransactions
include Interactor
def call
user = context.user
params = context.params
transactions = user.user_transactions
if params[:filters]
start_date = params[:filters][:start_date] && DateTime.strptime(params[:filters][:start_date], '%d-%m-%Y')
end_date = params[:filters][:end_date] && DateTime.strptime(params[:filters][:end_date], '%d-%m-%Y')
context.transactions = transactions.where(created_at: start_date..end_date)
else
context.transactions = transactions
end
end
end
Let's now refactor our controllers to use the above interactors
.
app/controllers/users_controller.rb
class UsersController < ApplicationController
skip_before_action :verify_user?
# POST /users
def create
result = CreateUser.call(create_params: user_params)
if result.success?
render json: { message: result.message }, status: :created
else
render json: { message: result.error }, status: :unprocessable_entity
end
end
def balance
render json: { balance: current_user.balance }, status: :ok
end
def login
result = AuthenticateUser.call(login_params)
if result.success?
render json: { auth_token: result.token }, status: :ok
else
render json: { message: result.message }, status: :unprocessable_entity
end
end
private
def user_params
params.require(:user).permit(:name, :email, :password, :balance)
end
def login_params
params.require(:user).permit(:email, :password)
end
end
app/controllers/user_transactions_controller.rb
class UserTransactionsController < ApplicationController
before_action :set_user_transaction, only: [:show]
def index
result = FetchTransactions.call(params: params, user: current_user)
render json: result.transactions, status: :ok
end
def show
render json: @user_transaction
end
def create
result = ProcessTransaction.call(params: user_transaction_params, user: current_user)
if result.success?
render json: result.transaction, status: :created
else
render json: { message: result.error }, status: :unprocessable_entity
end
end
private
def set_user_transaction
@user_transaction = current_user.user_transactions.where(id: params[:id]).first
end
def user_transaction_params
params.require(:user_transaction).permit(:amount, :details, :transaction_type)
end
end
โ
โ
That is it. Our controllers
look much cleaner. Even if someone looks at the project for the first time, they will know where to find the business logic. Let's go through some of the pros & cons of the interactor
gem.
Pros ๐
- easy to integrate
- straightforward DSL(domain-specific language)
- organizers help follow the SRP(single responsibility principle)
Cons ๐
- argument/contract validation not available
- the gem looks dead, no active maintainers
That is it for this blog. It is hard to cover more than one design pattern
in one blog. In the next one, we will see how we can use active_interaction and achieve a much better result by extracting the validations out of the models.
Thanks for reading. Do share your suggestions in the comments down below.