Back to articles

Every Rails App Has an Architecture. Mine Just Didn't Know It Yet.

May 31, 2026

ArchitectureRailsSWE

I had a 874-line controller and no architecture document. Reading Fundamentals of Software Architecture forced the first explicit trade-off decisions this codebase had ever had.

Every Rails App Has an Architecture. Mine Just Didn't Know It Yet.

Architecture in Practice, Book 1: Fundamentals of Software Architecture (Richards & Ford, O'Reilly 2020) Study artifacts: swbook-labs

I read this book with an 874-line controller open on the other monitor. It was a little embarrassing. Also useful.

The Problem

In Synergym, three files were carrying way too much:

  • DashboardsController at 874 lines. Weekly completion, streaks, achievements, workout scheduling. All mixed inside HTTP actions.
  • TranslationService at 1129 lines. External API calls, caching, locale lookup, and fallback rules in one place.
  • User at 699 lines. OAuth callbacks, client connections, onboarding, unit preferences, roles, plus fifteen has_many associations.

None of this happened because of one bad decision. It happened because each local change looked reasonable at the time. The bill arrived later.

The visible issue was file size. The real issue was that I could not name the structural problem in a way that produced a next step. I had discomfort, not a decision.

The Decision

The key line from Richards and Ford is simple: every system has an architecture. You either choose it, or you inherit it.

Mine was inherited, so I did not start with a rewrite. I started by naming what already existed and the trade-offs I was already paying.

ADR-003, keep layered monolith. Synergym already was a layered Rails monolith. For the current phase, that is still the right shape: small team, fast iteration, real domain complexity, no distribution pressure. Splitting into services would not have solved a God Controller. It would have moved the same mess across network boundaries. I documented four triggers that would force a new decision: Scale, Team Autonomy, Reliability Isolation, Deployment Independence. None are active.

ADR-004, choose three characteristics and defer five. This was the most practical part of the book. You cannot optimize everything at once, so I picked agility, simplicity, and testability for this phase. For the rest, I wrote explicit trigger conditions. Not "later." Actual conditions.

Before this, those assumptions were implicit.

The Implementation

With the monolith decision explicit, refactoring got easier. I was not debating architecture in every PR anymore. I was reducing coupling inside the chosen architecture.

First, I mapped it visually so the boundaries were obvious.

Container view

Component view A, dashboard flow

Component view B, translation flow

Component view C, user model flow

The file-size reduction was real:

  • DashboardsController: 874 -> 382 lines
  • TranslationService: 1129 -> 383 lines
  • User: 699 -> 384 lines

In simplified form, one controller path went from "do everything here" to "delegate to explicit collaborators":

# before def show @streak = ... @completion = ... @next_workout = ... end # after def show @streak = StreakCalculator.new(current_user).call @completion = WorkoutCompletionTracker.new(current_user).call @next_workout = WorkoutSchedulingHelper.new(current_user).next_workout end

The extracted classes were not inventions. They were already present as hidden responsibilities inside bigger files. Extraction just made the boundaries visible.

I also mirrored the same approach in swbook-labs with a TypeScript spike, mostly to prove the pattern was not Rails-specific.

ADR-007, fitness functions in CI. The book frames fitness functions as tests for structure, not behavior. They answer "is the shape drifting again?"

Now every PR touching app/**/*.rb runs three architecture checks:

An 874-line controller cannot quietly reappear anymore.

I also wrote ADR-005 as a coupling debt register. At the time, eight files were still above 400 lines. They are listed by name, with current size and a trigger for when each one becomes worth attacking.

The Trade-off

This work costs time now. Five ADRs and multi-phase extraction do not ship a feature on their own.

The return is delayed: cheaper future changes.

The CI gate is also limited. It is delta-aware by design: it blocks new violations or worsening ones, but not old debt that already existed. As a solo builder, I cannot stop everything and clean eight files in one shot, so existing debt is tracked in ADR-005 instead of hard-failing every PR.

That is the trade-off: slower this week, less friction every week after.

The Operating Rule

Every architectural assumption needs a trigger condition. No "someday we move to microservices." Only "we move when X condition is true," where X is observable.

"Someday" is not a plan. It is a way to avoid deciding.

Why It Matters

There is a big gap between:

  • "this file feels too big"
  • "this violates a trade-off we documented for this phase"

That gap is the difference between mood and decision.

The book did not fix my codebase. It gave me language and criteria to evaluate the codebase I already had. That was enough to unlock real changes.

One limit is still true: the book is stronger on designing architecture than on repairing architecture in a live solo product. Most systems are not greenfield. You inherit your own past choices too.

So the useful question is:

"What architecture do I have right now, and what concrete condition would make me change it?"

© 2026 Giorgio Ozzola. All Rights Reserved.

RSS Feed

Inspired by Takuya Matsuyama

Version: 1.5.0