May 27, 2026
Inside Forem's ReactionHandler: the Result pattern done in 14 lines
Forem's ReactionHandler ships a tiny inner Result class that captures everything I want from a service object's return value. Walking through it line by line.
I keep a list of small Rails files I send to people who ask “what does a good service object look like?” Forem’s ReactionHandler is on it. Not for the handler itself — that’s 200-ish lines of reaction creation, rate limiting, notifications, and audit logging — but for the tiny Result class nested at the top.
If you’ve ever argued with a teammate about whether a service object should raise, return a hash, or return [ok, value, error], this 14-line class is the answer I always come back to.
The class
Here it is, lifted from Forem at the SHA pinned to this article:
# forem/forem · app/services/reaction_handler.rb · @9eb974c
class Result
attr_accessor :action, :category, :reaction
delegate :errors_as_sentence, to: :reaction
def initialize(reaction: nil, action: nil, category: nil)
@reaction = reaction
@category = category
@action = action
end
def success?
reaction.errors.none?
end
end
Fourteen lines. It carries three pieces of state, exposes success?, and forwards an error-formatting method to the underlying record. That’s the whole API.
Why this beats the three usual alternatives
Alternative 1: raise on failure
# illustrative
def create_reaction
reaction = Reaction.new(...)
reaction.save!
reaction
end
The caller has to wrap every call in begin/rescue. Worse, validation failures (a domain event) and infrastructure errors (a database hiccup) both come out as exceptions, so the rescue has to discriminate. You end up with rescue ActiveRecord::RecordInvalid => e blocks scattered across controllers and jobs, each doing slightly different things with e.record.errors.
The Forem Result pushes failure into the return value, where it belongs for domain outcomes. Exceptions stay reserved for actual exceptions.
Alternative 2: return a hash
# illustrative
{ success: true, reaction: reaction, action: "create" }
This is what most service objects start as. It works until someone typos :succes or you want to add an attribute and have to grep every caller to see if anyone’s expecting the old shape. Hashes don’t tell you what keys exist; objects do.
Alternative 3: return a tuple
# illustrative
[reaction, errors]
Functional-language muscle memory. The problem in Ruby: positional return values don’t compose well with if statements at the call site, and adding a third return slot (say, action) breaks every caller silently.
What Result does instead
It’s a struct in spirit but a class in shape, which means:
success?is a method, not a flag. Callers ask the question they actually want answered. The implementation can change later (right now it’s “no errors on the reaction,” but it could grow to check rate limits or audit state) without touching call sites.delegate :errors_as_sentence, to: :reactionis the move that makes this scale. TheResultdoesn’t need to invent its own error API; it forwards to the record’s. Anyone who knows Active Model knowserrors_as_sentence. No new vocabulary.- All three attrs are positional-keyword-optional. A
noop_result(when the user already reacted) constructs aResultwith onlycategoryandactionset. A failure constructs one with the failedreactionso the caller can read.errors. One shape, multiple uses.
How the handler uses it
The handler exposes one private builder:
# forem/forem · app/services/reaction_handler.rb · @9eb974c
def result(reaction, action)
Result.new category: category, reaction: reaction, action: action
end
And three call sites that produce different shapes from the same constructor:
# forem/forem · app/services/reaction_handler.rb · @9eb974c
def noop_result
Result.new category: category, action: "none", reaction: existing_reaction
end
# forem/forem · app/services/reaction_handler.rb · @9eb974c
def handle_existing_reaction
locked_reaction = destroy_reaction(existing_reaction)
log_audit(locked_reaction) if locked_reaction
result(existing_reaction, "destroy")
end
The action attribute ("create", "destroy", "none") is the part that makes this a handler result rather than a create result. The caller — typically a controller — branches on result.action to decide which Turbo stream to render or which counter to increment. That information would be lossy if the method just returned a reaction.
When I’d reach for this pattern
Three signals:
-
The operation has multiple success modes. Create vs upsert vs noop. Sign in vs sign up via OAuth. Charge vs already-charged. Anywhere the caller needs to know not only “did it work” but “which path did it take,” a result object beats a boolean.
-
You want to keep validation errors in the domain layer. Active Record’s
errorscollection is good. Letting your service forward to it viadelegatemeans controllers don’t have to know whether they got back a record or a result wrapper. -
You expect the result shape to grow. A
Resultclass is cheap to extend — add an attribute, pass it through the constructor, done. A hash is cheap to extend until the day a caller relied on the shape and you broke them.
When I wouldn’t
If your service has exactly one success path and one failure path, and the failure is always “the record was invalid,” return the record. record.persisted? and record.errors are already the API you’d be building. Don’t wrap one method in a class.
The Forem case earns the wrapper because create, toggle, noop, and destroy are all valid outcomes of ReactionHandler.toggle. A bare reaction can’t carry that.
What I borrowed for my own apps
Two things, specifically:
- The
delegate :errors_as_sentence, to: :reactionline. Whatever the dominant record in your service is, delegating its error API to the result wrapper saves controllers from learning a second one. - Keyword args with all-optional defaults. Lets one constructor cover noop, success, and failure shapes without a builder method per case.
I dropped the attr_accessor (I prefer attr_reader and a constructor — mutating a result after the fact is a smell) and I usually add a failure? method that’s literally !success? because controller unless chains read worse than if result.failure?.
The whole point
A 14-line class is the right size for a result wrapper. If yours is bigger, you’re probably doing work in it that belongs back in the service. If yours doesn’t exist yet, the cost to add one is one file and zero dependencies.
Forem’s is a good model because it’s small, it forwards to the record’s existing API instead of reinventing one, and it carries the “which branch did we take” information that bare records can’t. Steal it.
Forem source code is licensed under AGPL-3.0. Excerpts above are short and used here for commentary; if you build on them, comply with the AGPL terms.