Subroutine
A gem that provides an interface for creating feature-driven operations. It loosely implements the command pattern if you're interested in nerding out a bit. See the examples below, it'll be more clear.
Examples
So you need to sign up a user? or maybe update one's account? or change a password? or maybe you need to sign up a business along with a user, associate them, send an email, and queue a worker in a single request? Not a problem, create an op for any of these use cases. Here's the signup example.
class SignupOp < ::Subroutine::Op
field :name
field :email
field :password
validates :name, presence: true
validates :email, presence: true
validates :password, presence: true
attr_reader :signed_up_user
protected
def perform
u = build_user
u.save!
deliver_welcome_email!(u)
@signed_up_user = u
true
end
def build_user
User.new(filtered_params)
end
def deliver_welcome_email!(u)
UserMailer.welcome(u.id).deliver_later
end
end
So why is this needed?
- No insane cluttering of controllers with strong parameters, etc.
- No insane cluttering of models with validations, callbacks, and random methods that don't relate to integrity or access of model data.
- Insanely testable.
- Insanely easy to read and maintain.
- Multi-model operations become insanely easy.
- Your sanity.
Connecting it all
app/
|
|- controllers/
| |- users_controller.rb
|
|- models/
| |- user.rb
|
|- ops/
|- signup_op.rb
Route
resources :users, only: [] do
collection do
post :signup
end
end
Model
# When ops are around, the point of the model is to ensure the data entering the db is 100% valid.
# So most of your models are a series of validations and common accessors, queries, etc.
class User
validates :name, presence: true
validates :email, email: true
has_secure_password
end
Controller(s)
# I've found that a great way to handle errors with ops is to allow you top level controller to appropriately
# render errors in a consisent way. This is exceptionally easy for api-driven apps.
class Api::Controller < ApplicationController
rescue_from ::Subroutine::Failure, with: :render_op_failure
def render_op_failure(e)
# however you want to do this, `e` will be similar to an ActiveRecord::RecordInvalid error
# e.record.errors, etc
end
end
# With ops, your controllers are essentially just connections between routes, operations, and templates.
class UsersController < ::Api::Controller
def sign_up
# If the op fails, a ::Subroutine::Failure will be raised.
op = SignupOp.submit!(params)
# If the op succeeds, it will be returned so you can access it's information.
render json: op.signed_up_user
end
end
Usage
The Subroutine::Op class' submit and submit! methods have the same signature as the class' constructor, enabling a few different ways to utilize an op. Here they are:
Via the class' submit method
op = MyOp.submit({foo: 'bar'})
# if the op succeeds it will be returned, otherwise it false will be returned.
Via the class' submit! method
op = MyOp.submit!({foo: 'bar'})
# if the op succeeds it will be returned, otherwise a ::Subroutine::Failure will be raised.
Via the instance's submit method
op = MyOp.new({foo: 'bar'})
val = op.submit
# if the op succeeds, val will be true, otherwise false
Via the instance's submit! method
op = MyOp.new({foo: 'bar'})
op.submit!
# if the op succeeds nothing will be raised, otherwise a ::Subroutine::Failure will be raised.
Fluff
Ops have some fluff. Let's see if we can cover it all with one example. I'll pretend I'm using ActiveRecord:
class ActivateOp < ::Subroutine::Op
# This will inherit all fields, error mappings, and default values from the SignupOp class.
# It currently does not inherit validations
inputs_from ::SignupOp
# This defines new inputs for this op.
field :invitation_token
field :thank_you_message
# This maps any "inherited" errors to the op's input.
# So if one of our objects that we inherit errors from has an email_address error, it will end up on our errors as "email".
error_map email_address: :email
# If you wanted default values, they can be declared a couple different ways:
# default thank_you_message: "Thanks so much"
# field thank_you_message: "Thanks so much"
# field :thank_you_message, default: "Thanks so much"
# If your default values need to be evaluated at runtime, simply wrap them in a proc:
# default thank_you_message: -> { I18n.t('thank_you') }
# Validations are declared just like any other ActiveModel
validates :token, presence: true
validate :validate_invitation_available
protected
# This is where the actual operation takes place.
def perform
user = nil
# Jump into a transaction to make sure any failure rolls back all changes.
ActiveRecord::Base.transaction do
user = create_user!
associate_invitation!(user)
end
# Set our "success" accessors.
@activated_user = user
# Return a truthy value to declare success.
true
end
# Use an existing op! OMG SO DRY
# You have access to the original inputs via original_params
def create_user!
op = ::SignupOp.submit!(original_params)
op.signed_up_user
end
# Deal with our invitation after our user is saved.
def associate_invitation!(user)
_invitation.user_id = user.id
_invitation. =
_invitation.convert!
end
# Build a default value if the user didn't provide one.
def
# You can check to see if a specific field was provided via field_provided?()
return if field_provided?(:thank_you_message)
.presence || I18n.t('thanks')
end
# Fetch the invitation via the provided token.
def _invitation
return @_invitation if defined?(@_invitation)
@_invitation = token ? ::Invitation.find_by(token: token) : nil
end
# Verbosely validate the existence of the invitation.
# In most cases, these validations can be written simpler.
# The true/false return value is a style I like but not required.
def validate_invitation_available
# The other validation has already added a message for a blank token.
return true if token.blank?
# Ensure we found an invitation matching the token.
# We could have used find_by!() in `_invitation` as well.
unless _invitation.present?
errors.add(:token, :not_found)
return false
end
# Ensure the token is valid.
unless _invitation.can_be_converted?
errors.add(:token, :not_convertable)
return false
end
true
end
end
Extending Subroutine::Op
Great, so you're sold on using ops. Let's talk about how I usually standardize their usage in my apps. The most common thing needed is current_user. For this reason I usually follow the rails convention of declaring an "Application" op which declares all of my common needs. I hate writing ApplicationOp all the time so I usually call it BaseOp.
class BaseOp < ::Subroutine::Op
attr_reader :current_user
def initialize(*args)
params = args.
@current_user = args[0]
super(params)
end
end
Great, so now I can pass the current user as my first argument to any op constructor. The next most common case is permissions. In a common role-based system things become pretty easy. I usually just add a class method which declares the minimum required role.
class SendInvitationOp < BaseOp
require_role :admin
end
In the case of a more complex permission system, I'll usually utilize pundit but still standardize the check as a validation.
class BaseOp < ::Subroutine::Op
validate :validate_permissions
protected
# default implementation is to allow access.
def
true
end
def
errors.add(:current_user, :not_authorized)
false
end
end
class SendInvitationOp < BaseOp
protected
def
unless UserPolicy.new(current_user).send_invitations?
return
end
true
end
end
Clearly there are a ton of ways this could be implemented but that should be a good jumping-off point.
Performance monitoring is also important to me so I've added a few hooks to observe what's going on during an op's submission. I'm primarily using Skylight at the moment.
class BaseOp < ::Subroutine::Op
protected
def observe_submission
Skylight.instrument category: 'op.submission', title: "#{self.class.name}#submit" do
yield
end
end
def observe_validation
Skylight.instrument category: 'op.validation', title: "#{self.class.name}#valid?" do
yield
end
end
def observe_perform
Skylight.instrument category: 'op.perform', title: "#{self.class.name}#perform" do
yield
end
end
end
Todo
- Enable ActiveModel 3.0-3.2 users by removing the ActiveModel::Model dependency.
- Demo app?
Contributing
- Fork it ( https://github.com/[my-github-username]/subroutine/fork )
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request