Back to articles

Your Code Is Coupled. Connascence Tells You Which Parts to Fix First.

Jun 13, 2026

ArchitectureRailsSWE

I knew User.rb was a problem. 730 lines, roles scattered everywhere, unit conversions duplicated across four methods. I could feel the coupling. Connascence gave me the vocabulary to name it and rank it.

Referenced book

Fundamentals of Software Architecture

Your Code Is Coupled. Connascence Tells You Which Parts to Fix First.

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

I knew User.rb was a problem. 730 lines. Fifteen associations. Role logic scattered across controllers, policies, and views. Unit conversion duplicated in four separate methods.

I could feel it every time I opened the file. What I could not do was name what was wrong in a way that told me where to start.

The Problem

"High coupling" is not a useful diagnosis. It tells you something is wrong. It does not tell you which violation to fix first, or why one is more dangerous than another.

In Synergym, two files were the obvious candidates:

  • User.rb at 730 lines. Roles, preferences, unit conversions, invitations, associations to every domain concept in the app.
  • DashboardsController at 874 lines. Athlete metrics, streaks, achievements, workout scheduling. All mixed inside HTTP actions.

Every developer looking at these files would say "this is coupled." That does not answer: which one do I fix first? Why?

Why It Accumulated

Chapter 8 of the same book names the pattern: the entity trap.

When a system is first built, it is natural to create one class per database entity. User, Workout, Exercise. Clean at the start.

Over time, every new feature that touches a user lands in User.rb. Not because of a bad decision. Because it was the path of least resistance and the solo developer could still hold all of it in their head.

Conway's Law: organizations produce systems that mirror their communication structure. A solo developer has no team boundary. Nothing forces separation.

The coupling accumulates silently until the file becomes a liability and the developer can no longer hold it all. This is not a Rails problem. It is what happens when a codebase grows without a forcing function for separation.

The Vocabulary

Connascence is a framework for measuring coupling severity. Two components are connascent when a change in one requires a change in the other. The type tells you how dangerous it is.

The static forms are detectable before you run the code:

  • Connascence of Name: two components reference the same name. A method rename requires updating callers. Normal, unavoidable.
  • Connascence of Meaning: components share an understanding of what a value means. A magic string like "admin" that every caller must spell correctly. This is where it starts to hurt.
  • Connascence of Algorithm: components share an algorithm. The same conversion logic written in multiple places. Changes must stay in sync.

The dynamic forms surface at runtime:

  • Connascence of Execution: the order of operations matters. Components must run in the right sequence or the system breaks silently.

The direction is simple: push violations toward weaker forms. Static is manageable. Dynamic is dangerous.

The full taxonomy, all nine forms ranked by severity, is at connascence.io.

The Before

Here is a stripped version of User.ts from the spike. The violations are annotated inline.

export class User { constructor( public readonly id: number, public readonly email: string, public role: string, // CoM: caller must know "admin" | "trainer" | "client" public preferredUnit: string, // CoM: caller must know "metric" | "imperial" public weightKg: number, public heightCm: number, ) {} isAdmin(): boolean { return this.role === "admin" } // CoM isTrainer(): boolean { return this.role === "trainer" } // CoM canAccessDashboard(): boolean { return this.role === "admin" || this.role === "trainer" // CoM x2 } canManageClients(): boolean { return this.role === "admin" || this.role === "trainer" // CoM x2: identical to above } displayWeight(): string { if (this.preferredUnit === "imperial") { // CoM return `${(this.weightKg * 2.20462).toFixed(1)} lbs` // CoA: algorithm lives here } return `${this.weightKg} kg` } displayHeight(): string { if (this.preferredUnit === "imperial") { // CoM: same string, different method const inches = this.heightCm / 2.54 // CoA: algorithm duplicated return `${Math.floor(inches / 12)}'${Math.round(inches % 12)}"` } return `${this.heightCm} cm` } }

The string "admin" appears five times in this file alone. Every controller, policy, and view that checks a role also knows this string. One typo, one rename, and nothing breaks at compile time. It fails at runtime.

The weight and height conversions share the same if (preferredUnit === "imperial") check, written separately. One gets updated for an edge case. The other does not.

The After

The fix is to extract the meaning into objects that own it. Role owns what "admin" means. UnitSystem owns what "imperial" means and what the algorithm is.

// Role: the string "admin" is now private to this module export class Role { private constructor(private readonly value: "admin" | "trainer" | "client") {} static admin(): Role { return new Role("admin") } static trainer(): Role { return new Role("trainer") } static client(): Role { return new Role("client") } static from(raw: string): Role { if (raw === "admin" || raw === "trainer" || raw === "client") return new Role(raw) throw new Error(`Unknown role: "${raw}"`) } isAdmin(): boolean { return this.value === "admin" } isTrainer(): boolean { return this.value === "trainer" } canAccessDashboard(): boolean { return this.isAdmin() || this.isTrainer() } canManageClients(): boolean { return this.isAdmin() || this.isTrainer() } canEditSettings(): boolean { return this.isAdmin() } }
// UnitSystem: the conversion algorithm lives once export class UnitSystem { private constructor(private readonly value: "metric" | "imperial") {} static metric(): UnitSystem { return new UnitSystem("metric") } static imperial(): UnitSystem { return new UnitSystem("imperial") } isImperial(): boolean { return this.value === "imperial" } displayWeight(kg: number): string { return this.isImperial() ? `${(kg * 2.20462).toFixed(1)} lbs` : `${kg} kg` } displayHeight(cm: number): string { if (this.isImperial()) { const inches = cm / 2.54 return `${Math.floor(inches / 12)}'${Math.round(inches % 12)}"` } return `${cm} cm` } }

User becomes a delegator. It no longer knows what "admin" means or how to convert kilograms.

// User: identity and delegation, nothing else export class User { constructor( public readonly id: number, public readonly email: string, public readonly role: Role, public readonly unitSystem: UnitSystem, public readonly weightKg: number, public readonly heightCm: number, ) {} canAccessDashboard(): boolean { return this.role.canAccessDashboard() } canManageClients(): boolean { return this.role.canManageClients() } displayWeight(): string { return this.unitSystem.displayWeight(this.weightKg) } displayHeight(): string { return this.unitSystem.displayHeight(this.heightCm) } }

Connascence of Meaning becomes Connascence of Name. The magic strings are gone. Callers reference Role.admin(), the weakest form of coupling, the acceptable kind. Rename the internal string to anything, and you change one file.

The full before and after, with 39 tests, is in swbook-labs.

The obvious alternative was a broader split: break User.rb into domain-specific modules or service objects wholesale.

I rejected it for two reasons. First, no prioritization criteria: connascence gave me a severity rank, so I knew which violation to fix first, not just that things were messy.

Second, the CI LOC gate already enforces the boundary incrementally. A big-bang rewrite would have reorganised the same violations without fixing the most dangerous one first.

The Trade-off

DashboardsController is a different problem.

It computes weekly completion, then streaks, then achievements, in sequence, inside a single HTTP action. These calculations depend on each other's intermediate state. The order is not enforced by any type or interface. It is enforced by the position of lines in an 874-line file.

That is Connascence of Execution. Dynamic. It surfaces at runtime, not at compile time. A value object does not fix it. Redesigning the calculation flow does.

The cost was not hypothetical. This week, PR #332 landed: a full trainer dashboard redesign, 3,400 lines of new UI and extracted query logic, all built on top of that same controller.

The calculation order was enforced only by line position while active development was happening around it. That is the concrete price of deferring CoE: live risk during the week, not a future concern.

That decision is documented in ADR-002 as the next structural problem to solve. I did not fix everything. I fixed what the framework said to fix first.

The Rule

Before touching any coupled file: identify the highest connascence form present, fix that one first, then log the remaining violations: their type and a trigger condition for when to revisit each one.

Not "this feels bad." Audit, rank, fix one, document the rest.

Critical reflection

Connascence does not fix the code. It gives you a way to rank what is broken and decide where to start.

DashboardsController is still 874 lines. The dynamic connascence in that file is still there. Naming it did not change that.

What changed is that the next time I open a file and see a string checked in four places, I know what to call it and where it sits on the severity ladder. That is enough to make a decision instead of just feeling uncomfortable.

The Role and UnitSystem extraction took two hours. The connascence analysis told me those were the right two hours to spend.

© 2026 Giorgio Ozzola. All Rights Reserved.

RSS Feed

Inspired by Takuya Matsuyama

Version: 1.5.0