Skywalker
Skywalker is a gem that provides a simple command pattern for applications that use transactions.
Why Skywalker?
It's impossible to come up with a 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?
What is a command?
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:
Testability
With a command, you inject most any argument, which means that you can simulate the run of the command without providing real arguments. 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.
Reasonability
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.
Knowledge of Results Without Knowledge of Response
A command prescriptively takes callbacks or #callable 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.
A Gateway to Harder Architectures
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.
Installation
Add this line to your application's Gemfile:
gem 'skywalker'
And then execute:
$ bundle
Or install it yourself as:
$ gem install skywalker
Usage
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 suss out the fact 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 gotten 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 save_group!
group.save!
end
private def send_notifications!
notifier.call(group).deliver
end
private def notifier
@notifier ||= NotificationsMailer.method(:group_created_notification)
end
end
You can of course set up a default for Group as with #notifier and pass in
params only, but I find injecting a pre-constructed ActiveRecord object usually
works well.
Basic Composition Summary
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.
Overriding Methods
The following methods are overridable for easy customization:
execute!- Define your operations here.
transaction(&block)- Uses an
ActiveRecord::Base.transactionby 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_successif 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_failureif 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.
Testing
To come.
Contributing
- Fork it ( https://github.com/robyurkowski/skywalker/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