Video transcript & code
Way back in the early 2000s, Java enterprise developers started noticing that their domain model objects were getting bigger and bigger. Some of them decided to address the problem by moving domain logic out into single-purpose classes they called "Service Objects".
Ten years later, Rails programmers moved all of their domain logic out of controllers and into "fat models", and ran into the same trouble all over again. Soon, they too were turning to Service Objects as a way to bundle up domain actions and slim down models.
I've never been entirely comfortable with this design movement, and in this episode I want to start to explain why.
Here's some make-believe code for a store called Instant Monkeys Online. Place an order at Instant Monkeys Online, and they'll ship monkeys to you - instantly!
Purchasing monkeys is handled through a "purchase monkeys" action. It gathers up credit card information, the number of monkeys desired, and the current user, and then delegates the actual purchasing to a method on the user object.
User class has to know all about purchasing monkeys. And that doesn't mean just one method, either. It turns out that inexplicably, some states don't allow us to just ship monkeys to people without first acquiring certain exotic animal permits. In order to comply with local laws, before completing a purchase we have to check if a user is able to purchase a monkey based on their address. For this reason, we have another method dedicated to determining shipping eligibility.
This is some seriously dubious domain modeling. If a
User is responsible for purchase logic, that probably means it's responsible for a dozen other unrelated tasks as well. This kind of code organization is how we get thousand-line
require "sinatra" post "/purchase_monkeys" do card_info = params[:card_info] quantity = params[:quantity].to_i user = current_user success = user.purchase_monkeys(card_info: card_info, quantity: quantity) if result "Monkeys are on their way!" else "Sorry, no monkeys for you." end end class User def purchase_monkeys(card_info:, quantity: 1) if can_purchase_monkey? gateway = PaymentGateway.new price = Monkey.current_price total = price * quantity gateway.charge!(total, card_info) MonkeyWarehouse.ship_monkeys(quantity, address) end end def can_purchase_monkey? rules = ShippingRegulations.latest rules.can_ship_to?(address.postal_code) end end
So we decide to extract out the purchase logic into a Service Object. There are a lot of variations on the Service Object style, but they are all broadly similar. We have a namespace dedicated to services, which probably corresponds to a separate directory tree on disk. We have a class called
PurchaseMonkeys. This class is stateless; it exists only to contain a class-level method called
.perform, which is where we have moved the monkey-purchasing logic. We've also moved the
.perform method has been updated to also receive a
user object. It now has to explicitly pass that object to the
.can_purchase_monkey? predicate method.
The updated controller action calls this
.perform method, passing in the dependencies it needs.
require "sinatra" module Services class PurchaseMonkeys def self.perform(user:, card_info:, quantity: 1) if can_purchase_monkey?(user) gateway = PaymentGateway.new price = Monkey.current_price total = price * quantity gateway.charge!(total, card_info) MonkeyWarehouse.ship_monkeys(quantity, user.address) end end def self.can_purchase_monkey?(user) rules = ShippingRegulations.latest rules.can_ship_to?(user.address.postal_code) end end end post "/purchase_monkeys" do card_info = params[:card_info] quantity = params[:quantity].to_i user = current_user Services::PurchaseMonkeys.perform(user: user, card_info: card_info, quantity: quantity) if result "Monkeys are on their way!" else "Sorry, no monkeys for you." end end
Is this an improvement? Well, by some measures it is. At least we've removed this logic from the overstuffed
But even though
PurchaseMonkeys is technically a class, we're note really using it as one. We could change these class methods to instance methods, but we'd still have a procedure in object's clothing.
The truth is, we've basically given up on trying to figure out where this logic belongs in our domain model, and we've decided to throw it in with a bunch of other procedures in the
Now, don't get me wrong. It can sometimes be good to punt on figuring out where in the domain model some logic belongs. In fact, it's much better to delay finding a home for some logic than to prematurely settle it into a place that doesn't make sense—like, for instance, the
But if that's what we're doing, let's be deliberate about it. In his book Patterns of Enterprise Application Architecture, Martin Fowler documents the Transaction Script pattern. A Transaction Script bundles up a single system interaction into an explicitly procedural method.
Here's some code that handles monkey purchases in a way that's directly inspired by the Transaction Script pattern. There are a few things we can notice about it.
First off, we're no longer giving each procedure its own class. We've come up with a
Purchasing module, which we're using to namespace transaction scripts related to, you guessed it, purchasing things. Because transaction scripts are one-shot procedures, we don't necessarily need to give them the dignity of their own individual classes. If we ever decide to get into the instant llama business, that procedure would go in this same module.
In the controller action, we're now referencing
purchase_monkeys as a module method on the
There's something else that has changed here. There is no longer a separate
can_purchase_monkeys? predicate method. Instead, we've inlined its logic straight into the transaction script.
Why? Well, as we said earlier, we're treating this module of transaction scripts as a staging area for logic that hasn't found its true home yet. In a way, we've de-factored our application a little bit, in preparation for a better, future organization of code.
But if we're going to de-factor, it's important to take it all the way. When we keep vestiges of the old organization around, it can limit our thinking.
Instead, what we've done here is lay out very clearly and explicitly, from beginning to end, what is needed to make a monkey purchase. We've created a classically procedural recipe, including dependency gathering and a series of sequential actions. This gives us an excellent vantage point from which to decide where to go from here, design-wise. We can conduct various mental experiments about how to redistribute these responsibilities, without being influenced by a preexisting organization.
require "sinatra" module Purchasing module_function def purchase_monkeys(user:, card_info:, quantity: 1) rules = ShippingRegulations.latest gateway = PaymentGateway.new price = Monkey.current_price total = price * quantity can_purchase = rules.can_purchase_monkey?(user.address.postal_code) if can_purchase gateway.charge!(total, card_info) MonkeyWarehouse.ship_monkeys(quantity, user.address) end end def purchase_llamas # ... end end post "/purchase_monkeys" do card_info = params[:card_info] quantity = params[:quantity].to_i user = current_user success = Purchasing.purchase_monkeys(user: user, card_info: card_info, quantity: quantity) if result "Monkeys are on their way!" else "Sorry, no monkeys for you." end end
And where do we go from here? In an upcoming episode, we'll explore a way to incorporate this logic into our domain model, without making any existing model classes overweight. Until then, happy hacking!