Adding Functionality to Ruby Classes With Decorators

In my last article, I presented some code that wrapped up accessing a customer’s Stripe data and added a caching layer on top. I wanted to take some time to dig in to that code and see how we can make it better.

Decorators give us a tool to add additional functionality to a class while still keeping the public API consistent. From the perspective of the client, this is a win-win! Not only do they get the added behavior, but they don’t need to call different methods to do so.

The Problem

Our original class accessed data from Stripe AND cached the response for some time period. I accentuated “AND” because it’s generally the word to be on alert for when considering whether functionality can be teased apart in to separate responsibilities.

The question becomes, can we make one class that accesses Stripe data, and another that’s only responsible for caching it?

Of course we can!

The Solution

Let’s start with the most basic form of accessing our Stripe customer data with the Stripe gem:

class AccountsController < ApplicationController
  before_action :require_authentication

  def show
    @customer = Stripe::Customer.retrieve(current_user.stripe_id)
    @invoices = @customer.invoices
    @upcoming_invoice = @customer.upcoming_invoice
  end
end

Extract an Adapter

Because we’re interfacing with a third-party system (Stripe), it makes sense for to create a local adapter to access the Stripe methods. It’s probably not likely we’re going to switch out the official Stripe gem for another one that access the same data, but a better argument might be that we could switch billing systems entirely in the future. And if we make a more generic adapter to our third-party billing system, we would only need to update our adapter when that time comes.

While the adapter optimization may seem like overkill here, we’ll see how that generic adapter helps us implement our caching layer shortly.

Let’s start by removing the notion that it’s Stripe and all and call it Billing. Here we can expose the methods needed from the AccountsController above:

class Billing
  attr_reader :billing_id

  def initialize(billing_id)
    @billing_id = billing_id
  end

  def customer
    Stripe::Customer.retrieve(billing_id)
  end

  def invoices
    customer.invoices
  end

  def upcoming_invoice
    customer.upcoming_invoice
  end
end

There we have it. A simple Billing class that wraps the methods that we used in the first place – no change in functionality. But certainly more organized and isolated.

Let’s now use this new class in the accounts controller from earlier:

class AccountsController < ApplicationController
  before_action :require_authentication

  def show
    billing = Billing.new(current_user.stripe_id)

    @customer = billing.customer
    @invoices = billing.invoices
    @upcoming_invoice = billing.upcoming_invoice
  end
end

Not too bad! At this point we’ve provide the exact same functionality we had before, but we have a class that sits in the middle between the controller and Stripe gem - an adapter if you will.

Create a Decorator

Now that we have our adapter set up, let’s look at how we can add caching behavior to improve the performance of our accounts page.

The most of basic form of a decorator is to pass in the object we’re decorating (Billing), and define the same methods of the billing, but add the additional functionality on top of them.

Let’s create a base form of BillingWithCache that does nothing more than call the host methods:

class BillingWithCache
  def initialize(billing_service)
    @billing_service = billing_service
  end

  def customer
    billing_service.customer
  end

  def invoices
    customer.invoices
  end

  def upcoming_invoice
    customer.upcoming_invoice
  end

  private

  attr_reader :billing_service
end

So while we haven’t added any additional functionality, we have created the ability for this class to be used in place of our existing Billing class because it responds to the same API (#customer, #invoices, #upcoming_invoice).

Integrating this new class with AccountsController looks like:

class AccountsController < ApplicationController
  before_action :require_authentication

  def show
    billing = BillingWithCache.new(Billing.new(current_user.stripe_id))

    @customer = billing.customer
    @invoices = billing.invoices
    @upcoming_invoice = billing.upcoming_invoice
  end
end

As you can see, we only had to change one line – the line where we decorated the original billing class:

BillingWithCache.new(Billing.new(current_user.stripe_id))

I know what you’re thinking, “But it doesn’t actually cache anything!”. You’re right! Let’s dig in to the BillingWithCache class and add that.

Adding Caching Functionality

In order to cache data using Rails.cache, we’re going to need a cache key of some kind. Fortunately, the original Billing class provides a reader for billing_id that will allow us to make this unique to that user.

def cache_key(item)
  "user/#{billing_id}/billing/#{item}"
end

In this case, item can refer to things like "customer", "invoices", or "upcoming_invoice". This gives us a method we can use internally with BillingWithCache to provide a cache key unique to the both the user and the type of data we’re caching.

Adding in the calls to actually cache the data:

class BillingWithCache
  def initialize(billing_service)
    @billing_service = billing_service
  end

  def customer
    key = cache_key("customer")

    Rails.cache.fetch(key, expires: 15.minutes) do
      billing_service.customer
    end
  end

  def invoices
    key = cache_key("invoices")

    Rails.cache.fetch(key, expires: 15.minutes) do
      customer.invoices
    end
  end

  def upcoming_invoice
    key = cache_key("upcoming_invoice")

    Rails.cache.fetch(key, expires: 15.minutes) do
      customer.upcoming_invoice
    end
  end

  private

  attr_reader :billing_service

  def cache_key(item)
    "user/#{billing_service.billing_id}/billing/#{item}"
  end
end

The code above caches the call to each of these methods for 15 minutes. We could go further and move that to an argument with a default value, but I’ll leave as an exercise for another time.

Summary

Separating your application and third-party services helps keeps your applications flexible – offering the freedom to switch to another service when one no longer fits the bill.

Another benefit of an adapter is you have the freedom to name the class and methods whatever you like. The base gem for a service might not have the best names, or it may be that the names don’t make sense when dragged in to your application’s domain. This is a small but important point as applications get larger and its code more complex. The more variable/method names you need to think about when you poke around the code, the harder it’ll be to remember what was going on. Not to mention the pain new developers will have if they acquire the code. Whether it’s you or the next developer, the time you invest in creating great names will be greatly appreciated.

Using decorators in this way makes it easier for clients of the code to avoid change, but keep your applications flexible. The Billing class above was relatively simple – intentionally so. If the class being decorated has more than a few methods, it might be worth incorporating SimpleDelegator to ensure the methods that don’t need additional functionality still continue to respond appropriately.