Video transcript & code
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
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
#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 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!