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.