At Your Service
Service Objects made easy (& works great w/ Rails)
Model / View / Controller isn't enough for today's complex web applications. Encapsulate your business logic in Service objects so that when you write a piece of business logic, you only write it once.
Inspiration & How to Use Service Objects:
Gourmet Service Objects --- by Brewhouse
Using Services to Keep Your Rails Controllers Clean and DRY --- by Engine Yard
7 Patterns to Refactor Fat ActiveRecord Models --- by Code Climate (see #2)
Installation
Add this line to your application's Gemfile:
gem 'at_your_service'
And then execute:
$ bundle install
Or install it yourself as:
$ gem install at_your_service
Example
Adding a user's Credit Card is a common piece of business logic. The below example uses Stripe to do so. By having this logic in a Service, whether it is an admin manually adding a new Credit Card for a User OR the User adding it themselves in the checkout flow, the logic will be exactly the same. Though the Renter object is the primary Active Record model involved, having this method as part of the Renter model would lead to that class being massive and unwieldy. It is much better as a standalone service, as shown below.
class AddCard
include AtYourService.with(strict: true)
attribute :renter, Renter
attribute :token, String
attribute :agent_name, String
UPDATE_RENTER_CUSTOMER_ID_ERROR = 'Could not associate Stripe data with Renter'
def call
begin
customer = nil
cards = []
if renter.stripe_customer_id
# Add card to customer
customer = Stripe::Customer.retrieve(renter.stripe_customer_id)
cards << customer.sources.create(source: token)
else
# Create new customer w/ card
customer = Stripe::Customer.create(
source: token,
email: renter.email,
description: "#{renter.full_name} - ##{renter.id} - created by #{agent_name}"
)
unless customer && renter.update(stripe_customer_id: customer.id)
Rails.logger.error "New Stripe Customer #{customer.inspect} could not be associated with Renter #{renter.inspect}"
return Error.new(UPDATE_RENTER_CUSTOMER_ID_ERROR)
end
end
cards += customer.sources.data
return Success.new(renter: ::Web::RenterStripeDecorator.new(renter), cards: cards)
rescue Stripe::CardError => e
return Error.new(e.json_body[:error][:message])
end
end
end
Setup
- create directory app/services.
- Add
config.autoload_paths += Dir["#{config.root}/app/services"]
to config/application.rb. - That's it! You are now ready to write your first Service
How to Write a Service
- Create a class in the
app/services
directory - Include AtYourService
- Define Attributes (strict or lenient) via the Virtus Gem
- Create the
call
method - Write your Business Logic (+ private helper methods)
- Return a
Success
orError
object
Create a class in the app/services
directory
Name your class with what the service does. If it's adding a credit card, call it AddCard
. If it's generating a quote, call it GenerateQuote
, etc.
Include AtYourService
Include the AtYourService module via include AtYourService
Define Attributes
With attributes, we clearly define the data that Service needs including the types. With Strict Coercion mode from Virtus, an error will be thrown if the inputted arguments cannot be coerced into the desired type (so no nils as arguments). By default, strict coercion mode is off and you can have nil attributes. To use strict coercion mode, modify your AtYourService include to be include AtYourService.with(strict: true)
Create the call
Method
At Your Service flavored service objects always define call
. This prevents the awkwardness of having to name both your service class and it's method (stuff like AddCard.add_card
). Now just name your class for what the service does (AddCard, SendInvite, MarkActiveReservation, ReturnProduct etc.) and then you always know you call it with .call
.
Write your Business Logic
Inside call
, define your business logic. Reference attributes by name, so attribute :user
would just be user
inside call
. Go wild here, Service objects are meant to house the complex business logic of your application. For convenience, you can define private helper methods below call like usual.
Return a Success
or Error
object
Again, with At Your Service we are all about consistency. Every Service should return either a Success
or Error
object and pass into it the appropriate data (for Success) or error messages (for Error). Take a look at the source of these two classes. They're very simple. For convenience, Error.new
accepts either a string or array of string error messages. With Success you can pass in any data you want, but usually a hash {user: user, order: order}
works best.
Rails API Example
Services are a great way to shrink your Rails controllers. Rather than having messy business logic in your controller, just offload it to a reusable Service that can be called across your codebase. I have many 2-line controller methods like so:
class OrderWizardController < ApplicationController
def generate_quote
result = GenerateQuote.call(params)
render json: result.display
end
def accept_quote
result = AcceptQuote.call(params)
render json: result.display
end
def remove_line_item
result = RemoveLineItem.call(params)
render json: result.display
end
# ... etc ...
end
Questions?
At Your Service is a new gem. If any of the above documentation is confusing, unclear or insufficient please let me know! I am always available via email at ben@bengelsey.com to help you out :)
Contributing to At Your Service
Running the Tests
$ rspec
Running the Rails Tests
$ cd rails_test/rails_app/
$ rspec
PRs and Issues are always welcome!
License
The gem is available as open source under the terms of the MIT License.