May 7, 2026
The decorator pattern in Rails: when ViewComponent is overkill
A presenter is the cheapest abstraction in Rails. Here's the bar I apply before reaching for one — and the bar before reaching past it to ViewComponent.
There’s a moment in a Rails view, usually around the second if nested inside an each, where I know the template has gotten away from me. The view is doing arithmetic. It’s branching on current_user.admin?. It’s calling .strftime with a format string a designer cared about three sprints ago. The instinct, in 2026, is to reach for view_component and componentize the whole thing.
Most of the time, the cheaper answer is a decorator.
This post is about the gap between “this view has a method on it” and “this view is a component.” A decorator fills that gap. It’s twenty lines of Ruby in app/decorators, no rendering layer, no slot DSL, no preview gem. When the gap is the right size, a decorator earns its file. When it isn’t, a helper, a scope, or ViewComponent is the better answer.
What a decorator is
A decorator wraps a model and adds presentation methods to it. The wrapped object still quacks like the model — decorator.title, decorator.published_at, decorator.user_id all work — but the decorator adds methods the model has no business knowing about, like “what color should this badge be” or “how do I format this date for the article header.”
The cheapest implementation is SimpleDelegator from the standard library:
# illustrative
class ArticleDecorator < SimpleDelegator
LONG_MARKDOWN = 900
def headline
title.size > 60 ? "#{title[0, 60]}..." : title
end
def long?
body_markdown.present? && body_markdown.size > LONG_MARKDOWN
end
def published_label
return "Draft" unless published?
"Published #{published_at.strftime('%b %-d')}"
end
end
That’s a decorator. No gem, no base class, nothing to install. The view writes @article.decorate.headline (where #decorate is a one-line method on the model that returns ArticleDecorator.new(self)) and the template stops doing arithmetic.
When a helper is enough
Before I reach for a decorator I ask whether a helper does the job. If the formatting is stateless and reused across multiple unrelated models — pretty_currency(amount), time_ago_with_tooltip(time) — a helper is fine. Helpers are global, which is their main weakness, but for cross-cutting formatting that weakness doesn’t bite.
The tell that a helper is wrong: the helper takes the same model as its first argument every time it’s called. article_headline(article), article_published_label(article), article_byline(article). That’s three helpers that all dispatch on the same noun. The noun wants its own object, and the object wants to be a decorator.
When a scope or model method is enough
Before I reach for a decorator I also ask whether the method belongs on the model. If the answer is a yes-or-no about the domain — “is this article published?”, “does this user have an active subscription?” — that’s a model concern, not a presentation concern. Put it on the model.
The line I draw: if the method’s name would still make sense in a Rake task with no view in sight, it’s a model method. article.published? makes sense at a console. article.published_label does not — "Published Jan 4" is a thing only a webpage cares about. Domain predicates on the model, presentation strings on the decorator.
Where Forem draws the line
Forem’s ArticleDecorator is the clearest production example I’ve read. It inherits from ApplicationDecorator (which uses draper), and the methods are exactly the shape I’d want:
# forem/forem · app/decorators/article_decorator.rb · @9eb974c (AGPL-3.0, 14-line excerpt)
def title_length_classification
if title.size > 105
"longest"
elsif title.size > 80
"longer"
elsif title.size > 60
"long"
elsif title.size > 22
"medium"
else
"short"
end
end
def long_markdown?
body_markdown.present? && body_markdown.size > LONG_MARKDOWN_THRESHOLD
end
title_length_classification returns a string the CSS uses to pick a font size. That belongs nowhere near the Article model — the database doesn’t care that the marketing team picked five buckets for headline length. But it also shouldn’t live in a helper, because the only thing it knows how to classify is an article’s title. It’s a method on a noun, called from a template, that returns a presentation value. Decorator.
Same with long_markdown?. The threshold (900 characters) is a presentation decision; the predicate is parametric on the article’s body. The model doesn’t need to know there’s a UI somewhere that switches layouts at 900 characters.
What’s notable about Forem’s file: the methods are tiny. One conditional, one delegation, one string concatenation. None of them are doing the kind of work that would justify a ViewComponent. But there are about thirty of them, and trying to express that as thirty helpers — article_title_length_classification(article), article_long_markdown?(article) — would be unbearable.
The Draper question
Draper gives you .decorate on every model, a Draper::Decorator base class with delegate_all, and integration with Rails’ helper module so decorator methods can call link_to and friends. It’s been around since 2011 and it works.
The case for it: you get helpers.link_to, you get free pagination collection decoration, you get a convention every Rails developer who’s been around a while will recognize. The case against it: it’s another gem in the dependency graph for what SimpleDelegator plus a handwritten decorate method gives you in eight lines.
My default these days is no Draper. SimpleDelegator for the wrapping, an app/decorators/application_decorator.rb with the two or three helpers I actually need (URL helpers, mostly), and a decorate method on ApplicationRecord that returns "#{self.class.name}Decorator".constantize.new(self). That’s the whole apparatus. If I find myself wanting helpers.image_tag inside a decorator three times in a week, I add Draper. I haven’t yet.
When a decorator stops being enough
A decorator is presentation logic on a single model. The moment I want to render markup — not return a string, but output HTML with structure, slots, and conditional regions — I’m past the decorator’s job and into ViewComponent’s.
Specifically: I switch to view_component when one of these is true.
- The thing has its own template file. A decorator method that returns HTML with five interpolated values is wrong. That’s a partial trying to escape; let it become a component.
- The thing accepts slots — header, body, footer, an arbitrary block. Decorators don’t compose blocks.
- The thing is rendered in isolation in tests. Component tests are fast and assert on rendered output. Decorator tests assert on string return values. Different testing shape, different problem.
- The thing has state during a render — accumulating items, tracking an index across nested partials. Decorators are stateless wrappers; components have an instance per render.
The shape I keep coming back to: decorator for “what string should I display here,” component for “what markup should I render here.” A decorator answers a question. A component renders a region.
A worked split
Here’s an article card I’d build in 2026, with the responsibilities sorted.
# illustrative
# app/decorators/article_decorator.rb
class ArticleDecorator < SimpleDelegator
def card_title
title.size > 60 ? "#{title[0, 57]}..." : title
end
def reading_time_label
minutes = (body_markdown.size / 1000.0).ceil
"#{minutes} min read"
end
def author_byline
"by #{user.name} · #{published_at.strftime('%b %-d, %Y')}"
end
end
<%# app/components/article_card_component.html.erb %>
<article class="card">
<h3><%= link_to article.card_title, article.current_state_path %></h3>
<p class="meta"><%= article.author_byline %> · <%= article.reading_time_label %></p>
<% if article.long_markdown? %>
<span class="badge">Long read</span>
<% end %>
</article>
# illustrative
# app/components/article_card_component.rb
class ArticleCardComponent < ViewComponent::Base
def initialize(article:)
@article = article.decorate
end
attr_reader :article
end
The decorator owns the strings. The component owns the markup. Neither owns both. The model — the real Article — is unchanged; it doesn’t know there’s a card, doesn’t know there’s a 60-character title cap, doesn’t know there’s a “Long read” badge.
The takeaway
Reach for a decorator when a model accumulates more than two presentation methods that have no business being on the model. Reach past the decorator for ViewComponent only when you need markup, slots, or isolated render tests. Helpers stay alive for the genuinely cross-cutting formatters; everything else moves to the noun it belongs to.
The bar I apply at the keyboard: if I’m about to write a third def article_*(article) helper, I’m two methods overdue for a decorator.
Code excerpt: article_decorator.rb — Forem AGPL-3.0, pinned at SHA 9eb974c4d30df25b6bd7fb7854431758f61826f4, used as a 14-line excerpt under fair use.