Skip to content

apsislabs/slayer

Repository files navigation

Slayer

Slayer: A Killer Service Layer

Gem Version Build Status Code Climate Coverage Status

Slayer is intended to operate as a minimal service layer for your ruby application. To achieve this, Slayer provides base classes for business logic.

Slayer is still under development, and not yet ready for production use. We are targetting a stable API with the 0.4.0 launch, so expect breaking changes until then.

Application Structure

Slayer provides 2 base classes for organizing your business logic: Forms and Commands. These each have a distinct role in your application's structure.

Forms

Slayer::Forms are objects for wrapping a set of data, usually to be passed as a parameter to a Command or Service.

Commands

Slayer::Commands are the bread and butter of your application's business logic. Commands wrap logic into easily tested, isolated, composable classes. In our applications, we usually create a single Command per Controller endpoint.

Slayer::Commands must implement a call method, which always return a structured Slayer::Result object making operating on results straightforward. The call method can also take a block, which provides Slayer::ResultMatcher object, and enforces handling of both pass and fail conditions for that result.

This helps provide confidence that your core business logic is behaving in expected ways, and helps coerce you to develop in a clean and testable way.

Installation

Add this line to your application's Gemfile:

gem 'slayer'

And then execute:

$ bundle

Or install it yourself as:

$ gem install slayer

Usage

Commands

Slayer Commands should implement call, which will pass or fail the service based on input. Commands return a Slayer::Result which has a predictable interface for determining passed? or failed?, a 'value' payload object, a 'status' value, and a user presentable message.

# A Command that passes when given the string "foo"
# and fails if given anything else.
class FooCommand < Slayer::Command
  def call(foo:)
    unless foo == "foo"
      return err value: foo, message: "Argument must be foo!"
    end

    ok value: foo
  end
end

Handling the results of a command can be done in two ways. The primary way is through a handler block. This block is passed a handler object, which is in turn given blocks to handle different result outcomes:

FooCommand.call(foo: "foo") do |m|
  m.ok do |value|
    puts "This code runs on success"
  end

  m.err do |_value, result|
    puts "This code runs on failure. Message: #{result.message}"
  end

  m.all do
    puts "This code runs on failure or success"
  end

  m.ensure do
    puts "This code always runs after other handler blocks"
  end
end

The second is less comprehensive, but can be useful for very simple commands. The call method on a Command returns its result object, which has statuses set on itself:

result = FooCommand.call(foo: "foo")
puts result.ok? # => true

result = FooCommand.call(foo: "bar")
puts result.ok? # => false

Here's a more complex example demonstrating how the command pattern can be used to encapuslate the logic for validating and creating a new user. This example is shown using a rails controller, but the same approach can be used regardless of the framework.

# commands/user_controller.rb
class CreateUserCommand < Slayer::Command
  def call(create_user_form:)
    unless arguments_valid?(create_user_form)
      return err value: create_user_form, status: :arguments_invalid
    end

    user = nil
    transaction do
      user = User.create(create_user_form.attributes)
    end

    unless user.persisted?
      return err message: I18n.t('user.create.error'), status: :unprocessible_entity
    end

    ok value: user
  end

  def arguments_valid?(create_user_form)
    create_user_form.kind_of?(CreateUserForm) &&
      create_user_form.valid? &&
      !User.exists?(email: create_user_form.email)
  end
end

# controllers/user_controller.rb
class UsersController < ApplicationController
  def create
    @create_user_form = CreateUserForm.from_params(create_user_params)

    CreateUserCommand.call(create_user_form: @create_user_form) do |m|
      m.ok do |user|
        auto_login(user)
        redirect_to root_path, notice: t('user.create.success')
      end

      m.err(:arguments_invalid) do |_user, result|
        flash[:error] = result.errors.full_messages.to_sentence
        render :new, status: :unprocessible_entity
      end

      m.err do |_user, result|
        flash[:error] = t('user.create.error')
        render :new, status: :bad_request
      end
    end
  end

  private

    def required_user_params
      [:first_name, :last_name, :email, :password]
    end

    def create_user_params
      permitted_params = required_user_params << :password_confirmation
      params.require(:user).permit(permitted_params)
    end
end

Result Matcher

The result matcher is an object that is used to handle Slayer::Result objects based on their status.

Handlers: ok, err, all, ensure

The result matcher block can take 4 types of handler blocks: ok, err, all, and ensure. They operate as you would expect based on their names.

  • The ok block runs if the command was successful.
  • The err block runs if the command was koed.
  • The all block runs on any type of result --- ok or err --- unless the result has already been handled.
  • The ensure block always runs after the result has been handled.

Handler Params

Every handler in the result matcher block is given three arguments: value, result, and command. These encapsulate the value provided in the ok or return err call from the Command, the returned Slayer::Result object, and the Slayer::Command instance that was just run:

class NoArgCommand < Slayer::Command
  def call
    @instance_var = 'instance'
    ok value: 'pass'
  end
end


NoArgCommand.call do |m|
  m.all do |value, result, command|
    puts value # => 'pass'
    puts result.ok? # => true
    puts command.instance_var # => 'instance'
  end
end

Statuses

You can pass a status flag to both the ok and return err methods that allows the result matcher to process different kinds of successes and failures differently:

class StatusCommand < Slayer::Command
  def call
    return err message: "Extra specific ko", status: :extra_specific_err if extra_specific_err?
    return err message: "Specific ko", status: :specific_err if specific_err?
    return err message: "Generic ko" if generic_err?

    return ok message: "Specific pass", status: :specific_pass if specific_pass?

    ok message: "Generic pass"
  end
end

StatusCommand.call do |m|
  m.err                         { puts "generic err" }
  m.err(:specific_err)          { puts "specific err" }
  m.err(:extra_specific_err)    { puts "extra specific err" }

  m.ok                          { puts "generic pass" }
  m.ok(:specific_pass)          { puts "specific pass" }
end

RSpec & Minitest Integrations

Slayer provides assertions and matchers that make testing your Commands simpler.

RSpec

To use with RSpec, update your spec_helper.rb file to include:

require 'slayer/rspec'

This provides you with two new matchers: be_successful_result and be_failed_result, both of which can be chained with a with_status, with_message, or with_value expectations:

RSpec.describe RSpecCommand do
  describe '#call' do
    context 'should pass' do
      subject(:result) { RSpecCommand.call(should_pass: true) }

      it { is_expected.to be_success_result }
      it { is_expected.not_to be_failed_result }
      it { is_expected.to be_success_result.with_status(:no_status) }
      it { is_expected.to be_success_result.with_message("message") }
      it { is_expected.to be_success_result.with_value("value") }
    end

    context 'should fail' do
      subject(:result) { RSpecCommand.call(should_pass: false) }

      it { is_expected.to be_failed_result }
      it { is_expected.not_to be_success_result }
      it { is_expected.to be_failed_result.with_status(:no_status) }
      it { is_expected.to be_failed_result.with_message("message") }
      it { is_expected.to be_failed_result.with_value("value") }
    end
  end
end

Stubbing Command Results

The RSpec helpers provide two utility functions for use in your tests which should simplify testing commands with stubbed results. This can be useful when you want test a Rails controller, and your command is already tested separately. In this case, you only really care about the logic in your matching blocks --- not in the command itself.

Put another way: this is useful when you want to test the success or failure conditions of your commands.

RSpec.describe FooController, type: :controller do do
  context 'successful command' do
    let(:foo) { create(:foo) }
    let(:fake_res) { fake_result(ok: true, message: 'foo updated') }

    describe '#update' do
      # Foo will not be called, instead we will get back the stubbed response
      # from the let block above, allowing us to bypass the command logic and
      # test only the controller logic
      stub_command_response(UpdateFooCommand, fake_res)
      post :update, params: { id: foo.id }
      expect(response).to have_http_status :ok
    end
  end
end

This method --- stub_command_response --- can take the return value as either a second argument, or as a block:

stub_command_response(UpdateFooCommand, fake_res)     # => fake result as an argument
stub_command_response(UpdateFooCommand) { fake_res }  # => fake result as a block

Minitest

To use with Minitest, update your 'test_helper' file to include:

require slayer/minitest

This provides you with new assertions: assert_success and assert_failed:

require "minitest/autorun"

class MinitestCommandTest < Minitest::Test
  def setup
    @success_result = MinitestCommand.call(should_pass: true)
    @failed_result = MinitestCommand.call(should_pass: false)
  end

  def test_is_ok
    assert_success @success_result, status: :no_status, message: 'message', value: 'value'
    refute_failed @success_result, status: :no_status, message: 'message', value: 'value'
  end

  def test_is_err
    assert_failed @failed_result, status: :no_status, message: 'message', value: 'value'
    refute_success @failed_result, status: :no_status, message: 'message', value: 'value'
  end
end

Note: There is no current integration for Minitest::Spec.

Rails Integration

While Slayer is independent of any framework, we do offer a first-class integration with Ruby on Rails. To install the Rails extensions, add this line to your application's Gemfile:

gem 'slayer_rails'

And then execute:

$ bundle

And that's it. The integration provides a small handful of features that make your life easier when working with Ruby on Rails.

Form Validations

With slayer_rails, Slayer::Form objects are automatically extended with ActiveRecord validations. You can use the same validations you would on your ActiveRecord models, but directly on your forms.

Form Creation

With slayer_rails there are two new methods for instantiating Slayer::Form objects: from_params and from_model. These make it easier to populate forms with data while in your Rails controllers.

Take the following example for a FooController:

class FooController < ApplicationController
  def new
    @foo_form = FooForm.new
  end

  def edit
    @foo = Foo.find(params[:id])
    @foo_form = FooForm.from_model(@foo)
  end

  def create
    @foo_form = FooForm.from_params(foo_params)
  end

  def update
    @foo_form = FooForm.from_params(foo_params)
  end

  private

    def foo_params
      params.require(:foo).permit(:bar, :baz)
    end
end

Transactions

Slayer::Command and Slayer::Service objects are extended with access to ActiveRecord transactions. Anywhere in your Command or Service objects, you can execute a transaction block, which will let you bundle database interactions.

class FooCommand < Slayer::Command
  def call
    transaction do
      # => database interactions
    end
  end
end

Generators

Use generators to make sure your Slayer objects are always in the right place. slayer_rails includes generators for Slayer::Form and Slayer::Command.

$ bin/rails g slayer:form foo_form
$ bin/rails g slayer:command foo_command

Compatability

Backwards compatability with previous versions requires additional includes.

require 'slayer/compat/compat_040'

If you use test matchers, you will have to separately require the compatability layer for your test runner:

require 'slayer/compat/minitest_compat_040'

# OR

require 'slayer/compat/rspec_compat_040'

Development

After checking out the repo, run bin/setup to install dependencies. Then, run rake test 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 bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.

To generate documentation run yard. To view undocumented files run yard stats --list-undoc.

Development w/ Docker

$ docker-compose up
$ bin/ssh_to_container
$ bin/console

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/apsislabs/slayer.

Any PRs should be accompanied with documentation in README.md, and changes documented in CHANGELOG.md.

License

The gem is available as open source under the terms of the MIT License.


Built by Apsis

apsis

slayer was built by Apsis Labs. We love sharing what we build! Check out our other libraries on Github, and if you like our work you can hire us to build your vision.