cyberangles guide

How to Implement Decorators in Ruby

In the world of object-oriented programming, there are times when you need to add behavior to an object dynamically without altering its class definition or resorting to inheritance. This is where **decorators** come into play. Decorators are a design pattern that allows you to wrap an object with another object (the decorator) to extend its functionality at runtime. Ruby, with its flexible syntax, metaprogramming capabilities, and built-in tools like `SimpleDelegator`, makes implementing decorators a breeze. Whether you want to add logging, validation, or role-specific behavior to an object, decorators provide a clean, maintainable alternative to bloated classes or deep inheritance hierarchies. In this blog, we’ll explore what decorators are, why they’re useful in Ruby, and how to implement them—from basic examples to advanced techniques. By the end, you’ll have the knowledge to use decorators effectively in your Ruby projects.

Table of Contents

What Are Decorators?

Decorators are structural design patterns that dynamically add behavior to objects by wrapping them in a “decorator” object. Unlike inheritance, which adds behavior to an entire class (and all its instances), decorators target individual objects. This means you can extend one instance of a class without affecting others.

Key Characteristics of Decorators:

  • Dynamic: Behavior is added at runtime, not compile time.
  • Non-intrusive: The original object’s class remains unmodified (follows the Open/Closed Principle: open for extension, closed for modification).
  • Composable: Multiple decorators can be stacked to combine behaviors (e.g., MilkDecorator.new(SugarDecorator.new(Coffee.new))).
  • Interface Preservation: Decorators must implement the same interface as the object they wrap, ensuring they can be used interchangeably with the original object.

Why Use Decorators in Ruby?

Ruby’s flexibility makes decorators particularly powerful. Here’s why you might choose them over other patterns:

1. Avoid Inheritance Bloat

Inheritance hierarchies can quickly become unwieldy (e.g., PremiumUser < User, AdminUser < User, PremiumAdminUser < PremiumUser). Decorators let you add role-specific behavior without creating a proliferation of subclasses.

2. Dynamic Behavior

Need to add logging to a single Order instance but not others? Decorators let you do this on the fly.

3. Reusability

Decorators are modular and can be reused across different objects (e.g., a LoggingDecorator can wrap User, Product, or Order objects).

4. Ruby’s Built-in Tools

Ruby’s standard library includes SimpleDelegator (a class for easy delegation), making decorator implementation trivial.

Understanding the Decorator Pattern

The decorator pattern consists of four main components, as defined by the Gang of Four (GoF):

  1. Component: The abstract interface (or base class) that both the original object and decorators implement.
  2. ConcreteComponent: The object to be decorated (e.g., a Coffee or User instance).
  3. Decorator: An abstract class/module that wraps a Component and delegates methods to it. It declares the interface for concrete decorators.
  4. ConcreteDecorator: Implements the Decorator interface and adds specific behavior (e.g., MilkDecorator or AdminDecorator).

In Ruby, we often skip the “abstract” components (since Ruby lacks formal interfaces) and focus on delegation: decorators wrap a target object and forward method calls to it, adding behavior where needed.

Implementing Decorators in Ruby

Let’s dive into practical implementations, starting with the basics.

Basic Decorator Implementation (Manual Delegation)

The simplest way to create a decorator is to manually delegate methods to the wrapped object. Here’s a classic example with a Coffee class and decorators for adding milk and sugar:

Step 1: Define the ConcreteComponent (Coffee)

# ConcreteComponent: The base object to decorate
class Coffee
  def cost
    2.0 # Base price of coffee
  end

  def description
    "Basic coffee"
  end
end

Step 2: Define Concrete Decorators

Decorators wrap the Coffee object and override methods to add behavior:

# ConcreteDecorator: Adds milk to coffee
class MilkDecorator
  def initialize(coffee)
    @coffee = coffee # Wrap the coffee object
  end

  # Delegate to @coffee and add milk cost
  def cost
    @coffee.cost + 0.5 # Milk adds $0.50
  end

  # Delegate to @coffee and add milk to description
  def description
    "#{@coffee.description}, with milk"
  end
end

# ConcreteDecorator: Adds sugar to coffee
class SugarDecorator
  def initialize(coffee)
    @coffee = coffee
  end

  def cost
    @coffee.cost + 0.25 # Sugar adds $0.25
  end

  def description
    "#{@coffee.description}, with sugar"
  end
end

Step 3: Use the Decorators

# Create a basic coffee
basic_coffee = Coffee.new
puts basic_coffee.cost # => 2.0
puts basic_coffee.description # => "Basic coffee"

# Add milk
milk_coffee = MilkDecorator.new(basic_coffee)
puts milk_coffee.cost # => 2.5
puts milk_coffee.description # => "Basic coffee, with milk"

# Add sugar to the milk coffee (stack decorators)
sweet_milk_coffee = SugarDecorator.new(milk_coffee)
puts sweet_milk_coffee.cost # => 2.75
puts sweet_milk_coffee.description # => "Basic coffee, with milk, with sugar"

Pros: Simple and explicit.
Cons: Requires manually delegating every method of the Component (e.g., if Coffee adds a ingredients method, MilkDecorator must also implement it to avoid errors).

Using Ruby’s SimpleDelegator

Ruby’s standard library includes SimpleDelegator (from the delegate module), which automates method delegation. It wraps an object and forwards all undefined methods to it, eliminating the need for manual delegation.

Example with SimpleDelegator

require 'delegate' # Include the delegate library

# ConcreteComponent: Same Coffee class as before
class Coffee
  def cost; 2.0; end
  def description; "Basic coffee"; end
end

# Decorator using SimpleDelegator
class MilkDecorator < SimpleDelegator
  # SimpleDelegator handles delegation; we only override methods to decorate
  def cost
    super + 0.5 # super calls the wrapped object's cost method
  end

  def description
    "#{super}, with milk"
  end
end

class SugarDecorator < SimpleDelegator
  def cost
    super + 0.25
  end

  def description
    "#{super}, with sugar"
  end
end

# Usage is identical to the manual example!
basic_coffee = Coffee.new
milk_coffee = MilkDecorator.new(basic_coffee)
sweet_milk_coffee = SugarDecorator.new(milk_coffee)

puts sweet_milk_coffee.cost # => 2.75
puts sweet_milk_coffee.description # => "Basic coffee, with milk, with sugar"

Why This Works: SimpleDelegator inherits from Delegator, which uses method_missing to forward calls to the wrapped object. You only need to override methods you want to decorate.

Using Modules for Decorators

Ruby modules can also act as decorators by adding methods to an object (via extend or include). This is useful for reusable, mix-in style decorators.

Example: Logging Decorator Module

# Decorator Module: Adds logging to method calls
module LoggingDecorator
  def with_logging(method_name)
    # Override the method to log before and after execution
    original_method = instance_method(method_name)
    define_method(method_name) do |*args, &block|
      puts "Calling #{method_name} with args: #{args.inspect}"
      result = original_method.bind(self).call(*args, &block)
      puts "#{method_name} returned: #{result.inspect}"
      result
    end
  end
end

# Usage: Decorate a User class with logging
class User
  extend LoggingDecorator # Add the decorator module

  def initialize(name); @name = name; end

  # Define a method to decorate
  def greet
    "Hello, #{@name}!"
  end

  # Apply logging to the greet method
  with_logging :greet
end

user = User.new("Alice")
user.greet
# Output:
# Calling greet with args: []
# greet returned: "Hello, Alice!"
# => "Hello, Alice!"

Pros: Modules are lightweight and reusable across classes.
Cons: Modifies the class itself (not individual instances) unless used with extend on an instance:

# Decorate a single instance (instead of the entire class)
user = User.new("Bob")
user.extend(LoggingDecorator)
user.with_logging(:greet)
user.greet # Now logs for this instance only

Advanced Decorator Techniques

Dynamic Decoration with method_missing

method_missing is a Ruby hook that intercepts calls to undefined methods. You can use it to dynamically decorate methods without explicitly overriding them.

Example: Dynamic Logging Decorator

class DynamicLoggingDecorator
  def initialize(component)
    @component = component
  end

  # Intercept method calls
  def method_missing(method, *args, &block)
    if @component.respond_to?(method)
      # Log before execution
      puts "[LOG] Calling #{method} on #{@component.class}"
      # Call the method on the component
      result = @component.send(method, *args, &block)
      # Log after execution
      puts "[LOG] #{method} returned: #{result}"
      result
    else
      # Raise an error if the component doesn't respond to the method
      super
    end
  end

  # Ensure respond_to? works correctly with method_missing
  def respond_to_missing?(method, include_private = false)
    @component.respond_to?(method, include_private) || super
  end
end

# Usage
coffee = Coffee.new
logged_coffee = DynamicLoggingDecorator.new(coffee)

logged_coffee.cost
# Output:
# [LOG] Calling cost on Coffee
# [LOG] cost returned: 2.0
# => 2.0

Pros: Decorates all methods of the component dynamically.
Cons: Slightly slower than direct delegation (due to method_missing overhead) and harder to debug.

Decorating with Procs/Lambdas

For one-off decorations, you can use procs/lambdas to wrap methods. This is useful for simple, ad-hoc behavior.

Example: Wrapping a Method with a Proc

class ProcDecorator
  def initialize(component, method, &block)
    @component = component
    @method = method
    @block = block # The decorator logic
  end

  def call(*args)
    original_result = @component.send(@method, *args)
    @block.call(original_result) # Apply the decorator
  end
end

# Usage: Add tax to Coffee's cost
coffee = Coffee.new
tax_decorator = ProcDecorator.new(coffee, :cost) { |cost| cost * 1.1 } # 10% tax
puts tax_decorator.call # => 2.2

Pros: Extremely flexible for short, inline decorations.
Cons: Not ideal for complex or reusable decorators.

Practical Examples

Let’s explore real-world use cases for decorators.

Example 1: User Role Decorators

Suppose you have a User class, and you want to add role-specific behavior (e.g., Admin vs. Premium users) without subclasses.

require 'delegate'

# ConcreteComponent: Base User class
class User
  attr_reader :name, :email

  def initialize(name, email)
    @name = name
    @email = email
  end

  def permissions
    [:read] # Default permission: read-only
  end
end

# Decorator for Admin users
class AdminDecorator < SimpleDelegator
  def permissions
    super + [:create, :update, :delete] # Add admin permissions
  end

  def admin?
    true
  end
end

# Decorator for Premium users
class PremiumDecorator < SimpleDelegator
  def permissions
    super + [:download] # Add premium permission
  end

  def premium?
    true
  end
end

# Usage
basic_user = User.new("Alice", "[email protected]")
puts basic_user.permissions # => [:read]
puts basic_user.respond_to?(:admin?) # => false (no admin? method)

admin_user = AdminDecorator.new(basic_user)
puts admin_user.permissions # => [:read, :create, :update, :delete]
puts admin_user.admin? # => true
puts admin_user.name # => "Alice" (delegated to User)

premium_user = PremiumDecorator.new(User.new("Bob", "[email protected]"))
puts premium_user.permissions # => [:read, :download]

Example 2: Product Discount Decorator

Add dynamic discounts to products without modifying the Product class.

require 'delegate'

class Product
  attr_reader :name, :price

  def initialize(name, price)
    @name = name
    @price = price
  end

  def display_price
    "$#{price}"
  end
end

class DiscountDecorator < SimpleDelegator
  def initialize(product, discount_percent)
    super(product)
    @discount_percent = discount_percent
  end

  # Override price to apply discount
  def price
    super * (1 - @discount_percent / 100.0)
  end

  # Override display_price to show discount
  def display_price
    original_price = __getobj__.price # Access the wrapped product's original price
    discounted = "$#{price.round(2)} (Save $#{(original_price - price).round(2)})"
  end
end

# Usage
laptop = Product.new("Laptop", 1000)
puts laptop.display_price # => "$1000"

discounted_laptop = DiscountDecorator.new(laptop, 15) # 15% discount
puts discounted_laptop.price # => 850.0
puts discounted_laptop.display_price # => "$850.0 (Save $150.0)"

Common Pitfalls and Best Practices

Pitfalls to Avoid:

  1. Over-Decoration: Stacking too many decorators (e.g., A.new(B.new(C.new(obj)))) can make code hard to follow.
  2. Breaking the Interface: Decorators must implement the same interface as the component. If a decorator omits a method, it may break code expecting the original interface.
  3. Performance Overhead: method_missing and deep decorator stacks can slow down method calls.
  4. State Leakage: Decorators should avoid modifying the wrapped object’s state (keep them stateless when possible).

Best Practices:

  1. Keep Decorators Focused: Each decorator should handle one responsibility (e.g., logging, discounts, or permissions).
  2. Use SimpleDelegator: Prefer it over manual delegation to reduce boilerplate and errors.
  3. Test Decorators in Isolation: Test decorators with mock components to ensure they work independently.
  4. Document Behavior: Clearly state what the decorator does (e.g., “Adds 10% tax to the price”).
  5. Avoid method_missing for Critical Paths: Use it sparingly, as it’s slower and harder to debug.

Conclusion

Decorators are a powerful tool in Ruby for adding dynamic, reusable behavior to objects. They help avoid inheritance bloat, keep classes focused, and enable flexible composition of features. By leveraging Ruby’s SimpleDelegator, modules, and metaprogramming hooks like method_missing, you can implement decorators tailored to your needs.

Next time you find yourself tempted to add a subclass or monkey-patch a class, consider using a decorator instead—it might lead to cleaner, more maintainable code.

References