Lessons from Writebook - Concerns - Lesson 2 - A nice DSL
I have had the glorious pleasure of reading WriteBook - mainly for the benefit of understanding how the creators of Basecamp writes code.
They make use of certain styles. Notably concerns. Looks like they prefer nice DSLs over clumsy intermediate objects. e.g. like Viewer objects.
I want to conduct an exercise by asking myself this question: how would I edit the following code, adopting their paradigms? You may conduct the exercise yourself, and see what you produce.
# the original code.
# app/controllers/accounts_controller.rb
class AccountsController < ApplicationController
def transfer
@source_account = Account.find(params[:id])
@destination_account = Account.find(params[:destination_id])
@amount = params[:amount]
if @source_account.transfer_to(@destination_account, @amount)
flash[:success] = "Successfully transferred #{@amount} to #{@destination_account}"
redirect_to @source_account
else
flash[:error] = "There was a problem transferring money to #{@destination_account}"
render :transfer
end
end
end
Use of Scopes
- e.g. in WriteBook, they make use of
BookScoped
. We can adopt a similar philsophy here - except we will call itAccountScoped
.
module AccountScoped extend ActiveSupport::Concern
included do
before_action :set_account, :set_destination_account
end
private
def set_account
@account = Account.find(params[:account_id])
end
def set_destination_account
@destination_account = Account.find(params[:destination_account_id])
end
end
# which produces the following so far:
# app/controllers/accounts_controller.rb
class AccountsController < ApplicationController
include AccountScoped
def transfer
if @source_account.transfer_to(@destination_account, @amount)
flash[:success] = "Successfully transferred #{@amount} to #{@destination_account}"
redirect_to @source_account
else
flash[:error] = "There was a problem transferring money to #{@destination_account}"
render :transfer
end
end
end
Use Restful controllers
DHH seems to prefer using Restful controllers, rather than proliferate custom actions in a single controller.
- Now we are
create
ing a transfer. This is non-idempotent.
# app/controllers/accounts/transfers_controller.rb
class Accounts::TransfersController < ApplicationController
include AccountScoped
def create
if @source_account.transfer_to(@destination_account, @amount)
flash[:success] = "Successfully transferred #{@amount} to #{@destination_account}"
redirect_to @source_account
else
flash.now[:error] = "There was a problem transferring money to #{@destination_account}"
end
end
end
Use Transfer Objects
module Transferrable extend ActiveSupport::Concern
included do
def transfer_to(destination_account)
Account::Transfer.new(self, destination_account)
end
end
end
class Account
class Transfer
def initialize(source_account, destination_account)
@source_account = source_account
@destination_account = destination_account
end
def amount(x_dollars)
# we could use some type casting here
# to ensure we get only dollars as opposed to strings
@destination_account.balance += x_dollars
@source_account.balance -= x_dollars
end
def save
# we could also wrap this in a transaction and save both accounts
# only if a successful transaction can occur, and if money exists
# in the source account
# I will leave this as an exercise to the reader
end
end
end
# and mix this into the account model
class Account < ActiveRecord::Base
include Transferrable
end
# which allows you to do:
class Accounts::TransfersController < ApplicationController
include AccountScoped
def create
if @source_account.transfer_to(@destination_account).amount(params[:amount]).save
redirect_to @source_account, success: "Successfully transferred #{@amount} to #{@destination_account}"
else
flash.now[:error] = "There was a problem transferring money to #{@destination_account}"
end
end
end
Great! Looks like we’ve cleaned up quite a bit.
We could probably improve the error rendering - i.e. to use validation errors to populate the problems with the transfer. The main point: with the use of a concerns, we have a nice(r) DSL (if you prefer it).
If I’ve made a mistake somewhere here, please let me know!
References
- https://signalvnoise.com/posts/3372-put-chubby-models-on-a-diet-with-concerns
- https://jeromedalbert.com/how-dhh-organizes-his-rails-controllers/
Helpful discussion:
- https://discuss.rubyonrails.org/t/helping-devs-understand-concerns-faster/74619/20?u=benkoshy
- https://discuss.rubyonrails.org/t/helping-devs-understand-concerns-faster/74619/21?u=benkoshy
- https://discuss.rubyonrails.org/t/helping-devs-understand-concerns-faster/74619/22?u=benkoshy
Give me something to test!
class Account
class Transfer
def initialize(source_account, destination_account)
@source_account = source_account
@destination_account = destination_account
end
def amount(x_dollars)
# we could use some type casting here
# to ensure we get only dollars as opposed to strings
@destination_account.balance += x_dollars
@source_account.balance -= x_dollars
end
def to_s
"Source account is: #{@source_account.balance}, and destination_account is #{@destination_account.balance}"
end
end
end
module Transferrable
def self.included(base) # the same as using a concern, but no need for dependencies
base.extend ClassMethods
base.class_eval do
def transfer_to(destination_account)
Account::Transfer.new(self, destination_account)
end
end
end
module ClassMethods
# nothing here
end
end
class Account
include Transferrable
attr_accessor :balance
def initialize
self.balance = 100 # start off with $100 balance
end
end
source = Account.new
destination = Account.new
transfer = source.transfer_to(destination)
puts transfer.to_s # view the balances thus far
transfer.amount(100)
puts transfer.to_s # view the balances after the transfer has taken effect