Performify
It's well-known practice that has been proved in many large projects to move server logic into separated service classes. This approach gives a lot of advantages, because when you are able to create object that incapsulates your logic it's much easier to develop, search, control and test. And performify
helps you to do it in nice and easy way with minimum of pain and maximum of result.
Installation
Add this line to your application's Gemfile:
gem 'performify'
And then execute:
$ bundle
Or install it yourself as:
$ gem install performify
Usage
How to have a deal with services:
- Define
ApplicationService
- Create new service inherited from
ApplicationService
- Implement
execute!
method - Use
super
to work with db transaction and automatic success / fail workflow control - Use
success!
andfail!
to control everything by hands - Use
on_success
/on_fail
to define callbacks - Use
schema do ... end
if you want to use validations
ApplicationService
So, first of all it's better to create ApplicationService
class that will be used as base for all services in your project. You can put any shared logic (like, authorization, for example) here:
class ApplicationService < Performify::Base
def authorize!(record)
# you can put authorization logic here and use it from inherited services
end
end
This is, for example, how authorization can be implemented for Pundit
:
class ApplicationService < Performify::Base
def authorize!(record, query = default_query, record_policy = nil)
record_policy ||= policy(record)
return if record_policy.public_send(query)
raise Pundit::NotAuthorizedError, query: query, record: record, policy: record_policy
end
def default_query
@default_query ||= "#{self.class.name.demodulize.underscore.to_sym}?"
end
def policy(record)
@policy ||= Pundit.policy!(@current_user, record)
end
end
Service: database
Now, to define new service just create new class and inherit it from ApplicationService
:
module Users
class Destroy < ApplicationService
def execute!
# current user is already available, so feel free to use it
# to get user's context
authorize! current_user unless force?
# block passed into super's implementation will be executed
# in transaction, so you can do multiple data operations, and final
# result of this block will be used to determine result of execution
super do
if current_user.update(destroyed_at: Time.zone.now)
current_user.comments.find_each do |c|
s = Comments::Destroy.new(current_user, c)
s.execute!
# it's also ok to raise ActiveRecord::Rollback, it will be handled
# gracefully as regular execution fail
raise ActiveRecord::Rollback unless s.success?
end
end
end
end
def force?
# additional instance variables can be passed as named args into
# initializer and accessed in service flow
force.present?
end
end
end
Now you can create instance of your service and check result of execution:
service = Users::Destroy.new(current_user, force: true)
service.execute!
service.success? # or service.fail?
Service: HTTP API
Sometimes your service doesn't work with database, but calls some http endpoint or do some other stuff that doesn't require db transaction. In this case you can control your service flow manually:
class Stripe::Create < ApplicationService
attr_reader :subscription
def execute!
# here you can go to Stripe and create subscription for the user
begin
@subscription = Stripe::Subscription.create(
customer: current_user.customer_id,
plan: selected_plan.stripe_name,
)
# everything looks ok, success
success!
rescue Stripe::StripeError => e
# something went wrong, let's notify developers and say that
# service execution has been failed
Airbrake.notify(e)
fail!
end
end
end
Callbacks
If you need to do something on service success / fail it is possible to define appropriate callbacks. Notice, that in case of using super
callbacks will be executed outside of db transaction, so it's safe to send emails from there, for example.
module Passwords
class Update < ApplicationService
def execute!
authorize! current_user
super do
current_user.update(password: password, password_confirmation: password_confirmation)
end
end
# you can pass method name as a callback
on_success :invalidate_sessions
# or you can pass block instead of method name
on_success { UserMailer.password_changed(current_user).deliver_later }
private def invalidate_sessions
# you can invalidate existing user's sessions here
end
end
end
Validation
Performify allows you to validate input arguments using dry-validation schemas. Validation is performed on creation of service instance. And if validation is not passed it will be impossible to call execution. Result of execution will be automatically switched to failed state.
module Users
class Create
schema do
required(:email).filled(:str?)
end
def execute!
# it will be impossible to call execution if provided arguments
# did not pass validation
end
end
end
service = Users::Create.new(current_user, email: nil)
service.execute! # nothing happens here
service.success? # will be false because of validation
service.errors # contains hash of errors
Sometimes you can have differences between validation errors and execution errors. But usually it's boring to check them separately since you just need to display final result to user. To avoid double check you can use following trick:
module Users
class Create
attr_reader :user
schema do
required(:email).filled(:str?)
end
def execute!
user = User.new(email: email)
authorize! user
# Let's assume that user has additional validation of uniqueness on the
# level of model, so in controller you need to check separately service's
# errors and model's errors, right?
super { user.save }
end
# So, we define on fail callback where we copy errors from model
# to service so now in controller we can check and use only service's errors
on_fail { errors! user.errors.to_h }
end
end
# in controller
service = Users::Create.new(current_user, email: nil)
service.execute!
if service.success?
# respond with ok
else
# respond with unprocessable entity and service.errors
end
You can get filtered inputs after success validation by accessing inputs
.
module Users
class UpdateProfile < ApplicationService
schema do
optional(:first_name).filled(:str?)
optional(:last_name).filled(:str?)
optional(:login).filled(:str?)
optional(:image)
optional(:email).filled(format?: /\A[^ \n\r\s]+@[^ \n\r\s]+\z/i)
end
def execute!
super { current_user.update(inputs) }
end
on_fail { errors!(current_user.errors) }
end
end
Initialization
Performify will dynamically define accessors for all arguments passed to service in addition to current_user:
module Users
class Create
def execute!
# it will define accessors for all arguments:
User.new(email: email, role: role, manager: current_user)
end
end
end
service = Users::Create.new(current_user, email: 'mail@google.com', role: 'employee')
But if you use schema
to validate parameters Performify will define accessors only for additional arguments mentioned in schema:
module Users
class Create
schema do
required(:email).filled(:str?)
optional(:phone).filled(:str?)
end
def execute!
# it will define accessors for `email` and `phone`, but won't define `role`
User.new(email: email, phone: phone, manager: current_user) # phone is nil
end
end
end
service = Users::Create.new(current_user, email: 'mail@google.com', role: 'manager')
Development
After checking out the repo, run bin/setup
to install dependencies. Then, run bin/rspec spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bin/rake install
. To release a new version, update the version number in version.rb
, and then run bin/rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/kimrgrey/performify.
License
The gem is available as open source under the terms of the MIT License.