The Decorator Pattern in Ruby — Clean Code Without the Bloat

by Eric Hanson, Backend Developer at Clean Systems Consulting

The problem decorators solve

You have a User model. The views need formatted output — full_name, avatar_url, membership_label. The naive solution is adding these to the model. Six months later the model has thirty presentation methods sitting next to database logic, validations, and business rules. The methods are tested alongside ActiveRecord callbacks and the test suite loads the entire Rails environment to verify string formatting.

The alternative is keeping the model clean and wrapping it in a presentation layer at the point of use. That's a decorator: an object that wraps another object, adds or overrides methods, and delegates everything else to the wrapped object. No subclassing. No callbacks. No model pollution.

SimpleDelegator — the stdlib baseline

Ruby ships SimpleDelegator in the standard library. It wraps an object and forwards any method call it doesn't define to the wrapped object via method_missing:

require 'delegate'

class UserPresenter < SimpleDelegator
  def full_name
    "#{first_name} #{last_name}".strip
  end

  def membership_label
    case membership_tier
    when "pro"  then "Pro Member"
    when "free" then "Free Tier"
    else "Unknown"
    end
  end

  def avatar_url
    gravatar_url || "/images/default_avatar.png"
  end
end

At the call site:

presenter = UserPresenter.new(@user)
presenter.full_name        # => "Alice Nakamura"
presenter.email            # => delegated to @user.email
presenter.membership_label # => "Pro Member"

The wrapped object is accessible via __getobj__ and replaceable via __setobj__. SimpleDelegator uses method_missing under the hood, so respond_to_missing? is also implemented correctly — presenter.respond_to?(:email) returns true even though UserPresenter doesn't define email.

The main limitation: presenter.is_a?(User) returns false. If anything in your call stack does type-checking — a serializer, a policy object, a method that calls user.is_a?(ActiveRecord::Base) — the decorator breaks that check. This is often acceptable in presentation layers, occasionally a hard blocker elsewhere.

Manual delegation — explicit and fast

SimpleDelegator is convenient but carries method_missing overhead and can obscure which methods are actually delegated. For decorators with a defined, bounded interface, explicit delegation is preferable:

class UserPresenter
  def initialize(user)
    @user = user
  end

  # Delegated methods — explicit
  delegate :id, :email, :created_at, :membership_tier, to: :@user

  def full_name
    "#{@user.first_name} #{@user.last_name}".strip
  end

  def membership_label
    case @user.membership_tier
    when "pro"  then "Pro Member"
    when "free" then "Free Tier"
    else "Unknown"
    end
  end
end

delegate here is ActiveSupport::CoreExt::Module::Delegation. Outside Rails, you replicate it manually or use Forwardable from the stdlib:

require 'forwardable'

class UserPresenter
  extend Forwardable

  def_delegators :@user, :id, :email, :created_at, :membership_tier

  def initialize(user)
    @user = user
  end
end

Forwardable generates real methods rather than relying on method_missing, so delegation is as fast as a direct method call. The tradeoff: the delegation list is a maintenance surface. Add a method to the model that the presenter needs to expose and you have to update def_delegators explicitly. SimpleDelegator handles that automatically.

Module-based decoration without a wrapper class

For lightweight, additive behavior — logging, timing, feature flagging — you can decorate a specific object instance without a wrapper class using extend:

module TimedExecution
  def call(...)
    start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
    result = super
    elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
    Rails.logger.info "#{self.class}#call completed in #{elapsed.round(3)}s"
    result
  end
end

service = PlaceOrder.new(user: user, cart: cart, payment_method: pm)
service.extend(TimedExecution)
service.call

extend mixes the module into the object's singleton class, so only this instance is affected. Other PlaceOrder instances are untouched. super reaches the original call implementation because the module is prepended to the singleton class's ancestor chain.

This is the most surgical form of decoration Ruby offers. It's particularly useful in development or test environments where you want to instrument a specific object without modifying the class.

The downside: singleton class manipulation is implicit. Someone reading service.call has no idea TimedExecution is in play unless they see the extend call. For production instrumentation, prepend at the class level is more visible — the behavior applies uniformly and shows up in MyClass.ancestors.

Stacking decorators

One of the pattern's genuine benefits is composability. Multiple decorators can wrap the same object, each adding a layer:

class CachedUser < SimpleDelegator
  def expensive_metric
    Rails.cache.fetch("user_metric_#{id}", expires_in: 5.minutes) do
      __getobj__.expensive_metric
    end
  end
end

class AuditedUser < SimpleDelegator
  def update_role(role)
    AuditLog.record(user_id: id, action: "role_change", to: role)
    super
  end
end

user = AuditedUser.new(CachedUser.new(User.find(id)))

The outer decorator's method calls fall through to the next layer and ultimately reach the User instance. Each decorator is independently testable with a stub at its inner boundary.

The thing to watch: decorator order matters when two layers intercept the same method. AuditedUser#update_role calling super reaches CachedUser#update_role if CachedUser defines it, not User#update_role directly. Explicit __getobj__ calls can bypass intermediate layers if you need to skip one — but if you need to bypass a decorator you stacked intentionally, the stack is probably wrong.

Where the pattern breaks down

Serializers and type checks. ActiveRecord serializers, JSON serializers (Blueprinter, Alba, fast_jsonapi), and policy objects often check object.class or object.is_a?. A decorator wrapping a User fails those checks. You can override class on the decorator:

def class
  __getobj__.class
end

But that's a smell. If the system is doing type-based dispatch on your objects, a decorator that hides the type creates more problems than it solves. Use a presenter that's explicitly a different type and pass only what the serializer needs.

ActiveRecord associations. Decorating an AR model and then passing it to a method that calls user.posts or triggers additional queries works fine — the delegation reaches the underlying record. But saving, reloading, or touching the underlying record from inside the decorator couples presentation and persistence in a way that tends to cause confusion. Decorators should be read-only wrappers in most cases. If you need to write through a decorator, question whether the decorator is the right tool.

Draper. The Draper gem formalizes the Rails presenter/decorator pattern with decorate, decorates_association, and view context injection. It's a solid choice if you want conventions enforced across a team. The tradeoff is the added dependency and its opinions about how decorators relate to views. For non-Rails Ruby or simpler use cases, SimpleDelegator and Forwardable cover the same ground without the dependency.

The practical split

Use SimpleDelegator when you want full interface passthrough with minimal setup and the delegation boundary is open-ended.

Use Forwardable or delegate when you want explicit, documented delegation and performance matters.

Use extend on an instance when you need to add behavior to a single object at runtime without affecting the class.

Stack decorators when multiple orthogonal concerns — caching, auditing, presentation — need to wrap the same object independently.

The pattern earns its place when you find yourself adding methods to a model that exist only to serve one view, one API format, or one reporting context. That's the moment to wrap, not to pollute.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

The Hidden Cost of Hiring Backend Engineers in Seattle's Shadow Economy of Tech Giants

You thought $180K was a strong offer. Your candidate said it was "in the range" — then took a job at a company that doesn't even compete in your market.

Read more

Why 9 Developers Cannot Deliver a Project 9 Months Faster

It sounds logical: if one developer takes 9 months, then 9 developers should take 1 month. But software projects don’t work like that.

Read more

New Zealand's Tech Talent Pool Is Small. Async Remote Contractors Are How Startups Close the Gap

You've been looking for a senior backend engineer for three months. You've seen every relevant CV in Auckland twice. The pool isn't refreshing — it's the same twelve people.

Read more

Why Remote Contractors Deliver Faster Than Office Teams

Remote contractors focus on results, not office presence. With fewer meetings and clearer scope, work moves faster and more efficiently.

Read more