When you were starting out as a programmer, you wrote simple code. Then you learned new techniques, took on harder problems, and produced increasingly complex solutions. Experience taught you that almost all code changes eventually, and you began writing it in anticipation of that day. Complexity came to feel natural and inevitable.
Faced with a new problem, an experienced programmer tends to jump straight to the “elegant” solution: adding layers of indirection, extracting abstractions, and laying the groundwork for changes that don’t exist yet. The result is usually convoluted code that answers no real requirement, because you can’t guess the right abstraction from incomplete information.
This article makes the opposite case, using a small, familiar problem. The best starting point isn’t the cleverest code or the most extensible code, but the code that passes the tests and reads clearly without designing anything at all.
Here’s what it has to do:
TimeAgo.new.phrase(47) # => "47 seconds ago" (plural)
TimeAgo.new.phrase(1) # => "1 second ago" (singular)
TimeAgo.new.phrase(0) # => "just now" (special case)
The Premature-Design Trap
Object-Oriented Design is a trade: you accept more complexity along one axis (more classes, more indirection, more abstraction) in exchange for less complexity along another (a lower cost when something changes). The trade only pays off if the abstractions are the right ones. And choosing the right abstraction is hard: get it right and the code is expressive and flexible; get it wrong and it’s confusing and expensive.
It’s tempting to reach for those abstractions early, inferring them from incomplete information, but that creates a chicken-and-egg problem: you can’t build the right abstraction until you fully understand the code, yet a wrong abstraction keeps you from ever understanding it. The takeaway isn’t to hunt for abstractions, but to resist them until they insist on showing up.
Let’s look at two ways to get this problem wrong.
Cleverness as a Cost
The first is compressing every decision into a single expression:
class TimeAgo
def phrase(seconds)
seconds.zero? ? "just now" : "#{seconds} second#{'s' unless seconds == 1} ago"
end
end
It fits on one line and does all three things at once. But those three decisions (the zero case, pluralization, and the general phrase) are crammed into a ternary with an interpolation that has a conditional inside it. To read it, you have to unpack the whole thing in your head before you can tell what it produces.
Cleverness gives you a small thrill when you write it or decode it. But if you can come up with something like this, you can almost certainly write something simpler.
Speculative Generality
The opposite mistake is preparing for a future nobody has asked for. Here, someone who anticipates “lots of time formats” builds a registry of formatters before needing one:
class TimeAgo
FORMATTERS = {
zero: ->(_) { "just now" },
singular: ->(_) { "1 second ago" },
plural: ->(n) { "#{n} seconds ago" }
}
def phrase(seconds)
formatter_for(seconds).call(seconds)
end
def formatter_for(seconds)
case
when seconds.zero? then FORMATTERS[:zero]
when seconds == 1 then FORMATTERS[:singular]
else FORMATTERS[:plural]
end
end
end
To produce a phrase, phrase asks formatter_for, which looks up a lambda in the hash and hands it back for phrase to invoke with call.
This indirection raises the cognitive load (you have to follow the hops to see what’s going on), and in return it doesn’t lower the cost of any future change, since nothing requires interchangeable formats. In other words, we don’t have the information we’d need to justify this abstraction.
The result is code that’s harder to understand and no easier to change: adding a new case means touching two places (the hash and formatter_for) instead of one. So its cost buys you nothing.
The Best Starting Point
Here’s the best solution, both for clarity and for doing only what’s required right now:
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
The first thing you notice is how simple it is. It maps cleanly onto the domain: there are three clear cases (zero, singular, and the general case), and you can tell them apart at a glance without unpacking anything in your head.
It’s normal for this solution not to be your first instinct. It feels almost too plain, and it’s missing qualities we expect from “good code”: it has no named abstractions and, as we’ll see, it even tolerates some duplication. But that’s exactly its strength. We could describe this starting point like so:
Code that reaches green quickly, favoring understandability over changeability. It uses tests to drive comprehension and patiently piles up concrete examples while waiting to understand the abstractions underneath.
In other words, it waits for enough information before removing duplication, because it’s cheaper to manage temporary duplication than to recover from the wrong abstraction.
The curious thing about this code is that, as easy as it is to understand, it makes no provision for change. And that’s fine as a starting point: if nothing ever changes, the most cost-effective move is to ship it as is.
Judging Code Objectively
The first two solutions are “bad,” but so far that’s just an opinion. And opinions about what clean code is tend to describe how finished code looks (“elegant,” “crisp abstractions”) without saying how to get there or how to choose between two candidate solutions.
Metrics help you compare variants and put a number on which one is better. There are three classic metrics:
- SLOC (Source Lines of Code): counts lines. Easy to collect, but it only measures volume. It says little about quality: you can write more lines to do less.
- Cyclomatic complexity (McCabe, 1976): counts the unique execution paths through the code. The more conditionals, the higher it goes (lower is better). It’s handy for knowing the minimum number of tests you need to cover all the logic.
- ABC (Fitzpatrick, 1997): counts Assignments (variable assignments), Branches, and Conditions (conditional logic).
Let’s compare the three variants:
| Variant | SLOC | Cyclomatic complexity | ABC * |
|---|---|---|---|
| Cryptic | 5 | 3 | 4.2 |
| Speculative | 17 | 3 | 10.5 |
| Simple | 9 | 3 | 2.2 |
sqrt(A² + B² + C²)
By cyclomatic complexity, all three are identical. The metric only counts execution paths, and all three make the same three decisions. It confirms they’re equivalent in logic, and says nothing about which one you’d rather maintain.
SLOC does separate them, but look at which way. It gives the speculative version away, at almost twice the lines of the simple one to do exactly the same thing. Then it hands first place to the cryptic one, at five lines. If SLOC were all you had, it would pick the solution we just rejected: shorter isn’t the same as simpler, and the cryptic version doesn’t remove a single one of those three decisions, it just packs them into less space.
ABC is the one that ranks them the way a reader would. The speculative version scores nearly five times the simple one, because it introduces an assignment (the hash) and a handful of message sends (formatter_for, call, the hash lookups, the lambdas), while the simple one has no assignments and barely any message sends: just conditionals that return strings. And the cryptic version, the shortest of the three, still scores nearly double the simple one. A conditional nested inside an interpolation costs you something to read whether or not it fits on one line.
Getting There With TDD
You shouldn’t try to write that case in one go. It should emerge from a series of tests, written one at a time, following the Red-Green-Refactor cycle (write a failing test, write the minimum code to pass it, then improve without breaking anything).
So let’s rewind to an empty file and watch the same solution appear on its own.
When to Generalize and When to Tolerate Duplication
The first test asks what 47 seconds should read as. The minimum code that passes it looks pointless, but it’s important to start here:
def phrase(seconds)
"47 seconds ago"
end
Adding a second test (phrase(10)) breaks that literal. So here’s the first fork in the road: do you handle the new value with its own conditional, or do you generalize? Since infinitely many values behave the same way (any n greater than 1), you now have enough information to generalize:
def phrase(seconds)
"#{seconds} seconds ago"
end
The singular is a special case. phrase(1) should return "1 second ago", not "1 seconds ago". The temptation is to push the pluralization logic into the string:
"#{seconds} second#{'s' unless seconds == 1} ago"
It’s shorter, which makes it look better, but it isn’t. That “abstraction” is a decoy. You’ve seen the singular exactly once, and from that single example you’re inferring a general rule about how these phrases get built. Generalizing from one case feels like insight, but nothing you have so far tells you the rule is the right one, or even that pluralization is the concept that varies here. The sensible move is to add a branch and tolerate the duplication:
It’s better to tolerate duplication than to commit to the wrong abstraction.
Following that rule, you add a branch for the singular, accepting that the two phrases look alike, and resisting DRY for now:
def phrase(seconds)
if seconds == 1
"1 second ago"
else
"#{seconds} seconds ago"
end
end
There’s the duplication: "second ago" and "seconds ago". With a single example of the singular, we still don’t know what the right abstraction is, so we wait.
When the zero case arrives (phrase(0) should give "just now"), the if becomes a case:
def phrase(seconds)
case seconds
when 0 then "just now"
when 1 then "1 second ago"
else "#{seconds} seconds ago"
end
end
That case is the solution from the beginning of the article, reached without designing anything up front. Just four tests, and the discipline to write the least code that passed each one.
Those four tests simplify down to a suite of three:
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
One test per branch, which is exactly the minimum that a cyclomatic complexity of 3 predicted. The one to drop is phrase(10): the moment the code stopped hardcoding the number, 10 and 47 exercised the same branch, so keeping both would only add a test to maintain for no extra coverage.
Test Behavior, Not Implementation
Let’s add a bit more functionality: composing several phrases into a feed. For that we’ll add a feed method that takes a list of durations and returns one phrase for each:
def feed(durations)
durations.map { |duration| phrase(duration) }.join("\n")
end
How do we test it? If you keep DRY in mind from the start, you might be tempted to write this:
def test_feed_renders_each_duration
time_ago = TimeAgo.new
durations = [47, 1, 0]
expected = durations.map { |duration| time_ago.phrase(duration) }.join("\n")
assert_equal expected, time_ago.feed(durations)
end
It’s short, it goes green, and it’s easy to write. But it’s a trap. The expected value reproduces exactly what feed does (walk the list with phrase and join with \n), so the test boils down to checking that something equals itself: it never verifies that the text produced is actually correct. If phrase (and feed along with it) had a bug, expected would inherit the same bug and the test would still pass.
The fix is for the test to know nothing about how feed produces its output. What we expect is a specific piece of text, so we assert it by hand:
def test_feed_renders_each_duration
expected = "47 seconds ago\n1 second ago\njust now"
assert_equal expected, TimeAgo.new.feed([47, 1, 0])
end
With a long list this gets repetitive, and you’ll feel the urge to generate the expected string with a map inside the test. DRY is a good technique in code, but it’s less useful in tests: cutting the string duplication would force you to add logic, and that logic would have to mirror the code’s, coupling the two together. Tests are the place for concretions; abstractions belong in the code. When you’re testing, the best choice is usually to just write it all out.
This is the safety net. A test coupled only to the public interface, blind to how the result is produced, is what tolerates change and makes refactoring possible. If you want to dig deeper into what to test and what to ignore so your tests survive every change, I cover it in Tests That Survive Change.
Wrapping Up
We have working code, backed by tests, and deliberately undesigned. We resisted both temptations (cleverness and speculative generality), leaned on metrics to see which solution was better, and let each test reveal just enough information before committing to an abstraction.
If nothing changes, this code is enough. But what about when something does? Imagine the next requirement is to stop speaking only in seconds:
- 60 should read as
"1 minute ago". - 90 as
"1 minute and 30 seconds ago". - 3600 as
"1 hour ago".
That jump between units makes our case fall apart, and forces us to design something more involved.
That’s exactly the line where the simple solution stops being enough. We’ll see it in the next article, when that new requirement forces us to refactor.
Test your knowledge
-
In the ABC metric, what does the "B" (Branches) count?
-
You extract the literal
"second"into a method and aren't sure what to call it. What's the best name?
-
You're testing
phrase(1)and you're torn between adding a branch for the singular or pushing the pluralization into the string. What should you do?
-
Why is it a bad test to assert that
feed([...])returns the same thing as walking the list withphraseand joining the result?
-
What best describes the starting point this article argues for?