In Progress
Unit 1, Lesson 21
In Progress

Decorator

Video transcript & code

We've all heard the recommendation that an object should have a single responsibility. For instance, here is a BankAccount object. It has one major responsibility: track the account balance.

class BankAccount
  def initialize(number)
    @number        = number
    @balance_cents = 0
  end

  def deposit(amount)
    @balance_cents += amount
  end

  def withdraw(amount)
    @balance_cents -= amount
  end

  def inspect
    "#<%s %s ($%0.2f)>" % 
      [self.class, @number, @balance_cents / 100.0]
  end
  alias_method :to_s, :inspect
end

This class is really simple: it has an account number and an account balance stored in cents. It provides methods for depositing and withdrawing funds.

It also customizes the #inspect method, and aliases #to_s to be the same as #inspect. This is something I like to do in my classes. In this case, the most important thing about the BankAccount is the current balance, so I want the string representation to clearly show the balance. In order to do so I'm using a string format, which we learned about in Episode 194 and Episode 195. I'm being US-centric here and assuming that the balance is held in US dollars.

Using the class is straightforward. We can deposit funds, and we can withdraw funds. Remember that the balance is stored in cents, so 1000 represents 10 dollars.

require "./bank_account"
account = BankAccount.new(123)

account.deposit 1000
account
# => #<BankAccount 123 ($10.00)>

account.withdraw 500
account
# => #<BankAccount 123 ($5.00)>

Now along comes a new requirement: in some cases, we need to keep an audit log of transactions. Here's a simple audit logger that's just enough to demonstrate the interface we need audited bank accounts to talk to. It has a #record method that receives an account number, a symbolic representation of the action being recorded, the amount involved, and the ending balance.

class AuditLog
  def record(number, action, amount, balance)
    printf(
      "%<time>s #%<number>s %<action>s %<amount>.2f (%<balance>.2f)\n",
      time:    Time.now, 
      number:  number, 
      action:  action, 
      amount:  amount / 100, 
      balance: balance / 100)
  end
end

At first, we might think to just add the auditing functionality to the BankAccount class.

class BankAccount
  attr_accessor :audit_log

  def initialize(number)
    @number        = number
    @balance_cents = 0
  end

  def deposit(amount)
    @balance_cents += amount
    if audit_log
      audit_log.record(@number, :deposit, amount, @balance_cents)
    end
  end

  def withdraw(amount)
    @balance_cents -= amount
    if audit_log
      audit_log.record(@number, :withdraw, amount, @balance_cents)
    end
  end

  def inspect
    "#<%s %s ($%0.2f)>" % 
      [self.class, @number, @balance_cents / 100.0]
  end
  alias_method :to_s, :inspect
end

In both the #deposit and #withdraw methods, we add a conditional case for logging the transaction to an audit log.

Now when we use the account object and add an audit log, we can see the transactions being logged:

require "./bank_account_with_auditing"
require "./audit_log"

account = BankAccount.new(123)
account.audit_log = AuditLog.new
account.deposit 1000
account.withdraw 500
# >> 2014-03-25 16:29:03 -0400 #123 deposit 10.00 (10.00)
# >> 2014-03-25 16:29:03 -0400 #123 withdraw 5.00 (5.00)

But let's take a step back. BankAccount now has more responsibilities. As always, it must track an account balance. But now it also has to keep track of whether it should be auditing. And if it should audit, it is responsible for recording its transactions to the audit log.

So much for the Single Responsibility Principle. If this is the way we respond to each new feature, pretty soon BankAccount is going to have a whole raft of responsibilities.

If only there was a way to encapsulate the audit logging feature in its own object, while leaving BankAccount to do what it does best: tracking an account balance.

Well, as it happens there is, and it's called a Decorator. Let's create a simple audit logging decorator.

The fundamental idea behind the decorator pattern is do "wrap" an object with a thin layer of extra functionality, while still having the same interface as the object being wrapped. Since we will be delegating calls to a wrapped object, a typical starting point for a Decorator is to start with an object which simply delegates all method calls to another object, and then build custom functionality on top of that.

Along those lines, we could start to build an AuditedAccount by defining a #method_missing which simply forwards all method calls to another object.

class AuditedAccount
  def initialize(object)
    @object = object
  end

  def method_missing(method, *args, &block)
    @object.public_send(method, *args, &block)
  end
end

However, there are a number of considerations we need to make when building robust delegation in Ruby. For instance, we should also define a #respond_to_missing callback. And what if this object is queried for its list of methods? It should take into account methods defined on the delegation target. And so on, and so forth.

Instead of writing all of this from scratch, let's lean on the Ruby standard library.

We'll start by require the delegate library. Then we create out AuditedAccount as a child of the SimpleDelegator class. SimpleDelegator gives us robust delegation capabilities for free.

In our initializer, we accept both the vanilla account object, and an audit_log. We then call super with the account as the sole argument. SimpleDelegator provides a single-argument initializer which accepts the object which will be the target of delegation.

Our initializer also saves the audit_log into an instance variable for later use.

Next we define a #deposit method which takes an amount, just like the one on BankAccount. It starts by invoking super. This falls back to the default SimpleDelegator behavior, which is to forward the call on to the delegate object. Note that we use a "bare" super with no parens to pass all parameters through; if you're not familiar with this technique check out Episode 14.

After the call to super, we augment the delegated behavior with auditing by sending the record message to the audit log. In order to complete this message, we need the account number and balance from the wrapped bank account. In order to make give the AuditedAccount access to these attributes, we go back to the BankAccount class and generate attribute accessor methods for them.

class BankAccount
  attr_accessor :number, :balance_cents
end

Returning to the AuditedAccount decorator, we can now treat those new accessor methods as if they were local methods, since any unrecognized method calls will be automatically forwarded to the target object. We fill in the @audit_log.record message to include the account number, the action type, the amount, and the ending balance.

Next, we do the exact same thing for the withdraw method. We delegate to the original bank account object using super, and then send a #record message to the audit log.

require 'delegate'
class AuditedAccount < SimpleDelegator
  def initialize(account, audit_log)
    super(account)
    @audit_log = audit_log
  end

  def deposit(amount)
    super
    @audit_log.record(number, :deposit, amount, balance_cents)
  end

  def withdraw(amount)
    super
    @audit_log.record(number, :withdraw, amount, balance_cents)
  end
end

Here's how it looks in use. We instantiate a bank account. Then we instantiate an AuditedAccount, giving it the bank account and a new audit log object. When we deposit funds and withdraw funds, we can see that the actions are logged. When we inspect the resulting account object, it we can see the account number and balance appear just as before.

require "./bank_account"
require "./audited_account"
require "./audit_log"

account = BankAccount.new(123)
account = AuditedAccount.new(account, AuditLog.new)
account.deposit(1000)
account.withdraw(500)
# >> 2014-03-25 19:55:48 -0400 #123 deposit 10.00 (10.00)
# >> 2014-03-25 19:55:48 -0400 #123 withdraw 5.00 (5.00)
2014-04-25 13:33:26 -0400 #123 deposit 10.00 (10.00)
2014-04-25 13:33:26 -0400 #123 withdraw 5.00 (5.00)

Notice how for all intents and purposes, including our customized #inspect method, this decorated account object behaves just like a bare BankAccount object. That's the essence of a Decorator: add a little functionality on top, without changing the object's interface.

When we look at the objects we wound up with, the responsibilities are still nicely separated. Bank accounts are still just responsible for maintaining balances. Audited bank accounts are responsible for adding audit logging to a bank account, without worrying about how to maintain a balance.

What about the responsibility to decide whether an account should be audited? That's the responsibility of our top-level code, which makes the decision to wire together a BankAccount with an AuditLog using an AuditedAccount object. The BankAccount remains blissfully unaware.

The Decorator pattern is a great choice when we need to add a little extra functionality without the close collaboration of the object being extended. And Ruby's SimpleDelegator base class is a great way to quickly construct decorators. That's all for today. Happy hacking!

Responses