"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
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
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
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
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:
Top comments (0)