Yeah, on first read I thought this was going to be another Java parody like the “kingdom of nouns” essay.
Also I am on record as being strongly against trying to do “service layers” or equivalent with Active Record ORMs (not just the default Rails ORM which is actually named ActiveRecord, but all ORMs built on the Active Record design pattern).
I’ve been using this pattern of naming and structuring service objects over the past ~year. It’s initially been greeted with some uncertainty and skepticism when I introduce it to new developers, but it tends to grow on folks as they give it a shot and look at how it’s already been used.
I wrote this up partially to codify some of how I’ve been explaining it to folks ad-hoc, but also to share with the broader community and get input on this way of extracting processes in Ruby/Rails projects.
Good starting point, but I would strongly advise reducing your dependency on constants, and using the initialization for dependency-injection, so that you can treat these like first-class functions.
The Dry-rb family of gems has been supporting this style for a while.
dry-monads is an ideal Result type for these objects to use, so that you can trivially compose them
dry-container gives you a threadsafe object to register keys. This is used to encapsulate the build process of instantiating a class with optional memoization
dry-auto_inject builds on that to give you a DSL for injecting container keys during initialization
dry-system builds on that to give you automatic, convention-based component registration
dry-rails provides some nice integration for Rails controllers
Hanami Has been rewritten from the ground up to follow these principles
So your controller example might look more like
# app/operations/make_purchase.rb
class MakePurchase < Operation
include Deps[:create_order, :process_payment, :fulfill_order]
def call(customer:, product:)
order = yield create_order.(customer:, product:)
result = yield process_payment.(payment_method: customer.payment_method, amount: order.cost)
if result.declined?
Failure[:declined, result]
else
fulfill_order.(order:)
end
end
end
Every time the Operation yields, it is checking the monad type. A Failure will immediately halt execution and return the failure result, while a Success is unwrapped for continued processing.
# app/controllers/purchases_controller.rb
class PurchasesController < ApplicationController
schema :create do
required(:product_id).filled(:integer)
end
before_action do
if safe_params&.failure?
render(:error, errors: safe_params.errors.to_h)
end
end
def create
product = Product.find(safe_params[:product_id])
case resolve(:make_purchase).(customer: current_user, product:)
in Success(order)
redirect_to order
in Failure[:declined, result]
render :declined, result
in Failure(MyApp::Error => err)
render :server_error, err
end
end
end
You get the Operation by name using resolve. This means that 1. your controller doesn’t need knowledge of how to instantiate MakePurchase, and 2. the specific constant identity is irrelevant. Want to refactor the process in a different Operation? Replace the automatic container key with a manual registration that uses a feature flag to choose the implementation.
MyApp::Container.register(:make_purchase) do
if Feature?(:new_purchase)
NewMakePurchase.new
else
MakePurchase.new
end
end
This also makes testing really easy. Need to stub out the coordinating dependencies? Inject a proc in your unit test.
subject :make_purchase do
described_class.new(
create_order: ->(**) { Success(fake_product) },
process_payment: ->(**) { Success(FakePayment.new(payment_method:, amount: Money.new(3200, "USD"))) }
)
end
If your dependency graph is more complicated than this, you can also stub the container keys directly
let(:process_payment) { Success(FakePayment.new(payment_method:, amount: Money.new(3200, "USD"))) }
around :each do |example|
container.stub(:process_payment, process_payment) { example.run }
end
Pardon my ignorance, but isn’t this just reinventing the concept of a subroutine?
Maybe I missed something.
Same question, but for top-level functions.
I can’t help but feel smug, from the top of my pile of Python’s
__call__
methods.Yeah, on first read I thought this was going to be another Java parody like the “kingdom of nouns” essay.
Also I am on record as being strongly against trying to do “service layers” or equivalent with Active Record ORMs (not just the default Rails ORM which is actually named
ActiveRecord
, but all ORMs built on the Active Record design pattern).I’ve been using this pattern of naming and structuring service objects over the past ~year. It’s initially been greeted with some uncertainty and skepticism when I introduce it to new developers, but it tends to grow on folks as they give it a shot and look at how it’s already been used.
I wrote this up partially to codify some of how I’ve been explaining it to folks ad-hoc, but also to share with the broader community and get input on this way of extracting processes in Ruby/Rails projects.
Good starting point, but I would strongly advise reducing your dependency on constants, and using the initialization for dependency-injection, so that you can treat these like first-class functions.
The Dry-rb family of gems has been supporting this style for a while.
So your controller example might look more like
Every time the Operation
yield
s, it is checking the monad type. A Failure will immediately halt execution and return the failure result, while a Success is unwrapped for continued processing.You get the Operation by name using
resolve
. This means that 1. your controller doesn’t need knowledge of how to instantiate MakePurchase, and 2. the specific constant identity is irrelevant. Want to refactor the process in a different Operation? Replace the automatic container key with a manual registration that uses a feature flag to choose the implementation.This also makes testing really easy. Need to stub out the coordinating dependencies? Inject a proc in your unit test.
If your dependency graph is more complicated than this, you can also stub the container keys directly
I find the font really hard to read and had to disable it to finish the article.
Luv reader mode