Decorators in Ruby — Delegation Patterns That Don't Fall Apart
You want to add display logic to a model without putting it on the model. So you reach for SimpleDelegator, wrap the object, add three methods, and it works — until something calls is_a?, or the decorated object gets passed to url_for, or you try to compare two of them, or you decorate a collection and every element loses its class. The decorator pattern in Ruby is straightforward; the delegation mechanics underneath it have sharp edges worth knowing before you build on them.
The Four Ways to Delegate
Ruby gives you four tools with genuinely different trade-offs.
1. def and forward manually
class OrderPresenter
def initialize(order) = @order = order
def total = @order.total
def status = @order.status
def formatted_total = "$#{'%.2f' % (@order.total / 100.0)}"
end
Explicit, obvious, and tedious past four methods. It’s also the only option with a genuinely closed interface — nothing leaks through that you didn’t write.
2. Forwardable
require "forwardable"
class OrderPresenter
extend Forwardable
def_delegators :@order, :total, :status, :placed_at
def_delegator :@order, :customer, :buyer # rename while forwarding
def initialize(order) = @order = order
def formatted_total = "$#{'%.2f' % (total / 100.0)}"
end
Standard library, explicit about what’s exposed, and supports renaming. This is the right default for a presenter with a known surface.
3. delegate (ActiveSupport)
class OrderPresenter
delegate :total, :status, :placed_at, to: :order
delegate :name, :email, to: :customer, prefix: true, allow_nil: true
attr_reader :order
def initialize(order) = @order = order
def customer = order.customer
end
prefix: gives you customer_name, allow_nil: returns nil instead of raising. Nicer ergonomics than Forwardable if you’re already in Rails.
4. SimpleDelegator
require "delegate"
class OrderPresenter < SimpleDelegator
def formatted_total = "$#{'%.2f' % (total / 100.0)}"
end
presenter = OrderPresenter.new(order)
presenter.status # forwarded
presenter.formatted_total # decorator's own
presenter.__getobj__ # the wrapped order
Everything forwards by default. Convenient, and the source of every problem in the next section.
Where SimpleDelegator Bites
is_a? lies, and class tells the truth
presenter.is_a?(Order) # => false
presenter.class # => OrderPresenter
presenter.kind_of?(Order) # => false
SimpleDelegator forwards method_missing, but class, is_a?, and instance_of? are defined on Object so they resolve on the delegator itself. Anything doing type checks — and Rails does, in url_for, in form builders, in polymorphic associations — sees the wrapper, not the model.
The usual patch:
class OrderPresenter < SimpleDelegator
def is_a?(klass) = __getobj__.is_a?(klass) || super
def kind_of?(klass) = is_a?(klass)
end
That fixes the symptom and creates a different problem: an object that claims to be an Order but isn’t will fail in places that assume the claim. Lying about type is a decision, not a fix — make it deliberately.
Equality is not what you expect
OrderPresenter.new(order) == order # => true (delegated ==)
order == OrderPresenter.new(order) # => false (Order#== sees a non-Order)
[order].include?(OrderPresenter.new(order)) # => depends on direction
Asymmetric equality breaks uniq, include?, Set, and hash keys in ways that are extremely annoying to debug. If decorated objects will ever be compared, define ==, eql?, and hash explicitly:
def ==(other) = __getobj__ == (other.respond_to?(:__getobj__) ? other.__getobj__ : other)
alias eql? ==
def hash = __getobj__.hash
Double decoration
presenter = OrderPresenter.new(OrderPresenter.new(order))
Two layers of forwarding, and __getobj__ returns the inner presenter rather than the order. Easy to do accidentally when a collection gets decorated twice. Guard it:
def self.wrap(object)
object.is_a?(self) ? object : new(object)
end
respond_to? and protected methods
SimpleDelegator forwards public methods. Protected and private methods on the wrapped object aren’t reachable, which surprises people who use send on the decorator and get NoMethodError.
The Pattern That Holds Up
For most presenter and decorator work, explicit delegation with a narrow surface beats blanket forwarding.
Example:
class OrderPresenter
delegate :id, :to_param, :status, :placed_at, to: :order
attr_reader :order, :view
def initialize(order, view: nil)
@order = order
@view = view
end
def self.wrap(orders, **kwargs)
orders.map { |o| new(o, **kwargs) }
end
def formatted_total
view.number_to_currency(order.total_cents / 100.0, unit: order.currency_symbol)
end
def status_badge
view.tag.span(status.titleize, class: "badge badge--#{status}")
end
def placed_on
order.placed_at.strftime("%d %b %Y")
end
end
Three properties worth noting: to_param is delegated explicitly so link_to "View", presenter works without lying about type; the view context is injected rather than pulled from a global; and wrap handles collections in one place.
Method-Level Decoration With Module#prepend
The other kind of decorator — wrapping behaviour rather than presenting data — is better served by prepend than by any delegation mechanism.
Example:
module Instrumented
def charge(amount)
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
result = super
Metrics.timing("payment.charge", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
result
rescue => e
Metrics.increment("payment.charge.error", tags: ["class:#{e.class}"])
raise
end
end
class PaymentProcessor
prepend Instrumented
def charge(amount)
Stripe::Charge.create(amount:)
end
end
No wrapper object, no delegation, no type confusion. PaymentProcessor.new is a PaymentProcessor, and super reaches the original implementation. It composes — three prepended modules stack in a defined order visible in ancestors — and it unwinds cleanly, which alias-based method chaining never did.
The limitation is that it applies to the class, not to individual instances. When you need one specific object decorated and others not, you’re back to a wrapper — or to extend on that instance:
processor = PaymentProcessor.new
processor.extend(Instrumented) # this object only
Pro-Tip: Before writing a decorator, check whether you need one at all. The most common use — “display logic that doesn’t belong on the model” — is often better served by a plain object that takes the model rather than wraps it.
OrderSummary.new(order).to_hhas no delegation, no type ambiguity, no equality problems, and a completely explicit interface, and the only cost is writingsummary.order.statusinstead ofsummary.statusin the two places you need the raw value. Delegation is a convenience feature; it earns its complexity when you have twenty pass-through methods, not four. The decorators that cause production bugs are almost always the ones that were written for four.
Testing Decorators
The thing worth asserting is the surface, not the forwarding.
Example:
RSpec.describe OrderPresenter do
let(:order) { build(:order, total_cents: 4250, currency_symbol: "$", status: "shipped") }
let(:view) { ActionController::Base.new.view_context }
subject(:presenter) { described_class.new(order, view:) }
it "formats the total as currency" do
expect(presenter.formatted_total).to eq("$42.50")
end
it "renders a status badge with a modifier class" do
expect(presenter.status_badge).to have_css("span.badge--shipped", text: "Shipped")
end
it "exposes only the delegated methods it declares" do
expect(presenter).to respond_to(:status, :placed_at)
expect(presenter).not_to respond_to(:destroy)
end
end
That last example is the one people skip and shouldn’t — it’s the test that fails when someone swaps explicit delegation for SimpleDelegator and accidentally exposes destroy to a view.
Conclusion
SimpleDelegator is the fastest way to a decorator and the fastest way to a type-identity bug, because blanket forwarding exposes an interface you never chose and is_a? quietly reports the wrapper. Prefer explicit delegation with Forwardable or ActiveSupport’s delegate and a surface you can read in one screen. For behavioural decoration, prepend avoids wrapping entirely and keeps ancestors honest about what’s happening. And check first whether a plain collaborator object — one that takes the model instead of impersonating it — would do the job with none of the complexity.
FAQs
Q1: What’s the difference between SimpleDelegator and DelegateClass?
DelegateClass(Order) generates a class that delegates only Order’s public instance methods, defined at class-creation time. It’s narrower and slightly faster than SimpleDelegator’s method_missing, and it won’t forward methods added to Order afterwards.
Q2: Do decorators hurt performance?
method_missing-based forwarding is measurably slower than a direct call — irrelevant for a handful of calls per request, noticeable in a loop over ten thousand records. Explicit delegation via def_delegators generates real methods and doesn’t have this cost.
Q3: Should decorators be able to access the view context?
If they generate HTML, they need it, and injecting it explicitly is much better than reaching for a global. If a decorator needs the view for everything it does, consider whether it should be a helper or a ViewComponent instead.
Q4: How do I decorate an ActiveRecord relation?
Map over it after loading, or wrap the relation in a small collection object that decorates on iteration. Don’t try to make the decorator itself relation-like — chaining .where onto a decorated collection is a rabbit hole.
Q5: Is Draper still a reasonable choice?
It handles collection decoration, view context, and the is_a? problem in one package, at the cost of a fair amount of implicit behaviour. Plain PORO presenters have become the more common choice, largely because the explicit version turns out to be about the same amount of code.
Check viewARU - Brand Newsletter!
Newsletter to DEVs by DEVs - boost your Personal Brand & career! 🚀