Skywalker is a gem that provides a simple command pattern for applications that use transactions. (Or not! In later versions, Skywalker is much more modular and can be used for non-transactional purposes, too.)
It's a reference to 'Commander Skywalker' from Star Wars, a rank the main protagonist achieves and by which he is called.
It's tricky to come up with a memorable single-word name for a gem about commands that's at least marginally witty. If you can't achieve wit or cleverness, at least achieve topicality, right?
A command is simply a series of instructions that should be run in sequence and considered a single unit. If one instruction fails, they should all fail.
That's a transaction, you say? You're correct! But there are some benefits of considering transactional blocks as objects:
One of the really cool things about Ruby is that it's trivial to pass a class as an argument. This makes Dependency Injection (DI) dead simple. (If you've never heard of DI, you should read my favourite architecture grump Piotr Solnic's take on it.)
This, of course, makes a lot of sense when you'd like to remove a reference to a model class to test in isolation. But it also makes a lot of sense when you realize that portions of your code are collaborators, too: anything that's orthogonal (or a side effect) to what you're working on is something that you can test in isolation. And if you've ever done this, you know that isolated tests are so much easier to write, so much easier to maintain, and so much faster to run.
This isn't limited to a Command object. You can offload these chunks of code into numerous other collaborators, some of which people call 'Service', 'Operation', or 'Policy' objects. But it is an essential property of these types of objects and a huge benefit.
Skywalker also has an Acceptable
module that can be used to create other such
objects that do not necessarily require a transaction.
Skywalker places a strong emphasis on dependency injection.
This means that you can unit test the command for correctness without having to do a full integration test for every single path through the code. That makes your test suite lean and mean, and encourages you to aim for weaker forms of coupling (i.e. preferring connascence of name, rather than identity).
Best practice is to describe the operations in methods, which can then be stubbed out to test small portions in isolation.
This also allows you to make the reasonable inference that the command will
abort properly if one step raises an error, and by convention, the same method
(on_failure
) will be called. In most cases, you can thereby verify happy path
and a single bad path through integration specs, and that will suffice.
The benefit of abstraction means that you can easily reason about a command
without having to know its internals. Standard caveats apply, but if you have a
CreateGroup
command, you should be able to infer that calling the command
with the correct arguments will produce the expected result.
A command prescriptively takes callbacks or #call
able objects, which can be
called depending on the result of the command. By default, Skywalker::Command
can handle an on_success
and an on_failure
callback, which are called after
their respective results. You can define these in your controllers, which lets
you run the same command but respond in unique ways, and keeps controller
concerns inside the controller.
You can also easily override which callbacks are run. Need to run a different
callback if request.xhr?
? Simply override run_success_callbacks
and
run_failure_callbacks
and call your own.
It's not hard to create an Event
class and step up toward full event
sourcing, or to go a bit further and implement full CQRS. This is the
architectural pattern your parents warned you about.
Add this line to your application's Gemfile:
gem 'skywalker'
And then execute:
$ bundle
Or install it yourself as:
$ gem install skywalker
Let's talk about a situation where you're creating a group and sending an email inside a Rails app.
Standard operating procedure usually falls into one of two patterns, both of which are mediocre. The first makes use of ActiveRecord callbacks:
# app/controllers/groups_controller.rb
class GroupsController < ApplicationController
# ...
def create
@group = Group.new(params.require(:group).permit(:name))
if @group.save
redirect_to @group, notice: "Created the group!"
else
flash[:alert] = "Oh no, something went wrong!"
render :new
end
end
end
# app/models/group.rb
class Group < ActiveRecord::Base
after_create :send_notification
private
def send_notification
NotificationMailer.group_created_notification(self).deliver
end
end
This might seem concise because it keeps the controller small. (Fat model, thin
controller has been a plank of Rails development for a while, but it's slowly
going away, thank heavens). But there are two problems here: first, it
introduces a point of coupling between the model and the mailer, which not only
makes testing slower, it means that these two objects are now entwined. Create
a group through the Rails console? You're sending an email with no way to skip
that. Secondly, it reduces the reasonability of the code. When you look at the
GroupsController
, you can't immediately see that this sends an email.
Moral #1: Orthogonal concerns should not be put into ActiveRecord callbacks.
The alternative is to keep this inside the controller:
# app/controllers/groups_controller.rb
class GroupsController < ApplicationController
# ...
def create
@group = Group.new(params.require(:group).permit(:name))
if @group.save
NotificationMailer.group_created_notification(@group).deliver
redirect_to @group, notice: "Created the group!"
else
flash[:alert] = "Oh no, something went wrong!"
render :new
end
end
end
This is more reasonable, but it's longer in the controller and at some point your eyes begin to glaze over. Imagine as these orthogonal concerns grow longer and longer. Maybe you're sending a tweet about the group, scheduling a background job to update some thumbnails, or hitting a webhook URL. You're losing the reasonability of the code because of the detail.
Moreover, imagine that the group email being sent contains critical
instructions on how to proceed. What if NotificationMailer
has a syntax
error? The group is created, but the mail won't be sent. Now the user hasn't
received a good error, and your database is potentially fouled up by
half-performed requests. You can run this in a transaction, but that does not
reduce the complexity contained within the controller.
Moral #2: Rails controllers should dispatch to application logic, and receive instructions on how to respond.
The purpose of the command is to group orthogonal but interdependent results
into logical operations. Here's how that looks with a Skywalker::Command
:
# app/controllers/groups_controller.rb
class GroupsController < ApplicationController
# ...
def create
CreateGroupCommand.call(
group: Group.new(params.require(:group).permit(:name)),
on_success: method(:on_create_success),
on_failure: method(:on_create_failure)
)
end
def on_create_success(command)
redirect_to command.group, notice: "Created the group!"
end
def on_create_failure(command)
flash[:alert] = "Oh no, something went wrong!"
@group = command.group
render :new
end
end
# app/commands/create_group_command.rb
class CreateGroupCommand < Skywalker::Command
def execute!
save_group!
send_notification!
end
private def required_args
%w(group on_success on_failure)
end
private def save_group!
group.save!
end
private def send_notifications!
notifier.deliver
end
private def notifier
@notifier ||= NotificationsMailer.group_created_notification(group)
end
end
Two notes on the above example:
First, you do not have to use method
. You are free to do what you
wish—whether that is using a proc, or injecting the controller context as
an argument and then overwriting the callback methods to use that. But I (and
this library) take a principled stance that what occurs inside those callbacks
is usually the responsibility of the controller and should remain within it, so
this is made easy, and you are strongly encouraged to follow this pattern.
Secondly, it is ideologically 'purer' to pass in a method which would construct a group, and the params separately, because it moves the instantiation of domain concepts out of the controller. However, for the purpose of 'GSD', I often wind up keeping the instantiation of simple AR objects inside of my controller.
Compose your commands:
require 'skywalker/command'
class AddGroupCommand < Skywalker::Command
def execute!
# Your transactional operations go here. No need to open a transaction.
# Simply make sure each method raises an error when it fails.
end
end
Then call your commands:
command = AddGroupCommand.call(
any_keyword_argument: "Is taken and has an attr_accessor defined for it."
)
You can pass any object responding to #call
to the on_success
and
on_failure
handlers, including procs, lambdas, controller methods, or other
commands themselves.
Exceptions thrown inside the success callbacks (on_success
or your own
callbacks defined in run_success_callbacks
) will cause the command to fail
and run the failure callbacks.
Exceptions thrown inside the failure callbacks (on_failure
or your own
callbacks defined in run_failure_callbacks
) will not be caught and will
bubble out of the command.
The following methods are overridable for easy customization:
execute!
- Define your operations here.
required_args
- An array of expected keys given to the command. Raises
ArgumentError
if keys are missing.
- An array of expected keys given to the command. Raises
validate_arguments!
- Checks required args are present, but can be customized. All instance variables are set by this point.
transaction(&block)
- Uses an
ActiveRecord::Base.transaction
by default, but can be customized.execute!
runs inside of this.
- Uses an
confirm_success
- Fires off callbacks on command success (i.e. non-error).
run_success_callbacks
- Dictates which success callbacks are run. Defaults to
on_success
if defined.
- Dictates which success callbacks are run. Defaults to
confirm_failure
- Fires off callbacks on command failure (i.e. erroneous state), and sets the
exception as
command.error
.
- Fires off callbacks on command failure (i.e. erroneous state), and sets the
exception as
run_failure_callbacks
- Dictates which failure callbacks are run. Defaults to
on_failure
if defined.
- Dictates which failure callbacks are run. Defaults to
For further reference, simply see the command file. It's less than 90 LOC and well-commented.
Take a look at the examples
directory, which uses the example as above of a
notifier, but makes it a bit more complicated: it assumes that we only send
emails if the user (which we'll pass in) has a preference set to receive email.
Here's what you can assume in your tests:
- Arguments that are present in the list of
required_args
will throw an error before the command executes if they are not passed. - Operations that throw an error will abort the command and trigger its failure state.
- Calling
Command.new().call
is functionally equivalent to callingCommand.call()
There are two tests that you need to write. First, you'll want to write a Command spec, which are very simplistic specs and should be used to verify the validity of the command in isolation from the rest of the system. (This is what the example shows.) You'll also want to write some high-level integration tests to make sure that the command is implemented correctly inside your controller, and has the expected system-wide results. You shouldn't need to write integration specs to test every path -- it should suffice to test a successful path and a failing path, though your situation may vary depending on the detail of error handling you perform.
Here's one huge benefit: with a few small steps, you won't need to include
rails_helper
to boot up the entire environment. That means blazingly fast
tests. All you need to do is stub transaction
on your command, like so:
RSpec.describe CreateGroupCommand do
describe "operations" do
let(:command) { CreateGroupCommand.new(group: double("group") }
before do
allow(command).to receive(:transaction).and_yield
end
# ...
end
end
Before version 3.0, you might have received an error like this:
undefined method `call' for nil:NilClass
# .../lib/skywalker/command.rb:118:in `run_failure_callbacks'
This means that the command failed and you didn't specify an on_failure
callback. You can stick a debugger inside of run_failure_callbacks
, and get
the failure exception as self.error
. You can also reraise the exception to
achieve a better result summary, but this is not done by default, as you may
also want to test error handling.
Now, in version 3.0 and later, any command that does not receive a callable
on_failure
argument will raise any arguments it encounters during the process
of running execute!
.
A Skywalker::Command
is implemented through a series of modules that can be
used independently from each other and outside of the context of the Command
object.
Skywalker::Acceptable
allows an object to receive a keyword list of arguments
upon instantiation. It creates a reader and writer for each keyword that doesn't
already have one, and it will raise an error for any keyword not given that is
present inside its required_args
list.
Example:
require 'skywalker/acceptable'
class MyClass
include Skywalker::Acceptable
def required_args
%w(baz)
end
def bar
"definitely not #{@bar}"
end
def baz=(int)
@baz = int.to_s.reverse.to_i
end
end
MyClass.new(foo: "abc", bar: "xyz") # => ArgumentError, "baz required but not given"
instance = MyClass.new(foo: "abc", bar: "xyz", baz: 123) # => <MyClass#...>
instance.foo # => "abc"
instance.bar # => "definitely not xyz"
instance.baz # => 321
A very simple module that allows a class to implement self.call
, which
forwards any arguments to a new instance and then calls that instance.
Example:
require 'skywalker/callable'
class MyClass
include Skywalker::Callable
def initialize(message)
@message = message
end
def call
@message
end
end
MyClass.new("Hello World").call # => Hello World
MyClass.call("Hello World 2") # => Hello World 2
Tiny but convenient.
Will include Acceptable
.
Implements the core transactional logic used by Skywalker::Command
.
Makes call
open a transaction, running execute!
and calling
confirm_success
or confirm_failure
as appropriate.
For example, see Skywalker::Command
documentation above.
- Fork it ( https://github.com/robyurkowski/skywalker/fork )
- Create your feature branch (
git checkout -b my-new-feature
) - Ensure both sets of tests are green (
bundle exec rake
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create a new Pull Request