Skip to content

Repository files navigation

Slayer

Slayer: A Killer Service Layer

Gem VersionBuild StatusCode ClimateCoverage 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.classFooCommand < Slayer::Commanddefcall(foo:)unlessfoo == "foo"returnerrvalue: foo,message: "Argument must be foo!"endokvalue: fooendend

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.okdo |value|
puts"This code runs on success"endm.errdo |_value,result|
puts"This code runs on failure. Message: #{result.message}"endm.alldoputs"This code runs on failure or success"endm.ensuredoputs"This code always runs after other handler blocks"endend

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")putsresult.ok?# => trueresult=FooCommand.call(foo: "bar")putsresult.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.rbclassCreateUserCommand < Slayer::Commanddefcall(create_user_form:)unlessarguments_valid?(create_user_form)returnerrvalue: create_user_form,status: :arguments_invalidenduser=niltransactiondouser=User.create(create_user_form.attributes)endunlessuser.persisted?returnerrmessage: I18n.t('user.create.error'),status: :unprocessible_entityendokvalue: userenddefarguments_valid?(create_user_form)create_user_form.kind_of?(CreateUserForm) &&
create_user_form.valid? &&
!User.exists?(email: create_user_form.email)endend# controllers/user_controller.rbclassUsersController < ApplicationControllerdefcreate@create_user_form=CreateUserForm.from_params(create_user_params)CreateUserCommand.call(create_user_form: @create_user_form)do |m|
m.okdo |user|
auto_login(user)redirect_toroot_path,notice: t('user.create.success')endm.err(:arguments_invalid)do |_user,result|
flash[:error]=result.errors.full_messages.to_sentencerender:new,status: :unprocessible_entityendm.errdo |_user,result|
flash[:error]=t('user.create.error')render:new,status: :bad_requestendendendprivatedefrequired_user_params[:first_name,:last_name,:email,:password]enddefcreate_user_paramspermitted_params=required_user_params << :password_confirmationparams.require(:user).permit(permitted_params)endend

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:

classNoArgCommand < Slayer::Commanddefcall@instance_var='instance'okvalue: 'pass'endendNoArgCommand.calldo |m|
m.alldo |value,result,command|
putsvalue# => 'pass'putsresult.ok?# => trueputscommand.instance_var# => 'instance'endend

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:

classStatusCommand < Slayer::Commanddefcallreturnerrmessage: "Extra specific ko",status: :extra_specific_errifextra_specific_err?returnerrmessage: "Specific ko",status: :specific_errifspecific_err?returnerrmessage: "Generic ko"ifgeneric_err?returnokmessage: "Specific pass",status: :specific_passifspecific_pass?okmessage: "Generic pass"endendStatusCommand.calldo |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.describeRSpecCommanddodescribe'#call'docontext'should pass'dosubject(:result){RSpecCommand.call(should_pass: true)}it{is_expected.tobe_success_result}it{is_expected.not_tobe_failed_result}it{is_expected.tobe_success_result.with_status(:no_status)}it{is_expected.tobe_success_result.with_message("message")}it{is_expected.tobe_success_result.with_value("value")}endcontext'should fail'dosubject(:result){RSpecCommand.call(should_pass: false)}it{is_expected.tobe_failed_result}it{is_expected.not_tobe_success_result}it{is_expected.tobe_failed_result.with_status(:no_status)}it{is_expected.tobe_failed_result.with_message("message")}it{is_expected.tobe_failed_result.with_value("value")}endendend

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.describeFooController,type: :controllerdodocontext'successful command'dolet(: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 logicstub_command_response(UpdateFooCommand,fake_res)post:update,params: {id: foo.id}expect(response).tohave_http_status:okendendend

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 argumentstub_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"classMinitestCommandTest < Minitest::Testdefsetup@success_result=MinitestCommand.call(should_pass: true)@failed_result=MinitestCommand.call(should_pass: false)enddeftest_is_okassert_success@success_result,status: :no_status,message: 'message',value: 'value'refute_failed@success_result,status: :no_status,message: 'message',value: 'value'enddeftest_is_errassert_failed@failed_result,status: :no_status,message: 'message',value: 'value'refute_success@failed_result,status: :no_status,message: 'message',value: 'value'endend

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:

classFooController < ApplicationControllerdefnew@foo_form=FooForm.newenddefedit@foo=Foo.find(params[:id])@foo_form=FooForm.from_model(@foo)enddefcreate@foo_form=FooForm.from_params(foo_params)enddefupdate@foo_form=FooForm.from_params(foo_params)endprivatedeffoo_paramsparams.require(:foo).permit(:bar,:baz)endend

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.

classFooCommand < Slayer::Commanddefcalltransactiondo# => database interactionsendendend

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'# ORrequire'slayer/compat/rspec_compat_040'

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bundle exec rake to run the tests and lint checks. 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 generate documentation run yard. To view undocumented files run yard stats --list-undoc.

Releasing

Create a release from main:

bin/release {major|minor|patch|pre}
git push --follow-tags

The release script validates the repository, bumps the version, creates a git tag.

Publishing to RubyGems and creating a GitHub Release are handled automatically by GitHub Actions.

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.

About

A Killer Service Layer

Topics

Resources

Stars

5 stars

Watchers

5 watching

Forks

Releases

Sponsor this project

Packages

Used by

Contributors

Languages