In Progress
Unit 1, Lesson 21
In Progress

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