"Fat models, skinny controllers" has been the design style for Rails app development. However, with time this style has been outdated as with code growth the models become too fat to handle.
Why not use concerns?
ActiveSupport concern can be used to extract the fat code out of models and share in multiple models. However ActiveSupport concerns are not useful in much scenarios as we end up including these in our models thus increasing burden.
In such cases we use a special purpose classes called 'service objects'. So in this tutorial we will look how these classes are used.
Service objects:
As SRP (single responsibility principle) focuses on a single responsibility for methods and classes. If our model is handling too many responsibilities, we can extract these out in special classes which focuses on particular bit of business logic each class. Suppose we have some e-commerce website. Where we receive order details from customer, we do some validation and construct some formatted data before sending it to payment gateway. We put this logic in our controller or model which is not directly related to the model. In such scenario we can extract this functionality out and abstract in some special class that only focuses on the processing details. These special classes are called service objects.
Service objects help in organizing the code in a structured way. It makes it easy to understand and debug the code. Also it is easy to test multiple small functionalities than one large functionality.
We keep service objects under app/service folder. The name should explain the functionality of service object. So our service object files look like this in our app:
app/
services/
create_order.rb
register_user.rb
register_user_with_facebook.rb
change_password.rb
Lets take an example of registration process where a user registers to the application. After creating user a welcome email is sent to user. This functionality can be extracted out in a service as below:
class RegistrationMailer
attr_reader :user
def initialize(user)
@user = user
end
class << self
def deliver user
user = new(user)
mail = UserMailer.registration user
mail.deliver
end
end
end
class RegistrationController < ApplicationController
def new
@user = User.new(params[:user])
end
def create
@user = User.new(params[:user])
begin
if @user.save
RegistrationMailer.deliver(@user)
redirect_to @user
else
render 'new'
end
rescue
# Handle exceptions
end
end
end
In above code we extracted the mailer functionality from controller in a service object class. The class is concerned about the mailing functionality only. This is just a simple example, we can abstract the large code in multiple classes.
Lets see little complex example. In one of my projects i implemented layer chatting functionality. Layer is a third party api provider. It gives chatting facility. It store all chat data on their server and exposed apis to retrieve chats. I created a super class Conversation:
class Conversation
attr_reader :game, :user
def initialize(game, user)
@game = game
@user = user
end
def host
game.host
end
def winner
game.winner
end
private
def find_or_create_conversation
has_conversation? ? game_conversation : new_conversation
end
def new_conversation
client = authenticate_layer_user
Layer::Conversation.create(conversation_attrbs, client)
end
def conversation_attrbs
participant = (game.host == user) ? [game.winner.id.to_s] : [game.host.id.to_s]
{
participants: participant,
distinct: true, #create separate conversation
metadata: {game_id: game.id.to_s, host: game.host.id.to_s, winner: game.winner.id.to_s}
}
end
def authorized_for_game?
return false unless (game && game.can_message?(user))
true
end
def has_conversation?
game.conversation_id.present? && game.conversation_url.present?
end
def no_conversation?
!has_conversation?
end
def game_conversation
return nil unless has_conversation?
Layer::Conversation.find(game.conversation_id, authenticate_layer_user)
end
def authenticate_layer_user
Layer::Client.authenticate { |nonce| Layer::IdentityToken.new(user.id, nonce)}
end
end
For sending new message i created a service objects class inheriting Conversation:
class PostMessage < Conversation
attr_reader :message, :errors
def initialize(game, user, message)
super(game, user)
@message = message
@errors = []
end
def process!
if authorized_for_game?
begin
create_chat
true
rescue RestClient::ResourceNotFound, Layer::Exceptions::ObjectDeleted
@errors << "conversation not found"
false
end
else
@errors << "not authorized for chat"
false
end
end
private
def create_chat
conversation = find_or_create_conversation
raise RestClient::ResourceNotFound unless conversation.present?
save_conversation_params(conversation) if no_conversation?
conversation.messages.create({parts: [{ body: message, mime_type: 'text/plain' }]})
end
def save_conversation_params conversation
game.conversation_url = conversation.url
game.conversation_id = conversation.id
game.save
end
end
Now in my controller i can use:
message = PostMessage.new(game, user, message)
if message.process!
render json: {message: "Post created"}, status: :ok
else
render json: {message: message.errors}, status: :unprocessable_entity
end
Conclusion:
Service objects is not core style of Rails, however rails developers are widely using this approach to maintain the code. It simplifies the testing process and debugging.
0 Comment(s)