Here is some news before I dive into the essay:
I've published a "readable" draft of Chapter 3: Operation Lens. By readable, I mean it is worth your time without implying perfection. Operation Lens is where the rubber meets the road. The data lens may have seemed simple and uninteresting. However, the operation lens delves deep into the separation between specification and implementation. This concept separates the juniors from the seniors—getting it to work vs designing it. If you were skeptical of the previous chapters for being too basic, I'd love to hear your thoughts on the new one.
And now for the essay:
Design is not recoverable from implementation
One glance at line 50 in the 100-line function told me we couldn't refactor it. The design was lost, and the only way to recover it was to reconstruct it from other sources like the git commits log, the tests, and programmers' memories.
This was during a recent episode of Apropos. We analyzed a lengthy function with plenty of duplication, making mental notes of what we might refactor to reflect the design better. Then, we hit a conditional that did not follow the pattern. It seemed to come out of nowhere.
The behavior was apparent: If the error type was a particular constant, rethrow the exception. It was a special case that had some meaning to the programmer. But, the design was unclear. Why was that type special for this particular try/catch block? What code might set that error type and why? We had more questions than answers.
This phenomenon of lost design could be a law of code, similar to the law of increasing entropy: Without energetic intervention, the implementation tends to diverge from the design. Given enough time, the codebase will not resemble the mental structures a programmer uses to understand it. And just like you can't reverse the shattering of a teacup, you can't recover the lost design.
In the long run every program becomes rococo—then rubble. - Alan Perlis
A corollary to this law is that you should put your design into the code while it's still fresh in your mind. What concepts are you using to think through how the software should work? These mental concepts are the design. Try to get that into the code before you forget it because it will only worsen over time. Your memory fades. As more people touch that code to fix bugs and add features, the further it will drift from the design.
Another corollary is that you should write your design first and implement it second. If you can’t go from implementation to design but can go from design to implementation, it’s obvious which order to write them in. The practical problem is that you don’t know the design until you implement it at least once. Implementation gives us so much information. The process is iterative.
A stronger version of the law states that a single implementation always corresponds to multiple designs. No matter how fresh the code is, it is already impossible to decide between the designs. The best you can do is to make your code eliminate as many designs as possible, especially the more likely but incorrect ones.
If this is a law, why are we so opposed to "over-designing" or "over-abstracting." Instead of building the design into the code, we write the code to be simple and work. We avoid indirection. There's wisdom to that, but why?
The root cause is that we've learned lousy design. To many, design means looking for ways to apply cookie-cutter patterns—the more, the better! To others, it means anticipating changes and defensively adding indirection, just in case some things change. After we learn bad design, we learn to stop designing, and then (maybe) we learn good design.
Anticipating changes is not design. Design is the higher level understanding of how we decompose the system into interacting parts. We cannot keep the entire system in our heads. We must subdivide it hierarchically to have any chance of understanding it. We break the whole thing into a manageable number of parts. If those parts are too big, we break them up. And so we proceed recursively until we're at a manageable level. How you decompose is the art of design. That's all I'll say here.
Everything should be built top-down, except the first time. - Alan Perlis
This law is one of the reasons I am skeptical of refactoring. Yes, refactoring is good. However, good refactoring is about recovering the design. Proponents assume you can find it either in the code or the tests. But the assumption doesn't hold. Sometimes, you have to impose a new design because you can't recover it, and that new design requires different behavior. So, by definition, you can't use refactoring.
Finally, I want to explore whether you should encode a bad design. Or, put another way, is it better to have undesigned code that works or poorly designed code that works? Since it's impossible to have a perfect design, we must allow bad design by induction.
The extreme of bad design is needlessly convoluted to the extent that it hides the underlying behavior. We might encode the design in the code, but it is less legible than no design. Moreover, people who wrote the bad design think it's good. Telling people they can only encode good design into the codebase doesn't help.
The only solution I know is to have them write the design separately from the implementation. That way, it might become apparent how complicated it is and seek to simplify it. But that's not assured. Some people consider complication a sign of sophistication. Maybe not everyone can have good taste.
In the end, design is an aesthetic skill since every decision is interconnected and full of tradeoffs. We need good taste to make good designs, and we develop taste through experience and experimentation. Building software is iterative. So this is the only path forward: The programmers must continuously improve their design skills, they must use that skill to improve the design, and they must improve the code to reflect that design better.