← All posts

Jun 10, 2026

Presenter Pattern in Rails: SimpleDelegator, Draper, or Just Helpers?

When one model grows a dozen view-shaped methods, you have three options. Here's how I pick between SimpleDelegator, Draper, and plain helpers — and where ViewComponent draws the line.

rails patterns presenters decorators draper

A Job model with formatted_salary, posted_ago_in_words, country_flag_emoji, tier_badge_class, and tooltip_text on it is a model that’s quietly turned into a view. Those methods don’t change records. They format them. They belong somewhere closer to the template.

The question is where. Rails gives you at least three answers, and I’ve used all three in production. Here’s how I decide.

The shape of the problem

I’ll use a job board model, since that’s what I work on every day:

# illustrative
class Job < ApplicationRecord
  belongs_to :company

  def formatted_salary
    return "Not disclosed" unless salary_min
    "#{salary_currency_symbol}#{salary_min / 1000}k–#{salary_max / 1000}k"
  end

  def posted_ago
    "#{((Time.current - posted_at) / 1.day).to_i}d ago"
  end

  def country_flag
    COUNTRY_FLAGS[country_code] || ""
  end

  def tooltip_text
    "Posted #{posted_ago} on #{source_name}"
  end
end

Five view-shaped methods. None of them touch the database. None of them are valid in a JSON serializer or an API context — they’re for HTML. They’ve snuck into the model because the template was the path of least resistance.

Option 1: A SimpleDelegator subclass

The lightest possible presenter is one Ruby file:

# illustrative
require "delegate"

class JobPresenter < SimpleDelegator
  def initialize(job, view)
    super(job)
    @view = view
  end

  def formatted_salary
    return "Not disclosed" unless salary_min
    "#{salary_currency_symbol}#{salary_min / 1000}k–#{salary_max / 1000}k"
  end

  def posted_ago
    @view.time_ago_in_words(posted_at) + " ago"
  end

  def country_flag
    COUNTRY_FLAGS[country_code] || ""
  end
end

Then in the controller or view:

# illustrative
@job = JobPresenter.new(Job.find(params[:id]), view_context)

That’s it. No gem. No conventions to learn. The presenter forwards every method it doesn’t define to the wrapped model, so @job.title still works in the template. You get access to view helpers via the injected view (or view_context).

I reach for this when I have one or two models that need presentation logic and I don’t want a dependency. The cost is that you’re hand-rolling the lookup pattern (controller has to wrap, every collection action has to map).

Option 2: The draper gem

draper formalizes the SimpleDelegator pattern and adds the conveniences:

# illustrative
class JobDecorator < Draper::Decorator
  delegate_all

  def formatted_salary
    return "Not disclosed" unless object.salary_min
    h.number_to_currency(object.salary_min, ...)
  end

  def posted_ago
    h.time_ago_in_words(object.posted_at) + " ago"
  end
end

Then Job.find(params[:id]).decorate returns the decorated instance, Job.all.decorate decorates the collection, and h gives you helpers without injecting view_context yourself.

Where Draper earns its keep:

  • You have 5+ models with presentation logic. The convention pays for itself.
  • You’re building admin UIs where every model has a “display name”, “status badge”, “edit link” pattern.
  • Your team already knows it. Draper has been around long enough that “I’ll write a decorator” is unambiguous.

Where I skip it:

  • The app is small. One file in app/presenters/ is fewer moving parts than a gem.
  • You’ve already adopted ViewComponent (more on this below).

Option 3: Plain helper modules

Rails’ default answer:

# illustrative
module JobsHelper
  def formatted_job_salary(job)
    return "Not disclosed" unless job.salary_min
    "#{job.salary_currency_symbol}#{job.salary_min / 1000}k–#{job.salary_max / 1000}k"
  end

  def job_posted_ago(job)
    "#{time_ago_in_words(job.posted_at)} ago"
  end
end

Helpers are global. That’s their feature and their flaw. Every helper module gets mixed into every view. formatted_salary(job) clashes with formatted_salary(invoice) unless you prefix every method with the model name — which is what I always end up doing.

I use helpers for truly app-wide formatting (formatted_money, inline_svg, external_link_to) and almost never for model-specific presentation anymore. The prefix pollution is a real cost, and helpers can’t carry state — every method takes the object as its first argument, every time.

The boundary with ViewComponent

I covered when ViewComponent is overkill in 01 — Decorator pattern in Rails. The short version of the boundary:

  • Presenter: wraps a model, exposes formatted attributes. Output is strings/booleans/CSS classes. No template.
  • ViewComponent: encapsulates a slice of HTML, takes any inputs, renders a template. Output is markup.

A JobCard is a ViewComponent — it owns a chunk of HTML with conditional classes, slots for actions, the works. A JobPresenter is the thing you pass into the JobCard, so the component doesn’t have to know that formatted_salary is “Not disclosed” when salary_min is nil.

You use both. They’re not alternatives.

How I actually pick

A flowchart that lives in my head:

  1. One model, ≤3 view methods? Leave them on the model. Yes, mixing concerns. Yes, fine.
  2. One or two models, more methods than that? Hand-rolled SimpleDelegator in app/presenters/. No gem.
  3. Five-plus models, app-wide pattern, team familiar with Draper? draper.
  4. The “presentation logic” is actually “this chunk of UI”? ViewComponent.
  5. Truly cross-cutting formatting? Helper module, prefix the method names.

The mistake I see most often is jumping to (3) before (2). A 200-line Ruby file in app/presenters/job_presenter.rb covers most apps for years. You can always promote it to a Draper decorator later if you grow into the convention. Going the other direction — ripping Draper out — is less fun.

The thing about presenters is that they’re a destination for view logic, not a magic word. Wherever you put it, the win is the same: the model stops carrying methods that only make sense inside an ERB tag, and the template stops calling if job.salary_min.present? && job.salary_max.present? three times in a row.