Rails Service Objects
Plan
- Introduction / Context
- Rails Patterns
- ➡️ SOLID
- Object Services
- ➡️ interactor gem
- Conclusion
Context
-
Rails default patterns
-
Model
Deals with a CRUD datasource
Expose data, transform and validate data before update -
View
Display data - from templates variables, return a representation of data (HTML, JSON, ...) -
Controller
Handle request level logic - example: authentication
-
Model
Context
-
Rails other patterns
-
Decorator
Augment model's data
( decorator(record) = decorated_record ) -
Presenter
Prepare data for the view - example with data comes from many model or sources
(decorator vs presenter)
-
Singleton
Shared instance across the app - example for configuration or redis client
-
Decorator
Context
Decorator and Presenter usage example
S.O.L.I.D principles
Single responsibility principle
a class should have only a single responsibility (i.e. changes to only one part of the software's specification should be able to affect the specification of the class).
Open/closed principle
"software entities … should be open for extension, but closed for modification."
Liskov substitution principle
"objects in a program should be replaceable with instances of their subtypes without altering the correctness of that program." See also design by contract.
Interface segregation principle
"many client-specific interfaces are better than one general-purpose interface."
Dependency inversion principle
"high level modules should not depend upon low level modules. Both should depend upon abstractions"
Service Objects
We have all been there when you see your controller action getting way too long and hold too much business logic.
You know you need to email the user, adjust an account, maybe submit to Stripe and finally ping a Slack Webhook.
Well, where should it go? This code needs to exist and doesn’t seem to fit in the model.
This my friend, is where Service Objects come in!
Service Objects
Service Objects: How to
class Analytics
def self.get_data(algolia_application)
usage = {}
acl = %w[
total_write_operations
total_read_operations
records
]
uri = URI("https://status.algolia.com/1/usage/#{acl.join(',')}/period/month")
res = perform_request(uri, algolia_application['monitoring_api_key'])
json = JSON.parse(res.body)
return [:bad_request, error: json['reason']] if json['reason'].present?
# ...
[:ok, usage]
end
def self.retrieve_analytics_information
# ...
end
end
Service Objects: How to
Class based with the following signature:
-
initialize()
-
execute()
-
execute!()
Service Objects: How to
class Analytics
attr_reader :algolia_application
def initialize(algolia_application)
@algolia_application = algolia_application
end
def execute(from:)
usage = {}
acl = %w[
total_write_operations
total_read_operations
records
]
uri = URI("https://status.algolia.com/1/usage/#{acl.join(',')}/period/month")
res = perform_request(uri, algolia_application['monitoring_api_key'])
json = JSON.parse(res.body)
return [:bad_request, error: json['reason']] if json['reason'].present?
# ...
[:ok, usage]
end
private
def retrieve_analytics_information
# ...
end
end
Service Objects: How to
analytics = Analytics.new(config.algolia_application)
analytics.execute(from: 1.month)
analytics.execute(from: 2.month)
Service Objects: interactor
class AuthenticateUser
include Interactor
def call
if user = User.authenticate(context.email, context.password)
context.user = user
context.token = user.secret_token
else
context.fail!(message: "authenticate_user.failure")
end
end
end
Service Objects: interactor
class SessionsController < ApplicationController
def create
result = AuthenticateUser.call(session_params)
if result.success?
session[:user_token] = result.token
redirect_to result.user
else
flash.now[:message] = t(result.message)
render :new
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
Service Objects: interactor
Interactors (building blocks)
- context
- flow control: fail!/success methods
- hooks support: around, before, after
- rollback support
Organizers (orchestrate interactors)
- flow control: if one interactor fails, others are "rollbacked"
Service Objects: interactor
# our Analytics Organizer
class StoreAnalytics
include Interactor::Organizer
organize Analytics::RetrieveAnalytics, Analytics::RetrieveUsage
end
### ...
## in controller
def analytics
result = StoreAnalytics.call(algolia_application)
if result.success?
result.usage
result.analytics
render AnalyticsPresenter.new(result).as_json
else
render status: result.error.status, json: result.error.json
end
end
Conclusion
-
Pros
- Code easier to test, even if services are composed (with an organizer)
- Responsibilities are clear and self-documented
- "Less complexity"
-
Cons
- How to build performance oriented Service Objects?
- How to identify responsibility in an application?
Rails Service Objects
By Charly Poly
Rails Service Objects
- 1,497