The Decorator Pattern in Ruby

Jan 10, 2022 - 5 min read
The Decorator Pattern in Ruby
Share

The Decorator pattern allows you to attach new behavior to individual objects dynamically without affecting their classes or other objects of the same class.

Here's a video version of this article if you prefer to watch instead:

It's similar to the Adapter pattern which, I've talked about in a separate video that I'm going to link to in the description, but the difference is that an Adapter changes the interface of the object it wraps, while the decorator does not.

And because the interface remains unchanged, you can stack multiple decorators together to change behavior at runtime.

You might think you could achieve the same result by using inheritance instead, but the problem with inheritance is that you have less flexibility and a lot more complexity.

You need to create a class for ever possible combination you may use, and that gets very messy very quickly.

But let's look at an example of a decorator.

# lib/01_poros.rb
class Person
  def feeling_at(outside_temp)
    if outside_temp > 20
      "Warm"
    else
      "Cold"
    end
  end
end

class Shirt
  def initialize(person)
    @person = person
  end

  def feeling_at(outside_temp)
    if outside_temp >= 30
      "Going for a swim"
    else
      @person.feeling_at(outside_temp)
    end
  end
end

class Coat
  def initialize(person)
    @person = person
  end

  def feeling_at(outside_temp)
    if outside_temp >= 35
      "Crazy hot"
    else
      @person.feeling_at(outside_temp)
    end
  end
end

# You need to take care of the object's entire interface.
# It doesn't address the "transparent interface" requirement.
outside_temp = 30

joe = Person.new
puts joe.feeling_at(outside_temp)
joe_shirt = Shirt.new(joe)
puts joe_shirt.feeling_at(outside_temp)
joe_coat = Coat.new(joe_shirt)
puts joe_coat.feeling_at(outside_temp)
puts "Class: #{joe_coat.class}"

In this first example, we're using the Shirt object and the Coat object to decorate the Person object.

Each decorator object can wrap either the original Person object or an already decorated one and change the behavior of the feeling_at method.

You could also wrap the object with the same decorator multiple times. It doesn't make much sense to do that in this particular example, but you could if you wanted to.

One problem with this implementation is, if the original object has other methods as well, we need to delegate all those the other methods we don't care about to the wrapped object in order to comply with the transparent interface requirement in the Gang of Four book.

And another problem is that the decorated object's class is not the original one (i.e., the Person class). It's the decorator's class. In this case, it's the Coat class.

So let's look at a different approach.

# lib/02_modules.rb
class Pizza
  def cost = 2.0

  def foo = "foo"
end

module Onions
  def cost = super + 1.0
end

module Cheese
  def cost = super + 2.2
end

# We can only extend the object once.
pizza = Pizza.new
pizza.extend(Onions)
pizza.extend(Cheese)

puts "Your pizza costs: #{pizza.cost}"
puts "Class: #{pizza.class}"

We can use modules to decorate an object as well.

The only problem with this approach is we can't extend an object more than once with the same module. So we can't have a double cheese pizza, for example.

If we were to use the previous approach, with the POROs, we could wrap the pizza object in a Cheese decorator twice to get the double cheese pizza.

But with the modules approach, Ruby won't allow that.

But otherwise, if you don't need that feature, the modules approach gives you the correct class name back, and it also looks pretty clean.

Ok, so we looked at how to decorate an object using both POROs and modules. But what else can we do?

Well...

# lib/03_method_missing.rb
module Decorator
  def initialize(component)
    @component = component
  end

  def method_missing(meth, *args)
    if @component.respond_to?(meth)
      @component.send(meth, *args)
    else
      super
    end
  end

  def respond_to?(meth)
    @component.respond_to?(meth)
  end
end

class Coffee
  def cost
    2
  end

  def origin
    "Colombia"
  end
end

class Milk
  include Decorator

  def cost
    @component.cost + 0.4
  end
end

coffee = Coffee.new
puts "Americano (#{coffee.origin}): $#{coffee.cost}" # => Americano (Colombia): $2
latte = Milk.new(coffee)
puts "Latte (#{latte.origin}): $#{latte.cost}" # => Latte (Colombia): $2.4
puts "Class: #{latte.class}" # => Coffee

We could use method_missing in combination with the POROs version to get past that inconvenience where the interface was not transparent.

Meaning if you had more methods on the original object, you needed to somehow forward those methods from the decorated object to the wrapped one.

There are two downsides to this approach.

First, using method_missing is slow.

And second, the class of the decorated object is the decorator class, namely Milk, instead of the Coffee class.

Lastly, there is the option of using SimpleDelegator instead of method_missing to achieve the same result.

# lib/04_simple_delegator.rb
require "delegate"

class Coffee
  def cost = 2

  def origin = "Colombia"
end

class Milk < SimpleDelegator
  def initialize(coffee)
    @coffee = coffee
    super
  end

  def class = __getobj__.class

  def cost = @coffee.cost + 0.4
end

coffee = Coffee.new
puts "Americano (#{coffee.origin}): $#{coffee.cost}"
latte = Milk.new(coffee)
puts "Latte (#{latte.origin}): $#{latte.cost}"
puts "Class: #{latte.class}"

If I were to sort these approaches by personal preference, I'd pick this one second. After the modules version.

It's got almost everything right.

You get to stack how many decorators you want; you can stack the same decorator multiple times and get the original class name back. It's got a lot of good things going for it.

But there's just one thing I'm not necessarily a fan of. And that is redefining the class method. I'm not 100% sure that's a good idea.

I like the decorator pattern because it allows you to create multiple single-responsibility decorators that you can plug and play as you see fit.

SERIOUS ABOUT LEARNING RAILS?

Every other week you'll get at least 1 actionable tip on how to become a better Ruby on Rails developer.
    We won't send you spam. Unsubscribe at any time.
    Built with ConvertKit
    Cezar Halmagean
    Software development consultant with over a decade of experience in helping growing companies scale large Ruby on Rails applications. Has written about the process of building Ruby on Rails applications in RubyWeekly, SemaphoreCI, and Foundr.