- Wait for Change Before You Design
- The Convergence Rules
- From Difference to Abstraction
- When the Abstraction Is False
- The Emergent Abstraction
- Closing the Loop
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 several 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:
class TimeAgo
def phrase(seconds)
case seconds
when 0 then "just now"
when 1 then "1 second ago"
else "#{seconds} seconds ago"
end
end
end
And here’s the safety net, one test per branch:
class TimeAgoTest < Minitest::Test
def test_phrase_for_many_seconds
assert_equal "47 seconds ago", TimeAgo.new.phrase(47)
end
def test_phrase_for_one_second
assert_equal "1 second ago", TimeAgo.new.phrase(1)
end
def test_phrase_for_zero
assert_equal "just now", TimeAgo.new.phrase(0)
end
end
Every “tests green” from here on refers to these three.
Wait 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" - 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"
end
end
Three 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: 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 loop 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 neither, remove the easiest code smell you can find and start again from the top.
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 contains 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 three-branch case. 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 difference 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 plural, 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.
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, add the empty method:
def unit
end
Do 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"
end
Green. Third, use the new 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"
end
Green. 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"
end
end
Green. The singular branch still isn’t running (nobody’s passing a 1 yet), but it’s already written and syntactically validated. And notice what that buys you: the path that was working still behaves exactly as before, so this step cannot have broken anything. If the tests had turned red, the new branch would be the only possible culprit.
Now the else branch starts passing the argument:
else "#{seconds} #{unit(seconds)} ago"
Green, and still plural: the case’s else never sends a 1, so unit keeps taking its own else, now because it receives 47. The singular branch is still waiting.
Then the when 1 branch replaces its literal with the same message send, and that waiting branch finally runs:
when 1 then "#{seconds} #{unit(seconds)} ago"
else "#{seconds} #{unit(seconds)} ago"
The two branches are now identical. One of them is redundant: 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
end
end
Each 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).
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)
end
We return the value from the else branch, which is what’s shown in the general case: the number itself.
def quantity(seconds)
seconds
end
And 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
end
end
In 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
end
end
The 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
end
end
The 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. We still won’t write it, though, because the code isn’t open yet, and the concept has nowhere to live that isn’t another branch of the same conditional.
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 a name built around plurals, and that would be a mistake.
That leaves the matter of names. The table trick gets you most of the way: it makes the concept visible and it rules out bad candidates, the way the third row ruled out plural. What it can’t do is produce the word, and no rule covers that part. 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 for now. What matters is describing the concept’s responsibility the way you understand it today, and picking a name that reflects it.
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: our attempt to express it as a single method turned out to be a false abstraction, so 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.
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?
Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.