Aaron Norling

Patterns in Reach

A case for reflexive, lightweight abstractions in Ruby and Rails development

I've recently participated in a few conversations exploring how to strike a balance between simplicity and structure in software development. Structure, in the context of these conversations, has become a sort of dirty word with structure or "patterns" being held synonymous with "complexity." A cohort of developers has been preaching an attractive, minimalistic gospel: "You Aren't Gonna Need It!", "Keep it simple, stupid!", and so on. These slogans have their place — they're reminders to avoid premature optimization and needless complexity.

I believe the fear of complexity is causing folks to overlook one of our best tools in Ruby and RoR development: the humble "plain old Ruby object" (PORO). Over the years I've embraced a few simple patterns (aka structures, aka abstractions) as my trusted companions - Service, Presenter, and Form Objects. They're always within reach. And when used thoughtfully, they don't add complexity. In actual practice they foster simplicity and create clarity.

This essay is about those patterns: the ones I reach for reflexively. Answering those individuals that might say they're "too much," I've found the opposite to be true. These patterns help me write code that is:

  • Easy to test
  • Easy to understand
  • Easy to change

Most significantly, I've found code that is preemptively... patterned? requires less ongoing maintenance and overall "ages" better.

The rest of this essay will show - through two concrete examples — what I mean by reflexive abstraction using "patterns in reach." One example comes from a Rails application and the other from a CLI-based Ruby gem.

Example 1: Managing Topics for an Essay (Rails + Service Object)

This example is drawn from a Rails application I wrote called Authormark. Authormark is a "longform essay" publishing tool. Fun fact - you're likely reading this on my Authormark-hosted site.

Context dump:

In Authormark, Essays can be associated with zero-to-many Topics. This is so that a Topic page can include all the Essays related to that Topic. For every Essay with a Topic, there should also always be exactly one Primary Topic. The Primary Topic is automatically set to the first Topic added, and reassigned automatically if the current Primary Topic is removed. The User can reassign any Topic as the Primary Topic. In the case where the last Topic is removed from an Essay, the Primary Topic is also removed.

One last note about the model structure for Authormark. The Essay and Topic models are joined using a has_many :through relationship via an EssayTopic model.

This is not terribly complex business logic, but it's got edge cases and a few coordination rules from the "Primary Topic" feature that aren't perfectly straightforward. With all that established, we have enough context to pose the question: Where should the business logic go? On the Essay model? The Topic model? In the controller?

My answer is: None of the above. Instead of overloading the model or bloating the controller, I'm glad to have reflexively reached for a PORO Service Object. In app/services/essay_topic_manager.rb I created a new class that exposed the following interface:

EssayTopicManager.new(essay).add(topic)
EssayTopicManager.new(essay).remove(topic)
EssayTopicManager.new(essay).promote(topic)

This service object allowed the controller to stay appropriately skinny. The service encapsulates the logic. The test suite is clear and focused without any concern for things that aren't its concern:

it "reassigns the primary topic when the current one is removed"
it "adds and promotes a topic in one call"
it "preserves the current primary topic if it's still valid"

Instead of burying business rules in callbacks, or scattering them across model and controller, I gave the logic a name and a home. The result? Two-line controller actions. Clean coordination, simple tests, no guesswork.

Highlights from the Authormark application expanding on the Service Object pattern are included below. Or click here if you'd like to skip to the next example.

Highlights from the Authormark Example

Topic and Primary Topic association to the Essay are toggled (in the UI) by a Stimulus controller. Starting with the routes:

routes.rb

  resources :essays do
    post "/topics/:topic_id", to: "essay_topics#create", as: :add_topic
    delete "/topics/:topic_id", to: "essay_topics#destroy", as: :remove_topic
    patch "/primary_topic/:topic_id", to: "essay_topics#promote", as: :promote_topic
  end

we can then see how the Stimulus controller forms the request to the backend:

app/javascript/controllers/topic_panel_controller.js

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  async add(event) {
    const topicId = event.currentTarget.closest("li").dataset.topicId
    await this.fetchAndReplace("POST", topicId)
  }

  async remove(event) {
    const topicId = event.currentTarget.closest("li").dataset.topicId
    await this.fetchAndReplace("DELETE", topicId)
  }

  async promote(event) {
    const topicId = event.currentTarget.closest("li").dataset.topicId
    await this.fetchAndReplace("PATCH", topicId, { promote: true })
  }

  async fetchAndReplace(method, topicId, options = {}) {
    const token = document.querySelector('meta[name="csrf-token"]').content
    const essayId = this.element.dataset.essayId

    let url = options.promote
      ? `/authoring/essays/${essayId}/primary_topic/${topicId}`
      : `/authoring/essays/${essayId}/topics/${topicId}`

    const response = await fetch(url, {
      method: method,
      headers: {
        "X-CSRF-Token": token,
        "Accept": "text/vnd.turbo-stream.html"
      }
    })

    if (response.ok) {
      const html = await response.text()
      document.getElementById("topic-panel").outerHTML = html
    } else {
      console.error("Request failed", response)
    }
  }
}

This controller is used in the Views to connect the UI controls to the following Rails controller:

app/controllers/authoring/essay_topics_controller.rb

# frozen_string_literal: true

module Authoring
  class EssayTopicsController < BaseController
    before_action :set_essay
    before_action :set_topics

    def create
      EssayTopicManager.new(@essay).add(@topic)
      render_panel
    end

    def destroy
      EssayTopicManager.new(@essay).remove(@topic)
      render_panel
    end

    def promote
      EssayTopicManager.new(@essay).promote(@topic)
      render_panel
    end

    private

    def set_essay
      @essay = Essay.find(params[:essay_id])
    end

    def set_topics
      @topics = Topic.all
      @topic = Topic.find(params[:topic_id])
    end

    def render_panel
      @all_topics = Topic.order(:title)
      render partial: "authoring/essays/topics", locals: { essay: @essay, all_topics: @all_topics }, formats: [ :html ]
    end
  end
end

Which finally connects to the EssayTopicManager Service:

app/services/essay_topic_manager.rb

# frozen_string_literal: true

class EssayTopicManager
  def initialize(essay)
    @essay = essay
  end

  def add(topic)
    return if @essay.topics.include?(topic)

    @essay.topics << topic
    promote_if_first_topic(topic)
  end

  def remove(topic)
    @essay.topics.delete(topic)
    drop_or_reassign_primary(topic)
  end

  def promote(topic)
    add(topic) # ensures topic is added before promoting
    @essay.update!(primary_topic: topic)
  end

  private

  def promote_if_first_topic(topic)
    if @essay.topics.size == 1
      @essay.update!(primary_topic: topic)
    end
  end

  def drop_or_reassign_primary(topic)
    return unless @essay.primary_topic == topic

    new_primary = @essay.topics.first
    @essay.update!(primary_topic: new_primary)
  end
end

I have confidence in the Service because it is easily testable:

spec/services/essay_topic_manager_spec.rb

# frozen_string_literal: true

require "rails_helper"

RSpec.describe EssayTopicManager do
  let(:essay) { create(:essay) }
  let(:topic1) { create(:topic) }
  let(:topic2) { create(:topic) }

  subject { described_class.new(essay) }

  describe "#add" do
    it "adds the topic" do
      subject.add(topic1)
      expect(essay.topics).to include(topic1)
    end

    it "sets the topic as primary if it's the first" do
      subject.add(topic1)
      expect(essay.primary_topic).to eq(topic1)
    end

    it "does not override existing primary" do
      essay.topics << topic2
      essay.update!(primary_topic: topic2)

      subject.add(topic1)
      expect(essay.primary_topic).to eq(topic2)
    end
  end

  describe "#remove" do
    before do
      essay.topics << topic1
      essay.topics << topic2
      essay.update!(primary_topic: topic1)
    end

    it "removes the topic" do
      subject.remove(topic1)
      expect(essay.topics).not_to include(topic1)
    end

    it "reassigns primary if primary is removed" do
      subject.remove(topic1)
      expect(essay.primary_topic).to eq(topic2)
    end

    it "clears primary if it was the only topic" do
      essay.topics = [ topic1 ]
      essay.update!(primary_topic: topic1)

      subject.remove(topic1)
      expect(essay.primary_topic).to be_nil
    end
  end

  describe "#promote" do
    it "adds the topic if not already added" do
      subject.promote(topic1)
      expect(essay.topics).to include(topic1)
    end

    it "sets the topic as primary" do
      subject.promote(topic1)
      expect(essay.primary_topic).to eq(topic1)
    end
  end
end

Imagine trying to fit the contents of EssayTopicsController into an existing controller - the hassles that would create with bloat, testing, REST-fulness and all the rest...

Example 2: Formatting CLI Output (Gem + Presenter Pattern)

In a small Ruby gem I wrote, users input numbers to be validated and classified. What and how (in terms of number classification and validation) are irrelevant to this essay. The gem supports:

  • Verbose or simple output (console text or JSON-formatted)
  • Single-value or file-based input

It's tempting to handle formatting directly in cli.rb, where the flags and input parsing already live. How big does that file become if one does that? How tangled do the tests get? How are different output cases tested without mocking half the CLI?

Here again, I reached for a Presenter Pattern — a lightweight PORO that formats the output:

presenter = SimplePresenter.new(result, input)
puts presenter.present

Or, in verbose mode:

presenter = VerbosePresenter.new(result, input)
puts JSON.pretty_generate([presenter.present])

Each presenter has exactly one job: format a single result. And because they're plain objects, the tests are isolated, fast, and expressive:

it "outputs a green line for a valid number"
it "outputs red text and 'ILL' for illegal numbers"
it "outputs JSON with all relevant metadata in verbose mode"

For this gem, Presenters gave me clarity in the same way service objects did: separation, intention, testability. Allowing other objects to be concerned with data retrieval or presentation created a controller-like interface in cli.rb, with the Presenter taking the place of the View... a fun and familiar idiom.

Highlights from the Ruby Gem example

Following the well-established pattern of creating a cli.rb the following code handled the mode switches between individual number and file inputs as well as output verbosity:

lib/my_gem/cli.rb

def assess_single_number(number_input, verbose)
# asses the input and return a single result determination, not relevant to the Presenter discussion 
  result = MyGem::Interactors::AssessNumberOrganizer.call(number: number_input) 
  if verbose
    presenter = MyGem::Presenters::VerbosePresenter.new(result, number_input) 
    puts JSON.pretty_generate([presenter.present])
  else
    puts MyGem::Presenters::SimplePresenter.new(result, number_input).present 
  end
end


def assess_file(file_input, verbose)
  ...
  File.open(file_input, "r") do |file|
    file.each_slice(4) do |lines|
      ...
      result = MyGem::Interactors::AssessNumberOrganizer.call(number: number_str)
      presenter = if verbose
                    MyGem::Presenters::VerbosePresenter.new(result, number_str)
                  else
                    MyGem::Presenters::SimplePresenter.new(result, number_str)
                  end
      results << presenter.present
    end
  end

  if verbose
    puts JSON.pretty_generate(results)
  else
    results.each { |line| puts line }
  end
end

In either case - single number as def assess_single_number() or a list of numbers from a file as def assess_file(), the result of the Interactor (a turbo-charged Service) is passed to the Presenters and either shown immediately or collected and shown later. The Presenter handles a single input and output as follows:

lib/my_gem/presenters/simple_presenter.rb

# frozen_string_literal: true

module MyGem
  module Presenters
    # SimplePresenter is responsible for formatting the output of the assessment results
    # as simple text with color coding based on the success or failure of the assessment.
    class SimplePresenter
      def initialize(result, number)
        @result = result
        @number = number.to_s
      end

      def present
        if @result.success?
          colorize(@number, :green)
        elsif @result.message.include?("Invalid checksum")
          # Determine error type based on the result error message.
          colorize("#{@number} ERR", :yellow)
        else
          colorize("#{@number} ILL", :red)
        end
      end

      private

      def colorize(text, color)
        case color
        when :green
          "\e[32m#{text}\e[0m"
        when :red
          "\e[31m#{text}\e[0m"
        when :yellow
          "\e[33m#{text}\e[0m"
        else
          text
        end
      end
    end
  end
end

The VerbosePresenter is much the same, ignoring colored ANSI output and formatting the output as JSON. The testing for a Presenter is - again - very straightforward:

test/my_gem/presenters/simple_presenter_test.rb

# frozen_string_literal: true

require "test_helper"
require "ingenious/presenters/simple_presenter"
require "ostruct" # For creating simple dummy Interactor objects, pretty safe since it's only used in tests

class SimplePresenterTest < Minitest::Test
  def test_valid_number_presented_in_green
    # Create a dummy successful result using OpenStruct
    result = OpenStruct.new(success?: true, message: nil) # rubocop:disable Style/OpenStructUse
    presenter = MyGem::Presenters::SimplePresenter.new(result, "123456789")
    output = presenter.present

    assert_includes output, "123456789"
    assert_includes output, "\e[32m", "Output should be green"
  end

  def test_illegal_number_presented_in_red
    # Create a dummy failure result for validation error.
    result = OpenStruct.new(success?: false, message: "Invalid number: must be 9 digits") # rubocop:disable Style/OpenStructUse
    presenter = MyGem::Presenters::SimplePresenter.new(result, "123")
    output = presenter.present

    assert_includes output, "123 ILL", "Output should append 'ILL' for illegal number"
    # Check for red ANSI color code (31)
    assert_includes output, "\e[31m", "Output should be red"
  end

  def test_checksum_error_presented_in_yellow
    # Create a dummy failure result for checksum error.
    result = OpenStruct.new(success?: false, message: "Invalid checksum: sum of digits is not divisible by 11") # rubocop:disable Style/OpenStructUse
    presenter = MyGem::Presenters::SimplePresenter.new(result, "123123123")
    output = presenter.present

    assert_includes output, "123123123 ERR", "Output should append 'ERR' for checksum error"
    # Check for yellow ANSI color code (33)
    assert_includes output, "\e[33m", "Output should be yellow"
  end
end

And voila! Code that is simpler, more robust, and easier to test and reason over than if it had all been shoved into cli.rb.

Real Simplicity Is Found In Structure

There's a version of the "simplicity above all" mindset that says: don't reach for abstractions unless the code demands it. I think this viewpoint is myopic.

Overly simplistic code can paradoxically and very quickly create complexity. Pondering the examples above, we can easily see how PORO objects make for simpler approaches to code authoring and testing. The Rails community has embraced the "convention over configuration" philosophy at the framework level. Consider that a handful of light, well-worn and familiar patterns — always within reach — can extend that philosophy into the application space and prevent the code from becoming needlessly complex in the first place.

These patterns:

  • Carve off responsibility early
  • Allow business logic to describe itself
  • Encourage focused tests with minimal setup
  • And can even make later refactors unnecessary, because the structure was right from the beginning

This isn’t abstraction for abstraction's sake. These aren't speculative "just in case" patterns. They're simple structures introduced at the moment they earn their keep — when coordination, formatting, or domain behavior becomes non-trivial.

Closing Thought

My rule is simple: When in doubt, reach for a PORO.

Here's the same sentiment with a little more nuance: If the code is only concerned with coordinating a single model or domain object - KISS 100%. But the moment logic crosses boundaries, grows conditionals, or accumulates edge cases? That’s when a PORO earns its keep.