Start With Ugly Code | DevelClan 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.
Note<br>The example problem is a relative-time humanizer : turning a number of seconds into a readable phrase ("47 seconds ago", "just now"). Rails ships with this out of the box (distance_of_time_in_words). It’s worth using because it’s easy to grasp and still surfaces the design decisions we’re after.
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)
"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
(_) { "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 endend">
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.
Note<br>There’s a subtler form of premature design: naming things for what they do today. If you extract the literal "second" into a method and call it second, you’ve named it after its current implementation. The day minutes show up, that name is wrong, and you have to rename it everywhere it’s used. Name methods for what they mean in the domain (unit, say), not for the value...