Majesties! Cease groveling before your codebase, and issue your applications Decrees! A Decree is a pattern designed to aid developers in extracting processes to places where they can be usefully reused.
A class that implements the decree pattern has several benefits: it can easily be replaced as the API surface area is a single method; it can compose other Decrees; it can easily be faked for development; and it can easily be mocked for testing. The Decree pattern helps to both isolate and centralize the core logic of your app.
In Rails apps, service objects are pretty much any object that’s not a controller or model. A service object can follow the Decree pattern by having names that tell our code what to do (it’s imperative to do this) and a single class-level entry point called .(...)
.
A few examples of imperative names are CreateObject
, GenerateReport
, and PerformMentalGymastics
. Names like these, combined with the shorthand #call()
syntax, give us very nice ergonomics compared to other naming and calling conventions. GenerateReport.(params)
is much nicer to read than ReportGenerator.new(params).generate_report
.
In the method signature, ...
represents any set of arguments, keyword arguments, and a block. Each of those arguments are optional to define as part of the signature.
The Decree’s entry point method defined at the class level as def self.call(...)
. Thus, the code to execute a decree is DoSomething.(params)
. A Decree is useful for anything in the space between responding to a controller action and writing to the database. It is where the logic that is central to your system goes.
A simple example of a decree whose job is to create a new player being used from a Rails controller:
class PlayersController < ApplicationController
def create
redirect_to CreatePlayer.(player_params)
end
end
class CreatePlayer
def self.call(params)
player = Player.new(params)
player.save!
player
end
end
What is a Decree?
More formally, a Decree is an object with an imperative name and a single point of entry that is the same signature (or, at least, share the same method name as entry point) as all other Decrees in a system. The decree pattern incorporates elements of several Gang of Four patterns: Command, Facade, and Mediator. It is a flavor of Tell, Don’t Ask that accepts that in libraries such as ActiveRecord, what we think of as a data object is already also responsible at minimum for data validation and persistence and querying of the database.
A Decree does not have public instance methods, define getters or setters, or have a life-cycle that exceeds the unit of work being done. If there is a result that needs to be returned as a result of issuing a Decree, it should be returned from the .()
entry point method. This is a bit of “functional language feature envy” that Ruby sometimes shows, and that’s a good thing! We have a class method, pass it some objects, and optionally get a result back.
Is a Decree better than a method?
I’m sure you’ve noticed that the imperative naming reads a lot like method definitions. Decrees are structured as classes rather than as methods because doing so lends flexibility. It is sometimes desirable to have the option to delegate from the class method call
to an instance by internally calling new.call
. Doing so provides the option to cleanly splitting work between private methods without the need to pass state around as arguments to other class or module methods.
Back in 2017, Avdi Grimm wrote “Enough with the Service Objects Already”, an excellent critique of the redundant naming I mentioned earlier. His example, IpnProcessor.new.process_ipn
, is refactored to a module where Grimm likes to gather all procedures. He mentions that he tends to use a module named after the app he’s using, so he ends up with Percolator.process_ipn
. This is very similar to a Decree, but rather than extracting a class which can later be refactored to have state, split work among helper methods that use that state, etc., he ends up with his module-namespaced method. In my opinion, Decrees are a similar, but slightly improved, solution to the same problem.
The pattern I usually use for “delegating to instance” looks like:
class ProcessPayment
def self.call(payment_method:, amount:)
new(payment_method:, amount:).()
end
# initalize and call need to be public methods for the class method to call
# them, but this is a language limitation and they should not be used
# directly.
def initialize(payment_method:, amount:) # :nodoc:
@payment_method = payment_method
@amount = amount
end
def call() # :nodoc:
# do the work, call private methods, return a value or don't
end
private
attr_reader :payment_method, :amount
# ...
end
Why use Decrees?
Decrees provide benefits to easy refactoring thanks to their single method API surface area, ease of composition of other Decrees in an ergonomic way, and are very easy to mock or fake.
A refactor to replace a service object can be very difficult. Service objects that don’t follow the decree pattern likely have some mix of data and logic concerns, may be long-lived, and have multiple methods as part of its public API. A Decree, by contrast, can be replaced very easily because it fully encapsulates its API into one method. Its life cycle ends when the work it’s asked to do has been completed, even if it uses the delegate to instance pattern I described. In theory, a Decree without arguments could even be replaced with an empty lambda: -> () {}
. Even more complex Decrees with multiple arguments and that return meaningful data remain simple to replace as there is only that single method that needs to be modified at each call site.
Composing Decrees reads as a list of instructions.
class MakePurchase
def self.call(customer:, product:)
order = Order.create!(customer: customer, product: product)
result = ProcessPayment.(payment_method: customer.payment_method, amount: order.cost)
if result.declined?
SendDeclinedEmail.(order: order)
else
FulfillOrder.(order: order)
end
end
end
MakePurchase.(customer: customer, product: product)
Above, it’s perfectly fine to mix Decrees with ActiveRecord, but Order.create!
could also be a CreateOrder
Decree as in the ProcessPayment
, which is probably creating some ActiveRecord object and returning that as its result. I consider reaching for a Decree over directly creating something when I need to touch multiple objects, anytime I’d otherwise reach for a callback, and certainly any time creating requires multiple steps.
Faking a Decree is easy because you can replace the decree with a decree that does less, faster. A classic example of an opportune time to fake a bit of code is when you don’t want to make real API calls in a development or test environment. You can create a Decree that wraps that API call and any logic around it and use that in those environments rather than the real thing. If your application makes use of dependency injection, you can even fake a decree with a lambda that returns a minimal required value, either static or based on inputs.
processor = if Rails.env.production?
ProcessPayment
else
->(payment_method:, amount:) { PaymentResult.new(declined?: false) }
end
MakePurchase.(customer: customer, product: product, processor: processor)
Mocking is also easy because there is only one method to mock. For example to mock ProcessPayment
in rspec-mocks:
allow(ProcessPayment).to receive(:call)
.with(payment_method: customer.payment_method, amount: order.cost)
.and_return(instance_double(PaymentResult, declined?: false))
Or in Mocktail with:
Mocktail.replace(ProcessPayment)
stubs { ProcessPayment.(payment_method: customer.payment_method, amount: order.cost) }
.with { PaymentResult.new(declined?: true) }
Mocking like this allows you to avoid the need to use Webmock, VCR, or otherwise fake API responses. You’ll probably still use tools like that when testing ProcessPayment
directly, but there’s no need to duplicate all of that work when testing its collaborators.
Give it a shot!
Liberal use of Decrees allow us to achieve skinny controllers and skinny models. It pushes the behaviors that are most important to your application into the service layer, allowing Rails to fulfill its core competencies: handling web requests and persisting data. It improves our ability to test the most important logic in the system, avoids most naming disputes, rewards dependency injection, follows the Law of Demeter, and encourages adherence to the Single Responsibility Principle.