If you missed it, Uncle Bob gave a keynote at Ruby Midwest regarding Rails application structure and being able to intuit a project's functionality from its structure. It really struck a chord with me, and I wanted to share a pattern I've just used for the first time (but considered for quite a while) that really makes me happy.
Commands with ActiveModel
Model, briefly.
So we have a project that has general ledgers. In our model, a Student has a
checking account and a savings account. We also have a class called
CreditManager that has methods on it that represent each transfer we support
within our system. So there are methods in there called
transfer_funds_from_checking_to_savings(student, amount) and
transfer_funds_from_savings_to_checking(student, amount). Each of these
methods are well-tested in the CreditManager's unit tests with mocks ensuring
that the ledger models get sent the appropriate messages to handle the
transfers.
The old busted way
We have a form in the interface for transferring funds from checking to savings or vice versa. In many Rails apps, this form might submit to a controller action that loads up the CreditManager, checks validations, executes the transfer, and returns. This puts logic into the controller that really doesn't belong there.
In some 'next tier' Rails apps you might see a model built, persisted to the
database (but not really used, ideally, for reporting) that was a
StudentTransfer. This model might have an after_create that kicked off the
credit manager calls. That's a decent step up, but it's really weird to have a
database-backed model for this, and inevitably someone starts reporting on 'the
ledger' by reading these models, which is....stupid.
Commands!
So the thing I'm doing now, and I'm extremely happy with the result, is to build out a Command class, backed by ActiveModel, to handle this use case.
Benefits
- Validation of input can be handled, and tested, painlessly.
- There is an obvious pattern for building a form to execute the command using
typical
form_foridioms. - We will be reusing a custom validator on all transfer commands in the system, so Rails' customer validator framework is very nice.
- We could easily move to a message based architecture here without missing a beat.
- Refactoring the bottom layers doesn't affect the controllers at all.
- Testing a ton of different user interactions can be done entirely at the unit test level, with nary a database connection or session setup to be seen.
Code
So this is a long way to get in a blog post without code, but I figured I'd try to set the stage nicely. Without further ado, here's the relevant bits:
# config/routes.rb
resources :student_transfer_commands
# app/views/banks/_transfer_credits.html.haml
= form_for StudentTransferCommand.new do |f|
= f.text_field :amount, placeholder: "Amount"
= f.select :direction, options_for_select([["Checking to Savings", "checking_to_savings"], ["Savings to Checking", "savings_to_checking"]])
= submit_tag "Transfer"
# app/controllers/student_transfer_commands_controller.rb
class StudentTransferCommandsController < LoggedInController
def create
transfer = StudentTransferCommand.new(params[:student_transfer_command])
transfer.student_id = current_person.id
if transfer.valid?
transfer.execute!
flash[:success] = "Transfer successful."
else
flash[:error] = "Invalid transfer."
end
redirect_to bank_path
end
end
# app/models/student_transfer_command.rb
class StudentTransferCommand
include ActiveModel::Validations
include ActiveModel::Naming
include ActiveModel::Conversion
attr_accessor :amount, :direction, :student_id
validates :direction, presence: true
validates :student_id, presence: true, numericality: true
validates_inclusion_of :direction, in: ["savings_to_checking", "checking_to_savings"]
validates :amount, positive_decimal: true
def initialize params={}
@amount = BigDecimal(params[:amount]) if params[:amount]
@direction = params[:direction]
@student_id = params[:student_id]
end
# This is so that activemodel acts like we want in the form
def persisted?
false
end
# The transfer knows what to call on credit manager based on its direction
def transfer_method
case direction
when "savings_to_checking"
:transfer_credits_from_savings_to_checking
when "checking_to_savings"
:transfer_credits_from_checking_to_savings
else
raise "unknown direction"
end
end
def student
Student.find(student_id)
end
def credit_manager
CreditManager.new
end
def execute!
credit_manager.send(transfer_method, student, amount)
end
end
# app/validators/positive_decimal_validator.rb
class PositiveDecimalValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.present?
record.errors[attribute] << "Must be present"
return false
end
unless value.is_a?(BigDecimal)
record.errors[attribute] << "Must be a BigDecimal"
return false
end
record.errors[attribute] << "Must be positive and non-zero" unless value > BigDecimal('0')
end
end
# test/unit/student_transfer_command_test.rb
require 'test_helper'
describe StudentTransferCommand do
subject { StudentTransferCommand.new }
it "requires valid amount" do
subject.wont have_valid(:amount).when(nil)
subject.wont have_valid(:amount).when(0)
subject.wont have_valid(:amount).when(BigDecimal('-1'))
subject.wont have_valid(:amount).when('asdf')
subject.wont have_valid(:amount).when('123')
subject.must have_valid(:amount).when(BigDecimal('1'))
end
it "requires valid direction" do
subject.wont have_valid(:direction).when(nil)
subject.wont have_valid(:direction).when("foo")
subject.must have_valid(:direction).when("savings_to_checking")
subject.must have_valid(:direction).when("checking_to_savings")
end
it "requires valid student_id" do
subject.wont have_valid(:student_id).when(nil)
subject.wont have_valid(:student_id).when("foo")
subject.must have_valid(:student_id).when(1)
end
it "knows the type of credit manager transfer to execute based on its direction" do
subject.direction = "checking_to_savings"
subject.transfer_method.must_equal :transfer_credits_from_checking_to_savings
subject.direction = "savings_to_checking"
subject.transfer_method.must_equal :transfer_credits_from_savings_to_checking
end
it "executes the appropriate transfer when #execute! is called" do
amount = BigDecimal('5')
subject.amount = amount
method = :meth
student = mock "student"
credit_manager = mock "credit manager"
subject.expects(:student).returns(student)
subject.expects(:credit_manager).returns(credit_manager)
credit_manager.expects(method).with(student, amount).returns(true)
subject.expects(:transfer_method).returns(method)
subject.execute!
end
end
What never shows up in the above? Anything related to ActiveRecord. This is unbelievably fast to test.
Thoughts?
I'd love to hear some feedback on this method. It feels so unbelievably good I don't know where to start.
Josh Adams is a developer and architect with over eleven years of professional experience building production-quality software and managing projects. Josh is isotope|eleven's lead architect, and is responsible for overseeing architectural decisions and translating customer requirements into working software. Josh graduated from the University of Alabama at Birmingham (UAB) with Bachelor of Science degrees in both Mathematics and Philosophy. He runs the ElixirSips screencast series, teaching hundreds of developers Elixir. He also occasionally provides Technical Review for Apress Publishing, specifically regarding Arduino microprocessors. When he's not working, Josh enjoys spending time with his family.