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