Refactoring with Convergence Rules
When code “smells bad,” programmers tend to fall into one of two extremes. The first is refactoring in big leaps guided by intuition: moving half a class around, renaming three things at once, and discovering twenty minutes later that the tests are failing and you can’t remember which of your twelve changes broke what. The second is the opposite: paralysis. You know the code needs to improve, but you don’t know where to start.
The alternative is a procedure so mechanical it almost qualifies as an algorithm. It requires no design vision and no prior experience with the problem. What it requires is the discipline to take very small steps, and the patience to let the abstraction emerge on its own.
Here’s the starting point, the simple code we used in the previous article:
class TimeAgo def phrase(seconds) case seconds when 0 then "just now" when 1 then "1 second ago" else "#{seconds} seconds ago" end endendWait for Change Before You Design
Writing, reading, and maintaining code all cost time. That’s why refactoring “because the code could look nicer” carries a real opportunity cost: every hour spent polishing aesthetics is an hour not spent on a problem that actually exists. The most reliable way to know you’re spending that time well is to let a new requirement trigger the refactoring. A requirement tells you two things: exactly where the code needs to change, and that from now on, that code needs to be easy to change. Code that never changes doesn’t need to be changeable; code that just received a change request does.
And the requirement has just arrived: durations should no longer be expressed only in seconds. 60 should read "1 minute ago", 90 should read "1 minute and 30 seconds ago", and 3600 should read "1 hour ago".
Before touching anything, a warning: the requirement says minutes and hours. It doesn’t say days, or weeks, or “round to the nearest unit.” The temptation to infer requirements nobody asked for is strong (you know the domain, and you can see the future coming), but inventing requirements is the gateway to the speculative generality we rejected in the previous article. If you’re unsure, ask the client; then write the minimum code necessary.
Let’s see what happens if we attack the requirement head-on, by adding branches to the case:
def phrase(seconds) case seconds when 0 then "just now" when 1 then "1 second ago" when 60 then "1 minute ago" when 61..119 then "1 minute and #{seconds - 60} second#{'s' if seconds > 61} ago" when 3600 then "1 hour ago" else "#{seconds} seconds ago" endendThree branches have become six, and one of them contains a cryptic interpolation. Every new time scale (hours with minutes, days…) will add more branches, each duplicating the same phrase pattern with small variations. Conditionals breed.
Open/Closed as a Compass
The question that decides whether to refactor isn’t “do I like this code?” It’s “is this code open to the new requirement?” “Open” comes from the Open/Closed principle (the O in SOLID): code should be open for extension and closed for modification. Code is open to a requirement when you can satisfy it by adding code instead of editing what’s already there.
Our case isn’t open: every new phrase variant requires editing the conditional. This gives rise to a two-phase discipline that shouldn’t be mixed: first refactor the existing code until it’s open to the requirement, and only then add the new code. Avoid refactoring and adding functionality at the same time; if something breaks, you won’t know exactly what caused it.
The decision sequence goes like this: is it open? If yes, add the code. Do you know how to open it? If yes, open it and add the code. If you don’t know either, remove the easiest code smell you can find, and ask yourself the same question again. Code smells (cataloged by Martin Fowler and Kent Beck in Refactoring, along with the curative refactoring for each) are the entry point when you can’t see the whole path: you don’t need to know how to solve the entire problem to know where to start.
Our original case harbors two smells from the catalog: Switch Statement (the very conditional that keeps growing by variant) and Duplicated Code (the near-identical phrase templates in its branches). Of the two, duplication is the more straightforward, so that’s where we’ll start. But keep in mind that removing duplication doesn’t guarantee opening the code. The technique is to remove smells one at a time, and the path toward openness reveals itself as you go.
The Convergence Rules
The duplication in our branches is hiding something: if every phrase variant is, at bottom, “the same phrase,” then an abstract template capable of producing all of them must exist. The problem is that this abstraction isn’t visible. To find it without needing to see it, we’ll apply three rules in a loop. I’ll call them the convergence rules, because their sole purpose is to make the duplicated variants converge until only one remains.
- Select the pieces that most resemble each other.
- Locate the smallest difference between them.
- Make the simplest change that removes that difference.
Repeat until the pieces are identical, then remove the duplicate.
Why does something this simple work? For two reasons. The first is where it puts the focus: on the difference. The eye is drawn to sameness first, because sameness is easy to spot, but meaning lives in what varies. If two fragments represent the same abstraction and still contain a difference, that difference is a smaller abstraction living inside the larger one. Naming the difference is how you identify the concept.
The second reason is the size of the step. Each change is small enough (ideally one line) to be instantly verifiable and reversible. The discipline that goes with the rules is simple: change one line, run the tests; if they turn red, undo that single change and try a better one. You never debug a mess, because you never let more than one unverified change pile up.
From Difference to Abstraction
Let’s apply the rules to the original case (the three-branch one). Rule 1 asks for the most similar branches. The 0 branch ("just now") doesn’t resemble any other. The 1 branch and the else branch, on the other hand, are quite similar:
when 1 then "1 second ago"else "#{seconds} seconds ago"Rule 2 asks for the smallest difference. Reading left to right, the first one is 1 versus #{seconds}. But it’s a difference only in appearance: inside the when 1 branch, the seconds variable is always exactly 1, so both expressions produce the same result. Rule 3 asks for the simplest change that removes it:
when 1 then "#{seconds} second ago"else "#{seconds} seconds ago"Tests green. The change looks trivial (the resulting value is identical), but the idea underneath it is huge: replacing a concrete value with the reference that generalizes it raises the level of abstraction, and it’s that rise that turns difference into sameness. Without it, you’re stuck with conditionals.
Now the interesting difference remains: "second" versus "seconds". And here the rules demand something more than mechanics: you have to name the concept.
Naming What Varies
Remember where we started: every phrase TimeAgo produces is, seen from above, the same thing (a relative-time phrase), which is why an abstract template capable of generating all of them must exist. If "second" and "seconds" are two examples of that shared template and they differ, that difference is a smaller abstraction hiding inside the larger one. It’s the visible tip of a concept that doesn’t have a name yet, and "second" and "seconds" are two of the values that concept can take. The next step is to figure out what concept they’re examples of.
A useful trick for finding that concept is to imagine the examples as rows in a table and ask yourself what the column header would be:
| seconds | ? |
|---|---|
| 1 | second |
| 47 | seconds |
| 60 | minute |
The first two rows come from the current code, while the third comes from a requirement (minutes) we haven’t implemented yet. The first candidate anyone would reach for as a header is pluralize, but it’s the wrong name, since it wouldn’t account for the third row (“minute” isn’t the plural of “second”). So the column doesn’t hold plurals; it holds units. The right header is unit.
Notice the role the requirement just played. We’re not implementing it yet, but it handed us a third concrete example of the category, and that example ruled out a bad name before we ever wrote it. Naming with more examples in view is always cheaper than renaming later.
Extracting Without Leaving Green
Time to remove the difference by sending a unit message where two different strings currently sit. The temptation is to write the whole method and switch both branches at once. But in a real project, with imperfect tests and more than one call site, that can create a problem that’s hard to debug. We’re going to do it in steps small enough to feel ridiculous, because the heart of the technique is that every single step leaves the tests green.
First, the empty method:
def unitendDo the tests pass now, even though nobody’s calling the method? Yes: green confirms the new code is syntactically valid and breaks nothing. It’s not much information, but it matters for the process.
Second step, return the value from the else branch:
def unit "seconds"endGreen. Third, use the method in that branch:
else "#{seconds} #{unit} ago"Green. Now unit needs to know how many seconds there are to choose between singular and plural, so it needs to take an argument. But changing the definition and the call at the same time is two changes, and we can only make one at a time. The trick is an optional argument with a temporary default value:
def unit(seconds = :todo) "seconds"endGreen. The default value is a deliberately absurd symbol: :todo. It can’t be mistaken for a legitimate value, and it reminds you it needs to disappear by the end of the refactoring.
Now we add the conditional:
def unit(seconds = :todo) if seconds == 1 "second" else "seconds" endendGreen. The singular branch still isn’t running (nobody’s passing a 1 yet), but it’s already written and syntactically validated. This is a small example of Open/Closed: we’ve opened unit to a new case without touching the path that already worked.
Next steps, always one at a time and always with tests green: the else branch adds the argument to the call, unit(seconds) (which still falls into the plural, since the case’s else never receives a 1), and then the when 1 branch replaces its "second" with the same message send:
when 1 then "#{seconds} #{unit(seconds)} ago"else "#{seconds} #{unit(seconds)} ago"The two branches are now identical. One of them is redundant: we remove the when 1 branch, and since nobody calls unit without an argument anymore, the :todo default also goes away. Here’s the full result:
class TimeAgo def phrase(seconds) case seconds when 0 then "just now" else "#{seconds} #{unit(seconds)} ago" end end
def unit(seconds) if seconds == 1 "second" else "seconds" end endendEach of those intermediate steps was a stable landing point: at any of them, you could stop, ship to production, or undo a single move if something went wrong.
What the Metrics Say
In the previous article, we used SLOC, cyclomatic complexity, and ABC to compare solutions, so let’s measure this one too. The verdict is that we’ve gotten worse. We went from 9 lines to 15, from one method to two, and from one conditional to two (the case is still there, and unit has a new if). Flog would penalize the new version too, since message sends add up.
And yet this code is better, because the unit concept now exists: it has a name, it lives in a single place, and it can change without touching anything else. That improvement can’t be computed by any static analysis tool, so it’s worth remembering that metrics compare costs, but they don’t see concepts. They’re best used to choose between alternatives, not to judge whether a refactoring paid off.
When the Abstraction Is False
The general branch shows a quantity of seconds, and the 0 branch doesn’t. But if both are relative-time phrases, maybe they share a concept we haven’t named yet: the quantity being shown. When there are 47 seconds, the quantity is 47; when there are 0, the quantity is “none at all.” Let’s extract that concept, quantity, using the exact same mechanics we used for unit, and see what happens.
Let’s recall the two branches we’re about to try to unify:
when 0 then "just now"else "#{seconds} #{unit(seconds)} ago"With unit, we had two nearly identical templates and a single word that differed. Here, by contrast, the two branches are quite different from each other. The only thing we can claim they share is that concept of quantity being shown, so that’s where we’ll try to make them converge. We start as always: empty method, tests green.
def quantity(seconds)endWe return the value from the else branch, which is what’s shown in the general case: the number itself.
def quantity(seconds) secondsendAnd we use it in that branch:
else "#{quantity(seconds)} #{unit(seconds)} ago"Green. Now quantity needs to learn the other case: when there are 0 seconds, what’s shown isn’t a number, it’s… something. We add the conditional, just like we did before:
def quantity(seconds) if seconds == 0 # what goes here? else seconds endendIn the when 0 branch of the case, the whole phrase is shown: "just now". quantity has no value to return for 0. We could force it to return nil and let phrase decide:
def phrase(seconds) amount = quantity(seconds) amount ? "#{amount} #{unit(seconds)} ago" : "just now"end
def quantity(seconds) if seconds == 0 nil else seconds endendThe tests pass. And yet this code is a trap. Look at what happened to phrase: to use the value quantity returns, it first has to interrogate it with that amount ?. The conditional we were trying to eliminate just moved somewhere else, now checking for nil, and in the process it coupled phrase to quantity’s internal protocol.
This is a violation of the Liskov Substitution Principle (the L in SOLID) in its generalized reading. The classic formulation talks about subtypes being substitutable for their supertypes, but the underlying idea is broader: objects should be what they claim to be. Applied to return values: everything a method returns should honor a single contract, so the caller can use it without inspecting it first. A method that sometimes returns “a value you can print” and other times “a signal that there’s no value” is signing two different contracts, and it shifts the burden of telling them apart onto the caller. Every piece of knowledge is a dependency, and this design forces phrase to know too much.
There’s no single contract that fixes quantity. It would have to return something printable that, once dropped into the "#{...} #{unit} ago" template, produced “just now”, and that’s obviously impossible: “just now” doesn’t have the quantity-unit-suffix anatomy. Liskov just caught a false abstraction. When you can’t define a contract that covers every case, it’s not that you’re missing an abstraction: you have two concepts disguised as one. The 0 phrase isn’t a variant of the general template; it’s a different phrase entirely.
Let’s rewind and look at what happened. While we were extracting a true abstraction (unit), the micro-step mechanics made it easy for every one-line change to leave the tests green. The moment we tried to extract a false abstraction, the mechanics broke down: the conditional got stuck halfway, with no natural value to return, and the only way to “finish” was to take a big leap. That was the signal that the abstraction was wrong. The convergence rules don’t just build abstractions; they also catch the false ones.
So we undo our way back to the previous state:
class TimeAgo def phrase(seconds) case seconds when 0 then "just now" else "#{seconds} #{unit(seconds)} ago" end end
def unit(seconds) if seconds == 1 "second" else "seconds" end endendThe attempt wasn’t wasted: we named quantity, and now we know the quantity shown won’t always match the seconds received. Once minutes arrive, phrase(60) will show a 1, not a 60. There’s no code that needs it yet, so we won’t write it.
The Emergent Abstraction
Let’s review what it took to get here: compare, locate the smallest difference, remove it with the simplest change, repeat. This shows the difference between designing an abstraction and discovering one: a discovered abstraction is backed by the concrete examples already in the code, while a designed-upfront one rests only on your intuition. That’s why the result of a well-done refactoring is usually better than whatever you would have planned from scratch: it’s unlikely you’d design unit from the start; you’d more likely reach for pluralize, and that would be a mistake.
That leaves the matter of names, the one part of the process where the rules won’t guide you. When the right name doesn’t show up, there are two reasonable options: spend a few bounded minutes on it (a thesaurus can help) and settle for the best candidate, knowing renaming is cheap; or use a placeholder name for now. What matters is describing the concept’s responsibility the way you understand it today, and picking a name that reflects it: the effort you put in now is what lets you recognize the perfect name once it shows up.
Closing the Loop
The process has consolidated one abstraction (unit) and surfaced a second concept, quantity, which we named but which doesn’t live in the code yet: its first implementation turned out to be false, and we undid it.
As for our original goal: the code still isn’t open to the requirement. Adding minutes right now would still mean editing conditionals (unit would have to learn “minute,” phrase would have to decide what quantity to show).
Getting rid of them entirely calls for a different approach: instead of asking the data what it is, give the concepts themselves the shape of objects that know how to answer for themselves. How to make that leap, and why it removes conditionals instead of just moving them around, is the subject of the next article.
Test your knowledge
-
What's the first step of the convergence rules?
-
In the singular branch, why replace the literal
1with the seconds interpolation if the resulting value is identical?
-
When adding an argument gradually, why use an absurd default value like
:todoinstead ofnilor a real number?
-
quantityreturnsnilfor 0 seconds and anIntegerfor everything else. What's the underlying problem?
-
You're in the middle of a refactoring, you haven't changed the behavior, and the tests fail. What do you do?