SRP Series · 5 of 7
Decorators
Where the pattern comes from, why display logic keeps creeping into models, and how Forem uses OrganizationDecorator to keep view concerns out of its data layer.
Where this rule comes from
The Decorator pattern predates Rails by a couple of decades. It is one of the original 23 patterns in the 1994 Gang of Four book, Design Patterns: Elements of Reusable Object-Oriented Software. The original framing was generic: wrap an object to add behavior to it dynamically, without changing the class it belongs to. The classic example in the book is a graphical window that can be wrapped in a "with scroll bars" decorator or a "with border" decorator.
The Rails community adopted the pattern in 2011 with Draper, a gem by Jeff Casimir that gave decorators a specific job: handle display logic about a model, without putting it on the model itself. Around the same time, active_decorator and a few similar gems appeared, all converging on the same shape. The community had recognized a recurring failure mode: display methods like user.formatted_name or order.status_badge_color kept landing on models, even though they had nothing to do with the database.
The underlying rule the decorator pattern enforces, in Rails terms: "what is this thing" and "how do we display this thing" are different responsibilities. The Organization model knows the columns, the associations, the validations, the domain logic. A decorator knows the colors, the formatted name, the fallback avatar URL, the way the trademark symbol gets appended for corporations. They use the same data, but they answer different questions, asked by different parts of the system.
If you let display logic stay on the model, every redesign becomes a model edit. The day the design team wants the listing card to use a slightly different shade of blue, you open a file used by Sidekiq workers, importers, the API, and the admin tool. That is the wrong file to be touching for a CSS-adjacent decision.
The anti-pattern
Picture a Rails app where the Organization model has grown over the years. The marketing team wanted color-coded org cards. The branding team wanted a darker version for hover states. The design team wanted a trademark symbol on certain orgs. Each request landed as a method on the model, because that was the path of least resistance.
class Organization < ApplicationRecord
# ... 200 lines of normal Organization logic ...
# display logic that crept in over the years
def bg_color_for_display
bg_color_hex.presence || "#0a0a0a"
end
def text_color_for_display
text_color_hex.presence || "#ffffff"
end
def darker_bg(adjustment = 0.88)
Color::CompareHex.new(
[bg_color_for_display, text_color_for_display]
).brightness(adjustment)
end
def display_name
"#{name}#{trademark? ? "™" : ""}"
end
def avatar_url_or_fallback
avatar.present? ? avatar.url : ActionController::Base.helpers.image_path("default-org.svg")
end
end Two specific problems are buried in this code, and both are signs the responsibilities have gotten tangled.
First, the Organization model is reaching into ActionController::Base.helpers to compute an asset path. Models do not belong in the controller layer; they should not even know that controllers exist. The moment a model imports a view helper, the layering of the app is broken. A Sidekiq job that loads an Organization should never need the controller stack on the load path, but here it does.
Second, every redesign now touches the model. When the design team rebrands the listing card next quarter, the new "darker bg" formula or the new fallback color or the new trademark rule all land in organization.rb. The diff in the PR mixes database concerns with CSS concerns. The risk of the model edit accidentally breaking something unrelated (a callback, a validation, an association) goes up every time the design team needs to ship something.
How Forem solves it
Forem uses Draper plus a custom ApplicationDecorator base class. Every model that has non-trivial display logic gets a parallel file under app/decorators/. The Organization one looks like this:
# forem/app/decorators/organization_decorator.rb · @9eb974c4
# License: AGPL-3.0
class OrganizationDecorator < ApplicationDecorator
def darker_color(adjustment = 0.88)
Color::CompareHex.new(
[enriched_colors[:bg], enriched_colors[:text]]
).brightness(adjustment)
end
def enriched_colors
if bg_color_hex.blank?
{ bg: assigned_color[:bg],
text: assigned_color[:text] }
else
{ bg: bg_color_hex,
text: text_color_hex.presence || assigned_color[:text] }
end
end
def assigned_color
{ bg: "#0a0a0a", text: "#ffffff" }
end
end Three design choices are doing the work here:
The decorator transparently delegates to the model. Inside enriched_colors, the calls to bg_color_hex and text_color_hex are not method definitions on the decorator. They are calls to the underlying Organization, automatically forwarded by Draper. Wrapping a model in a decorator gives you the model's full public interface plus the decorator's display methods, all on one object.
Nothing in the decorator changes the database. No callbacks, no validations, no save. The class only answers questions about presentation. That makes it impossible for a misplaced display method to accidentally trigger a write, which is one of the failure modes the model version was vulnerable to.
The default colors live as constants inside the decorator, not in the model. The Organization table does not have a "what color do we use if the org did not pick one" concept; that is a UI-side decision. Keeping the default in the decorator is the SRP-correct place for it.
The view side uses the decorated object instead of the raw model:
# In a controller
def show
@organization = Organization.find(params[:id]).decorate
end
# In the view (.html.erb)
<div style="background: <%= @organization.enriched_colors[:bg] %>;
color: <%= @organization.darker_color %>;">
<%= @organization.name %>
</div> The view never asks the model for display logic. The model never knows what hex shade was picked for hover. Each class has one reason to change: schema migrations touch the model, design-system changes touch the decorator, layout changes touch the view.
Why this design holds up
Four benefits, each one a fix for a specific failure of the fat-Organization version.
Models stay about data. The schema, the validations, the associations, and the genuine domain methods. No CSS-adjacent code, no view helpers smuggled in via ActionController::Base.helpers, no formatting logic. The model file shrinks, and what remains in it is unambiguously model-shaped.
Views stay declarative. No inline ternaries computing the right hex code or the right pluralized phrase. The view reads like markup with a few values plugged in, not like a logic-heavy template doing real work.
Display logic gets unit-tested. A decorator spec looks like OrganizationDecorator.new(build_stubbed(:organization)).darker_color. No view rendering, no controller cycle, no factory cascade. The same coverage runs in milliseconds and takes a few lines, which means display logic actually gets tested instead of being assumed to work.
One source of truth across multiple views. If the listing card, the profile header, and the email digest all need to show "the right shade of org color," they all use the decorator. The rebrand only changes one file. The bug class where two views render the same data differently disappears, because there is only one place the display rule lives.
Helpers vs decorators
Rails ships with helpers (app/helpers/) for view-layer utilities. Helpers and decorators overlap, and the choice between them matters. The rule of thumb:
- Helper, for view-layer utilities that do not center on a specific model. Formatting a money amount, rendering a flash message, building a navigation list, generating a CSS class from a status string.
- Decorator, for display logic about a specific record. Anything that would otherwise have been a method on the model named
{model}.formatted_x_for_viewor{model}.display_y.
A decorator is not a wrapper for arbitrary behavior. If a method on the decorator changes the world (sends an email, fires a job, updates a counter), it does not belong in a decorator. Decorators answer the question "how does this thing look?" and nothing else. The discipline is what keeps the responsibility from drifting.
The principle at play
The model owns the data; the view owns the layout; the decorator owns the rules that turn data into presentation. Each of those three has a different change driver. Schema migrations move the model. Layout redesigns move the view. Design-system tweaks move the decorator. Keeping the three responsibilities in three classes means each change touches the file that actually owns the decision.
The deeper move, the one the Gang of Four were pointing at in 1994, is that you can add behavior to an object without modifying the object itself. The Organization model never had to know it would one day need a "darker color for hover" method. The decorator was added later, in a separate file, and the model is unaware. Each new piece of behavior is additive, not invasive.
The Rails-specific lesson, two decades later: view-layer concerns try to drift downward into the model because models are conveniently passed to views and "putting a method on the user" feels easier than creating a new class. The drift is usually invisible until the first big redesign, at which point every CSS change becomes a database-model change, and the friction makes the team afraid to redesign. Decorators are the small upfront investment that keeps redesigns cheap.
Practice exercise
- Open your largest model. Search for methods that exist only to be called from a view: anything ending in
_for_display,_html,_text,formatted_*,display_*, or that return HTML-safe strings. - For each one, ask the question: "Would I ever call this from a Sidekiq job, a Rake task, or an importer?" If the answer is no, it is display logic and it belongs in a decorator.
- Sketch the decorator. Common name pattern:
{Model}Decorator. List the methods that move over. Visualize the model file with those methods gone, that is the version of the model you actually want. - Bonus: open
app/views/and look for ternary expressions, inline hex colors, orif/elsebranches choosing what to display. Each of those is presentation logic that escaped into the markup. Decorators are usually the better home.