In Progress
Unit 1, Lesson 21
In Progress

Process Object

Video transcript & code

The other day we had some code we wanted to factor out of an over-stuffed User class. We briefly considered moving it into a "service object", but we wound up deciding against that. Let's turn back the clock, and assume that we did in fact go with a service object approach.

Let's refamiliarize ourselves with this code. A request arrives at the /purchase_monkeys endpoint. We extract some information from the parameters, and then use the PurchaseMonkeys service class to perform the actual monkey-purchasing logic. Our response depends on whether the purchase succeeded or failed.

Meanwhile, in the PurchaseMonkeys service: the first thing we do is to determine if we are allowed to ship monkeys to the user, based on their address. In order to do this we have to consult the current iteration of the applicable shipping regulations.

Once we have our go-ahead to ship, we get ourselves a payment gateway object, calculate the total price, charge the customer using the gateway, and then tell the MonkeyWarehouse to ship some monkeys.

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
  result = 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

Time passes, and our requirements change. It turns out that our shipping service, "ZooPS", actually has a web service we can use to query whether it's possible to ship animals to a given location. We're eager to not have to maintain current versions of shipping regulations ourselves, so we decide to change our system to make use of this service.

There's just one hitch. For scalability, the service is asynchronous. We send off a query, and then we have to wait for it to hit a callback URL with a response either approving or denying the shipment.

Accordingly, we've broken our code down into two service objects. First, there's an InitiateMonkeyPurchase service, which collects information and kicks off the shipping approval process. Its perform method returns all of the information that will be needed to continue this transaction once the approval arrives.

Then we have a CompleteMonkeyPurchase service. Its perform method takes in the same information as the InitiateMonkeyPurchase service. It contains the other half of the code that was originally part of the PurchaseMonkeys service.

Now let's take a look at how we've had to update the HTTP endpoints for this application. In the /purchase_monkeys action, we now use the InitiateMonkeyPurchase service to kick off a purchase, and we capture the hash it returns. Now we need to store that information for later, when the approval shows up. So we have new helper method to store the information away somewhere. How this method works isn't important to the topic at hand: maybe it stuffs the information into Memcache, or perhaps we have an ad-hoc database table for it. Maybe we just keep it around in memory.

We also have a brand-new endpoint for callbacks from the ZooPS service. It pulls an approval_id out of the params, and then checks to see if an :approved flag is set in the affirmative. If so, it moves ahead into confirming the purchase. It retrieves the purchase information by approval ID, from wherever it was stored. Then it uses a CompleteMonkeyPurchase service to finish the order. In the process, it has to look up the user referenced in the purchase info, as well as supply other arguments sourced from the purchase_info hash.

require "sinatra"

module Services
  class InitiateMonkeyPurchase
    def self.perform(user:, card_info:, quantity: 1)
      approval_request_id = ZooPS.request_shipping_approval(
        zip_code: user.address.postal_code,
        species: "monkey",
        callback: "https://example.com/zoops_callback")

      {
        user_id:     user.id,
        card_info:   card_info,
        quantity:    quantity,
        approval_id: approval_request_id
      }
    end
  end

  class CompleteMonkeyPurchase
    def self.perform(user:, card_info:, quantity:)
      gateway = PaymentGateway.new
      price   = Monkey.current_price
      total   = price * quantity
      gateway.charge!(total, card_info)
      MonkeyWarehouse.ship_monkeys(quantity, user.address)
    end
  end
end

post "/purchase_monkeys" do
  card_info = params[:card_info]
  quantity  = params[:quantity].to_i
  user      = current_user
  purchase_info = Services::InitiateMonkeyPurchase.perform(
    user:      user,
    card_info: card_info,
    quantity:  quantity)
  store_purchase_info(purchase_info)
  "Your purchase is pending approval"
end


post "/zoops_callback" do
  approval_id = params[:id]
  if params[:approved] == "yes"
    purchase_info = retrieve_purchase_info(approval_id)
    Services::CompleteMonkeyPurchase.perform(
      user: User.find(purchase_info[:user_id]),
      card_info: purchase_info[:card_info],
      quantity: purchase_info[:quantity])
  end
end

Let's take a step back and look at what we've come up with here. We have two main bits of behavior: initiating a purchase, and completing a purchase. The methods implementing this behavior receive similar bundles of data. A given sequence of purchase initiation and completion has its own unique identity, in the form of the the approval_id that we use to retrieve the right purchase information. And this purchase process goes through different states: at first it is pending approval, and then after handling an approval, it is completed and should not be repeated.

Behavior, identity, state: these are familiar words. When we find these three properties together, it normally means we've identified a new object. In fact, if we look at our HTTP actions and squint, it kind of looks like we've re-invented object-orientation: we've got a bundle of data that we're passing around, and we're acting on it with different procedures.

So, if we've really identified a latent object, what sort of object is it?

Imagine we had to explain how monkeys are purchased to a programmer who is brand-new to the project. We might say:

"Purchasing monkeys is a multi-step process…"

And there's our clue right there. Purchasing monkeys is a process, and we need an object to encapsulate that process.

Let's take a look at what such a process object might look like. We'll start with how it is used, and then take a look at the implementation.

When we receive a /purchase_monkeys request, we first extract some parameter values as usual. Then, we create a new MonkeyPurchase object, passing in the information it needs to proceed.

At this point, the object is a potential purchase. We tell it to go ahead and get started by notifying it of an event: the fact that the user has submitted a purchase order. The choice to treat this as an event notification rather than as a command is significant, and we'll talk about it more in an upcoming episode.

We then tell the object to save itself, using whatever object/database mapping system we've opted for in this project. And we let the user know their purchase is in progress.

Later, when the callback arrives from the shipping service, and assuming the shipping has been approved, we retrieve our purchase process object using the approval_id. We notify the the object of a new event: that shipping has been approved. Then, we once again save the object back to the database.

Now let's look at the MonkeyPurchase process class definition. It contains attribute readers for its own state, the purchasing user, credit card information, monkey quantity, and the shipping approval ID.

On initialization, it stores all of the data it is given. It also puts itself in the :ready state.

When it receives the submitted notification, it reaches out to the ZooPS service, saving the approval_id away for later. It then updates its state to :pending_approval.

On receiving a shipping_approved notification, it completes the purchase and updates its own state to :completed.

At present we aren't really doing anything with the :state attribute. It's just informational. But in future, we might use it to validate that the object moves through states in the correct order.

require "sinatra"

class MonkeyPurchase < SchmactiveRecord::Base
  attr_accessors :state, :user, :card_info, :quantity, :approval_id

  def initialize(user:, card_info:, quantity: 1)
    self.user      = user
    self.card_info = card_info
    self.quantity  = quantity
    self.state     = :ready
  end

  def submitted
    self.approval_id = ZooPS.request_shipping_approval(
      zip_code: user.address.postal_code,
      species: "monkey")
    self.state = :pending_approval
  end

  def shipping_approved
    gateway = PaymentGateway.new
    price   = Monkey.current_price
    total   = price * quantity
    gateway.charge!(total, card_info)
    MonkeyWarehouse.ship_monkeys(quantity, user.address)
    self.state = :complete
  end

  # ...
end

post "/purchase_monkeys" do
  card_info = params[:card_info]
  quantity  = params[:quantity].to_i
  user      = current_user
  purchase = MonkeyPurchase.new(
    user:      user,
    card_info: card_info,
    quantity:  quantity)
  purchase.submitted
  purchase.save!
  "Your purchase is pending approval"
end


post "/zoops_callback" do
  approval_id = params[:id]
  if params[:approved] == "yes"
    purchase = MonkeyPurchase.find_by_approval_id(approval_id)
    purchase.shipping_approved
    purchase.save!
  end
end

Instead of a vague imitation of an object, we now have the real thing. And it has done a nice job of cleaning up our HTTP actions.

This new object is not a service object. It is not a transaction script. In a way, it's nothing special at all. Because what we've really done here is to identify another object in the domain. A MonkeyPurchase process is just as much a part of our domain model as a User, a Monkey, a warehouse, or a payment gateway.

Why didn't we see this object earlier? It's because we made a very common oversight in object modeling. We looked at a process, and instead of seeing it as a process, we saw it as a transaction. We treated purchasing monkeys as a discrete action. And service objects, which are effectively just procedures, are good for handling transactions.

But then something happened: reality intruded, and forced us to break up our transaction into two, related transactions, separated in time. This is where things started to get messy.

This kind of shift happens all the time in application programming. We model something as a transaction, and then discover it is a process. This happens a lot because, as it turns out, transactions actually aren't all that common out in the real world. Purchases turn out to be processes with multiple steps. User sign-ups turn out to be processes with multiple steps. If you talk to someone familiar with banking software, they will tell you that even bank transactions aren't really transactional! They are processes, which take time to settle. And, in fact, banks make quite a lot of money off of this fact.

Did we need to go through the "service object" stage before refactoring to a process object? No, we didn't. Here's an example of what the code might have looked like if we had modeled purchases as single-step processes from the very beginning.

This time, we have no need for persistence yet, so we're not building the class on an ORM base class. All of the logic for completing the purchase is contained in the submitted method. This method records a different ending state depending on whether the shipping was approved.

The /purchase_monkeys HTTP action is very similar to what we've already seen. The biggest difference is that we no longer save the purchase object at the end, and we have a switch on the ending state of the object to determine what to report back to the user who is eager to receive monkeys.

Incidentally, this is one of the side-benefits of using process objects over service objects. With a service object, we have to find a way to bundle up all relevant information about the result of an operation into the return value somehow, and hope that we included everything the client code might want to know. With a process object, operations are separated from outcomes. After notifying the process object of an event, the client code can query it for as much or as little information about the outcome as it needs to know.

require "sinatra"

class MonkeyPurchase
  attr_accessors :state, :user, :card_info, :quantity

  def initialize(user:, card_info:, quantity: 1)
    self.user      = user
    self.card_info = card_info
    self.quantity  = quantity
    self.state     = :ready
  end

  def submitted
    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)
      self.state = :complete
    else
      self.state = :failed
    end
  end

  private

  def can_purchase_monkey?(user)
    rules = ShippingRegulations.latest
    rules.can_ship_to?(user.address.postal_code)
  end
end

post "/purchase_monkeys" do
  card_info = params[:card_info]
  quantity  = params[:quantity].to_i
  user      = current_user
  purchase = MonkeyPurchase.new(
    user:      user,
    card_info: card_info,
    quantity:  quantity)
  purchase.submitted
  if purchase.state == :complete
    "Your monkeys are on their way!"
  else
    "We were unable to complete your purchase."
  end
end

So let's wrap up. What have we learned today? We've learned that most application domains are chock full of processes. Sometimes they masquerade as transactions, until the requirements are elaborated and we discover that there are discrete steps and states involved. So-called "service objects" are great for modeling transactions, but they break down when modeling ongoing processes, fragmenting the process across disconnected objects.

If we start from day one modeling business processes as first-class elements in our domain, we get a couple of benefits. As with service objects, we still have a place to put logic to handle user actions. A place where it won't bloat up our principle domain models, things like "User" or "Product". But unlike service objects, when it comes time to add more intermediate steps to these user activities, objects modeling processes can grow organically to accommodate the new logic. They can be queried for their current status at any time. And if necessary, we can even factor out subprocess objects to keep long, involved processes from becoming fat models in their own right.

And that's all for today. Happy hacking!

Responses