In Progress
Unit 1, Lesson 1
In Progress

Notify, Don’t Tell

Video transcript & code

In the last episode, we bucked current fashions and modeled purchasing items as a process object, instead of as a service object. Let's briefly review that code before we continue.

When a user submits an order for instant monkeys, we collect some information about the order and then instantiate a MonkeyPurchase object. We notify the object that the order has been submitted, and then we save it.

One of the side effects of submitting an order is that it kicks off an asynchronous query to a shipping service, checking whether we can legally ship monkeys to the user's address.

Later, the service triggers a callback action. In the callback, we look up the pending purchase, and notify it that shipping approval has come through. Then we save it again.

One thing that may seem a little odd about this code is that every time we receive an HTTP request, instead of telling the purchase object to do something, we notify it of an event that has occurred. This design choice is deliberate, and I want to talk a bit more about it.

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

Let's take a look at what the MonkeyPurchase presently does when it is told that shipping has been approved. Briefly stated, it calculates a total price, processes the payment, ships the requested monkeys, and then marks itself as completed.

If we had to sum all of these activities up, we might say that after the shipping is approved, the purchase process completes itself. So why don't we call this method something like complete, instead?

For the sake of argument, let's go ahead and make that change.

class MonkeyPurchase < SchmactiveRecord::Base
  # ...
  def complete
    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 "/zoops_callback" do
  approval_id = params[:id]
  if params[:approved] == "yes"
    purchase = MonkeyPurchase.find_by_approval_id(approval_id)
    purchase.complete
    purchase.save!
  end
end

Now, instead of informing the purchase process of an event, we are giving it a command: "complete yourself!"

In the last episode, we remarked on the fact that as an application matures, business processes tend to become elaborated. This process started out having just a single action, which then became two actions when we found we had to wait for a callback from the shipping service before we could complete the purchase.

What if, in the future, we discover that there is a third step that needs to be broken out? What if payments made by electronic check can't be completed immediately, and we have to kick off an asynchronous job to poll payment status until the payment succeeds and we can move on to shipping monkeys?

class MonkeyPurchase < SchmactiveRecord::Base
  # ...
  def process_payment
    gateway = PaymentGateway.new
    price   = Monkey.current_price
    total   = price * quantity
    charge_id = gateway.charge!(total, card_info)
    enqueue_payment_poll_job(charge_id)
    self.state = :pending_payment
  end

  def ship_monkeys
    MonkeyWarehouse.ship_monkeys(quantity, user.address)
    self.state = :complete
  end
  # ...
end

Now we have to change our HTTP callback action to send a different command when the shipping approval comes in.

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

So that's one problem right there: the single responsibility principle says that we try to set things up so that we only have to make a change in one place. Here we've had to change both the process object and the HTTP request handler.

But this is indicative of a deeper issue. Usually, when we extract out service objects or process objects from our HTTP actions, we are doing it because we want to separate our code business logic from our routing and other web implementation logic. By having the HTTP request handler give commands to the process object, we've implicitly made it responsible both for noting an event, and for deciding what domain actions should be taken based on that event. In effect, we've put a little bit of business knowledge into the web controller layer.

What if we had stuck with our original version that notified the object of an event, rather than commanding it to do something? How would we have had to change the shipping approval callback after splitting out the payment processing?

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

The answer, of course, is that we wouldn't have had to change anything. By choosing to notify the purchase process of an event it may be interested in, instead of telling it how it should react to that event, we've made a clear division of responsibilities.

There are some side benefits to this approach as well. One common defect that we want to avoid in web applications is processing the same transaction more than once. Our process object has persistent state, so it is able to check its current state and avoid processing a payment twice using a guard clause.

class MonkeyPurchase < SchmactiveRecord::Base
  # ...
  def process_payment
    return unless state == :pending_approval
    gateway = PaymentGateway.new
    price   = Monkey.current_price
    total   = price * quantity
    charge_id = gateway.charge!(total, card_info)
    enqueue_payment_poll_job(charge_id)
    self.state = :pending_payment
  end
  # ...
end

However, now the name of the method, #process_payment, is a little misleading. It suggests that if we send that message, a payment will be processed. But the semantics of the method are now that the payment might be processed, so long as the object is in the right state.

If we go back to naming the method as a notification, we are no longer suggesting anything about how the purchase process will handle the event. The business rules for what to do next after shipping approval are now free to change without misleading anyone reading the code.

class MonkeyPurchase < SchmactiveRecord::Base
  # ...
  def shipping_approved
    return unless state == :pending_approval
    gateway = PaymentGateway.new
    price   = Monkey.current_price
    total   = price * quantity
    charge_id = gateway.charge!(total, card_info)
    enqueue_payment_poll_job(charge_id)
    self.state = :pending_payment
  end
  # ...
end

This convention of naming methods for events rather than as commands may look a little peculiar by conventional OO standards. But it's fully in line with Alan Kay's original vision of objects as tiny, independent computers sending messages to each other. A message is just a message. It isn't a demand.

An essential element of object-oriented separation of concerns is object humility: an object should know its own business, and shouldn't try to make decisions for other objects. In this application, it is the HTTP controller's business to discern what kind of request has arrived from a client, and let our domain objects know about the request. It doesn't also need to decide how those objects should deal with the event. And so, it sends them event notifications, not commands.

And that's it for today. Happy hacking!

Responses