David Morales David Morales
/
A simple, solid green block next to a teetering tower of ornate blocks about to topple, with a ruby in the center.

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 the starting point:

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

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

The best solution, for clarity and for doing only what’s required right now, is this:

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 answers the domain questions well: 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:

Let’s compare the three variants:

VariantSLOCCyclomatic complexityABC *
Cryptic534.2
Speculative17310.5
Simple932.2
*Hand-calculated classic ABC: sqrt(A² + B² + C²)

By cyclomatic complexity, all three are identical. The metric only counts execution paths, and all three have the same number of logical branches.

It’s SLOC that starts to give the speculative version away, though you probably already noticed just by looking at it. It takes almost twice as many lines as the simple one to do exactly the same thing. And ABC confirms it, with nearly five times the score of the simple version. The speculative version introduces an assignment (the hash) and a handful of message sends (formatter_for, call, the hash lookups, the lambdas) that drive its score up, while the simple one has no assignments and barely any message sends: just conditionals that return strings.

Getting There With TDD

This solution actually emerges 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). Green means safety: it tells you that, at least as far as your current tests go, you understand the problem. And getting to green quickly makes everything that follows simpler.

When to Generalize and When to Tolerate Duplication

The first test asks for the phrase for 47 seconds. 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. And here’s the first fork in the road: do I handle the case with a specific conditional, or do I 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: it shows up out of nowhere with a single example, so it feels important, but it only feels that way because you’re working with incomplete information. 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 (an if/elsif suggests to the reader that each condition varies in some meaningful way and forces them to inspect each one; a case says they all test equality against an explicit value).

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: 90 seconds should read as "1 minute and 30 seconds ago", and 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

  1. In the ABC metric, what does the “B” (Branches) count?

  1. You extract the literal "second" into a method and aren’t sure what to call it. What’s the best name?

  1. You’re testing phrase(1) and you’re torn between adding a branch to the case or pushing the pluralization into the string. What should you do?

  1. Why is it a bad test to assert that feed([...]) returns the same thing as walking the list with phrase and joining the result?

  1. What best describes the starting point this article argues for?