Transaction Script
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.
The 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 User
classes.
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 can_purchase_monkey?
predicate.
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 User
class.
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 services
subdirectory.
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 User
model.
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.
We're also using module_function (as seen in Episode #49) to make methods available both as mixins and as module-level methods.
In the controller action, we're now referencing purchase_monkeys
as a module method on the Purchasing
module.
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!
Responses