Table of Contents
- What Are Decorators?
- Why Use Decorators in Ruby?
- Understanding the Decorator Pattern
- Implementing Decorators in Ruby
- Advanced Decorator Techniques
- Practical Examples
- Common Pitfalls and Best Practices
- Conclusion
- References
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):
- Component: The abstract interface (or base class) that both the original object and decorators implement.
- ConcreteComponent: The object to be decorated (e.g., a
CoffeeorUserinstance). - Decorator: An abstract class/module that wraps a
Componentand delegates methods to it. It declares the interface for concrete decorators. - ConcreteDecorator: Implements the
Decoratorinterface and adds specific behavior (e.g.,MilkDecoratororAdminDecorator).
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:
- Over-Decoration: Stacking too many decorators (e.g.,
A.new(B.new(C.new(obj)))) can make code hard to follow. - 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.
- Performance Overhead:
method_missingand deep decorator stacks can slow down method calls. - State Leakage: Decorators should avoid modifying the wrapped object’s state (keep them stateless when possible).
Best Practices:
- Keep Decorators Focused: Each decorator should handle one responsibility (e.g., logging, discounts, or permissions).
- Use
SimpleDelegator: Prefer it over manual delegation to reduce boilerplate and errors. - Test Decorators in Isolation: Test decorators with mock components to ensure they work independently.
- Document Behavior: Clearly state what the decorator does (e.g., “Adds 10% tax to the price”).
- Avoid
method_missingfor 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
- Design Patterns: Elements of Reusable Object-Oriented Software (Gang of Four)
- Ruby Documentation:
SimpleDelegator - Ruby Documentation:
method_missing - Pragmatic Programmers: Decorator Pattern in Ruby
- ThoughtBot: Using Decorators in Ruby