+2

Using Service Objects In Code Design

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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí