Using Service Objects In Code Design
Bài đăng này đã không được cập nhật trong 7 năm
The more code we add to our rails app, the more concern we start to feel that our code become messy. In fact, to write clean code does not only mean to write unduplicated, well-refactored code, but also with a clear, well-connected, easy to understand logics in mind. Refactoring code to its proper place, keeping each part do what it is designed to do and nothing else. Large-scaled rails app cannot survive without clean codes. Using service object means refactoring codes to their proper places that acts as a services to other component such as controllers and models, so that controllers just "control" and "use services", whereas models just concern with the business logic. service object is just plain old ruby object or in short, PORO.
Understanding Services
I think in order to get a good grasp of why we need to write code using services at all, we need to understand about domain driven design. I'm going to quote some comments about DDD from stackoverflow since it makes a pretty good explanation of DDD.
DDD is about trying to make your software a model of a real-world system or process. In using DDD, you are meant to work closely with a domain expert who can explain how the real-world system works. For example, if you're developing a system that handles the placing of bets on horse races, your domain expert might be an experienced bookmaker.
Between yourself and the domain expert, you build a ubiquitous language (UL), which is basically a conceptual description of the system. The idea is that you should be able to write down what the system does in a way that the domain expert can read it and verify that it is correct. In our betting example, the ubiquitous language would include the definition of words such as 'race', 'bet', 'odds' and so on.
This coding design says that each part of codes live in its own domain so that the expert of each domain can easily verify that it is correct. And these are several pattern that DDD recocommend:
- Repository, a pattern for persistence (saving and loading your data, typically to/from a database)
- Factory, a pattern for object creation
- Service, a pattern for creating objects that manipulate your main domain objects without being a part of the domain themselves
So services normally do not live in the main domain but acts upon the main domain objects.
The standard rails app comes with 6 folders in folder app/
that are:
app
assets
controllers
helpers
mailers
models
views
But this files structure should not limit our imagination in creating new folder and files to fullfil our goal in using service object model. We'll get into a few examples to make things clear:
Example with rails API
Suppose in we have an api about a blog with a post controller which goes something like this:
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [:show, :update, :destroy]
def index
@posts = Todo.all
json_response(@posts)
end
def create
@post = Post.create!(post_params)
json_response(@post, :created)
end
def show
json_response(@post)
end
def update
@post.update(post_params)
head :no_content
end
def destroy
@post.destroy
head :no_content
end
private
def post_params
params.permit(:title, :content, :created_by)
end
def set_post
@post = Post.find(params[:id])
end
end
You see that I am using methods json_response which I'll put in folder app/controllers/concerns
in file response.rb
.
# app/controllers/concerns/response.rb
module Response
def json_response(object, status = :ok)
render json: object, status: status
end
end
Here, what json_response
do is to respond with json and http status code. we somehow seperate the 'concerns' from the controller.
Putting method json_response
in the file app/controllers/concerns/response.rb
seems just the right place. The logic become clearer.
One more thing you notice that in our PostsController
, you don't see if
statement such as:
if @post.save
...
else
...
end
This is because we have created an ExceptionHandler
module to deal with it.
# app/controllers/concerns/exception_handler.rb
module ExceptionHandler
# provides the more graceful `included` method
extend ActiveSupport::Concern
included do
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
rescue_from ActiveRecord::RecordInvalid do |e|
json_response({ message: e.message }, :unprocessable_entity)
end
end
end
In this way when set_post
is called to find a post by id and the record does not exist, ActiveRecord
will throw an exception ActiveRecord::RecordNotFound
. We'll rescue from this exception and return a 404 message. In our create
method in PostsController
, note that we're using create!
instead of create
. This way, the model will raise an exception ActiveRecord::RecordInvalid
.
Both modules Response
and ExceptionHandler
are included in file app/controllers/application_controller.rb
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Response
include ExceptionHandler
end
By creating services, codes become reuseable. This is the goal of most great code; we want some snippet of codes to be reuseable. Even if we move the code to other applications, we just need minimal change. I think we can think of Gemfile as an extreme form of services too. Let's continue further with other examples.
Examples with authentications
Suppose we use gem jwt
for token authentication. Here is the link to the gem documentation https://github.com/jwt/ruby-jwt
A clean way to implement this is to first create a singleton class JasonWebToken
to encode and decode token based on expiration date and user_id
. And the best place to put this file is app/lib
since it is not domain-specific.
# app/lib/json_web_token.rb
class JsonWebToken
HMAC_SECRET = Rails.application.secrets.secret_key_base
def self.encode(payload, exp = 24.hours.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, HMAC_SECRET)
end
def self.decode(token)
body = JWT.decode(token, HMAC_SECRET)[0]
HashWithIndifferentAccess.new body
# rescue from expiry exception
rescue JWT::ExpiredSignature, JWT::VerificationError => e
# raise custom error to be handled by custom handler
raise ExceptionHandler::ExpiredSignature, e.message
end
end
We updated the ExceptionHandler
module to handle the error with JWT
encoding and decoding. Remember its location? It's app/controllers/concerns/exception_handler.rb
.
module ExceptionHandler
extend ActiveSupport::Concern
# Define custom error subclasses - rescue catches `StandardErrors`
class AuthenticationError < StandardError; end
class MissingToken < StandardError; end
class InvalidToken < StandardError; end
included do
rescue_from ActiveRecord::RecordInvalid, with: :four_twenty_two
rescue_from ExceptionHandler::AuthenticationError, with: :unauthorized_request
rescue_from ExceptionHandler::MissingToken, with: :four_twenty_two
rescue_from ExceptionHandler::InvalidToken, with: :four_twenty_two
rescue_from ActiveRecord::RecordNotFound do |e|
json_response({ message: e.message }, :not_found)
end
end
private
def four_twenty_two(e)
json_response({ message: e.message }, :unprocessable_entity)
end
def unauthorized_request(e)
json_response({ message: e.message }, :unauthorized)
end
end
Let's also create another service AuthenticationUser
to handle the authentication procedure. What should we put this class? Since it does authentication service, let's put it in app/auth/authentication_user.rb
class AuthenticateUser
def initialize(email, password)
@email = email
@password = password
end
def call
JsonWebToken.encode(user_id: user.id) if user
end
private
attr_reader :email, :password
def user
user = User.find_by(email: email)
return user if user && user.authenticate(password)d
raise(ExceptionHandler::AuthenticationError, "Invalid Credentials")
end
end
And now we can use this in AuthenticationController
like this:
# app/controllers/authentication_controller.rb
class AuthenticationController < ApplicationController
def authenticate
auth_token = AuthenticateUser.new(auth_params[:email], auth_params[:password]).call
json_response(auth_token: auth_token)
end
private
def auth_params
params.permit(:email, :password)
end
end
Now we have a clean and slim AuthenticationController
. Authentication controller just control by using Authentication service. Now we can see that by using service object, we can create a reuseable code with logic that is easy to follow.
We can easily reuse these code with little modification.
Example with Registration
Here is another good example I have found on the web. Suppose that in registering a new user, We often do something else, such as sending a welcoming email to the user mailbox. Therefore it is usually wise to create a NewRegistrationService which can perform other services. This service lives in the file app/services/new_registration_service.rb
.
class NewRegistrationService
....
....
def call(param)
...
send_welcome_email
...
end
private
....
def send_welcome_email
WelcomeEmailMailer.welcome_email(@user).deliver_later
end
...
end
So that we can use the NewRegistrationService
like this:
result = NewRegistration.build.call({some_params})
if result
redirect_to root_path(result.user)
else
redirect_to last_path(result.user), notice: 'Error saving record'
end
Summary
Let make a good summary about service object: Service objects encapsulates single process of our business. They take all collaborators (database, logging, external adapters like Facebook, user parameters) and performs a given process. Services belongs to our domain - They shouldn’t know they’re within Rails or webapp!
We get a lot of benefits when we introduce services, including:
-
Ability to test controllers - controller becomes a really thin wrapper which provides collaborators to services - thus we can only check if certain methods within controller are called when certain action occurs,
-
Ability to test business process in isolation - when we separate process from its environment, we can easily stub all collaborators and only check if certain steps are performed within our service.
-
Lesser coupling between our application and a framework - in an ideal world, with service objects we can achieve an absolutely technology-independent domain world with very small Rails part which only supplies entry points, routing and all 'middleware’. In this case we can even copy our application code without Rails and put it into, for example, desktop application.
-
They make controllers slim - even in bigger applications actions using service objects usually don’t take more than 10 LoC.
-
It’s a solid border between domain and the framework - without services our framework works directly on domain objects to produce desired result to clients. When we introduce this new layer we obtain a very solid border between Rails and domain - controllers see only services and should only interact with domain using them.
All rights reserved