← All posts

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.

rails service-objects patterns oss forem

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: :reaction is the move that makes this scale. The Result doesn’t need to invent its own error API; it forwards to the record’s. Anyone who knows Active Model knows errors_as_sentence. No new vocabulary.
  • All three attrs are positional-keyword-optional. A noop_result (when the user already reacted) constructs a Result with only category and action set. A failure constructs one with the failed reaction so 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:

  1. 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.

  2. You want to keep validation errors in the domain layer. Active Record’s errors collection is good. Letting your service forward to it via delegate means controllers don’t have to know whether they got back a record or a result wrapper.

  3. You expect the result shape to grow. A Result class 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: :reaction line. 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.