DEV Community

Junko T.
Junko T.

Posted on • Edited on

Slim down models and controllers using Rails Service Objects

Alt Text
Designed by tartila / Freepik

"Fat models, skinny controllers" is one of the best practices for refactoring Rails applications. As an application grows larger and becomes more complex, however, there will be more business logic in models and it gets harder to apply the single-responsibility principle. This is when we should consider extracting the business logic into Service Object to achieve "Skinny models, skinny controllers".

What is a Service Object?

Remember encapsulation in OOP? A service object in Rails is basically encapsulating business logic as a Ruby object which is designed to execute one single action. A service object can be called from anywhere, and it is also easy to test. Consider using it when:

  • the action is complex
  • the action is called in multiple models
  • the action interacts with an external service/API

Extract a service object

Let's see it in action. I have created a Rails application that generates hashtags using Google Cloud Vision API when a user posts an image. I am using the sample code I introduced in the previous post.

I have get_tags method in the Post model:

# app/models/post.rb

class Post < ApplicationRecord
  def get_tags(image)
    api_key = ENV['GOOGLE_API_KEY']
    api_url = "https://vision.googleapis.com/v1/images:annotate?key=#{api_key}"
    base64_image = Base64.strict_encode64(File.new(image, 'rb').read)

    body = {
      requests: [{
        image: {
          content: base64_image
        },
        features: [
          {
            type: 'LABEL_DETECTION',
            maxResults: 5
          }
        ]
      }]
    }.to_json

    uri = URI.parse(api_url)
    https = Net::HTTP.new(uri.host, uri.port)
    https.use_ssl = true
    request = Net::HTTP::Post.new(uri.request_uri)
    request['Content-Type'] = 'application/json'
    response = https.request(request, body)
    results = JSON.parse(response.body)
    results['responses'][0]['labelAnnotations'].each do |result|
      tag = Tag.find_or_create_by(name: result['description'])
      PostTag.create(post_id: id, tag_id: tag.id)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

The model knows too much about how to tag the images. Let's refactor it extracting the action as a service object.

First, create app/services folder and tag_generator.rb below it. Move all the logic from get_tags to a class .call method:

# app/services/tag_generator.rb

class TagGenerator
  def self.call(post, image)
    api_key = ENV['GOOGLE_API_KEY']
    api_url = "https://vision.googleapis.com/v1/images:annotate?key=#{api_key}"
    base64_image = Base64.strict_encode64(File.new(image, 'rb').read)

    body = {
      requests: [{
        image: {
          content: base64_image
        },
        features: [
          {
            type: 'LABEL_DETECTION',
            maxResults: 5
          }
        ]
      }]
    }.to_json

    uri = URI.parse(api_url)
    https = Net::HTTP.new(uri.host, uri.port)
    https.use_ssl = true
    request = Net::HTTP::Post.new(uri.request_uri)
    request['Content-Type'] = 'application/json'
    response = https.request(request, body)
    results = JSON.parse(response.body)
    results['responses'][0]['labelAnnotations'].each do |result|
      tag = Tag.find_or_create_by(name: result['description'])
      PostTag.create(post_id: post.id, tag_id: tag.id)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Now it is a class method of the TagGenerator class. Let's update post_controller.rb as well.

Before:

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  def create
    @post = current_user.posts.new(post_params)

    if @post.save
      @post.get_tags(params[:post][:image])
      redirect_to @post
    else
      render :new
    end
  end
Enter fullscreen mode Exit fullscreen mode

After:

# app/controllers/posts_controller.rb

class PostsController < ApplicationController
  def create
    @post = current_user.posts.new(post_params)

    if @post.save
      TagGenerator.call(@post, params[:post][:image])
      redirect_to @post
    else
      render :new
    end
  end
Enter fullscreen mode Exit fullscreen mode

Now we didn't just downsize the Post model but also made the tag generator action maintainable and reusable.

This is the result of executing TagGenerator.call. The auto-generated hashtags from the image:
Alt Text
Alt Text


Reference:
7 Patterns to Refactor Fat ActiveRecord Models

Top comments (0)