“We’ll clean it up later.”
— every engineering team, at some point
In my last post, I wrote about the hidden complexity behind simple products. The short version: great software feels effortless because someone chose to carry the complexity for you.
This post is about how engineers learn to carry it.
Not through frameworks. Not through tools. Through a small set of laws that keep showing up, decade after decade, in every codebase and every team. Most of them were written before I was born. All of them are still true.
I found a wonderful collection of these at lawsofsoftwareengineering.com, and I want to walk through the ten that have cost me the most to learn. Not the ten most famous. The ten most expensive.
This is part one of two.
1. Tesler’s Law: Complexity Never Disappears
“Every application has an inherent amount of irreducible complexity that can only be shifted, not eliminated.”
If you read my last post, this law is the reason it exists.
Every system has a floor of complexity below which you cannot go. Booking a ride is complicated. Moving money is complicated. No amount of clean code changes that.
The only real question is: who pays for it?
The complexity has to live somewhere.
┌──────────────┐ ┌──────────────┐
│ USER │ │ ENGINEER │
│ │ │ │
│ "Why do I │ or │ "I'll write │
│ have to do │ │ the retry │
│ all this?" │ │ logic." │
└──────────────┘ └──────────────┘
Someone always carries the weight.
Enter fullscreen mode Exit fullscreen mode
Junior engineers try to remove complexity and end up removing capability.
Senior engineers move complexity to where it does the least damage: away from the user, away from the calling code, into one well-tested place that owns it.
When you see a beautifully simple API, you are not looking at the absence of complexity. You are looking at someone else’s decision to absorb it.
2. Gall’s Law: Working Systems Grow, They Are Not Born
“A complex system that works is invariably found to have evolved from a simple system that worked.”
The full quote from John Gall continues, and the second half is the part that hurts:
“A complex system designed from scratch never works and cannot be patched up to make it work. You have to start over with a working simple system.”
I have watched teams spend six months designing the perfect architecture on a whiteboard. Microservices, event buses, the works. Then they build it, and it does not work, and nobody can say why, because there was never a moment when it did work.
THE PATH THAT WORKS
monolith ──▶ monolith + queue ──▶ extract one service ──▶ ...
✓ ✓ ✓
(works) (works) (works)
THE PATH THAT DOESN'T
whiteboard ──▶ 14 services ──▶ integration ──▶ ???
✗
(nothing has ever worked)
Enter fullscreen mode Exit fullscreen mode
Every system I have shipped that survived started embarrassingly simple. The elegance came later, one working step at a time.
Start with something that works. Keep it working while you grow it. There is no other reliable path.
3. Conway’s Law: Your Org Chart Ships With Your Code
“Organizations design systems that mirror their own communication structure.”
This one sounds like sociology until you see it in production.
Two teams that rarely talk will build two services with an awkward, defensive boundary between them. A team that sits together will build a module with clean internal cohesion. The API contract between services is, quite literally, a snapshot of the human relationship between teams.
ORG CHART ARCHITECTURE
Team A ←──(weekly sync)──→ Team B
│ │
▼ ▼
┌─────────┐ fragile API ┌─────────┐
│Service A│ ◀─────────────▶ │Service B│
└─────────┘ (versioned, └─────────┘
defensive,
distrusted)
Enter fullscreen mode Exit fullscreen mode
The mature response isn’t to fight this law. It’s to use it. If you want a particular architecture, shape the teams first. If you want two components deeply integrated, put their owners in the same standup.
You will ship your communication structure whether you intend to or not. So intend to.
4. Hyrum’s Law: Your Bugs Are Someone’s Features
“With a sufficient number of API users, all observable behaviors of your system will be depended on by somebody.”
Not the documented behaviour. All observable behaviour.
The order of keys in your JSON response. The exact wording of an error message. The fact that your endpoint happens to respond in under 200ms. Somebody, somewhere, has built on top of it.
I learned this working on wallet software, where the users downstream of your API include other engineers, integrators, and scripts you will never see. You do not get to decide what your interface is. Your users decide, by what they observe.
What you promised: What they depend on:
┌───────────────────┐ ┌───────────────────┐
│ GET /users/:id │ │ GET /users/:id │
│ returns a User │ │ returns a User │
└───────────────────┘ │ ...in under 200ms │
│ ...keys in order │
│ ...null not absent│
│ ...error text ".."│
└───────────────────┘
Enter fullscreen mode Exit fullscreen mode
Practical consequences:
- Treat every observable behaviour change as a potential breaking change
- Make contracts explicit before users make them implicit for you
- If you don’t want something depended on, make it impossible to observe (randomise it, hide it, remove it)
5. The Law of Leaky Abstractions: The Basement Always Floods Upward
“All non-trivial abstractions, to some degree, are leaky.”
— Joel Spolsky
Abstractions are how we survive complexity. But they are contracts written in optimism, and reality does not sign contracts.
Your ORM is an abstraction over SQL, until a query is slow and you need to read the generated SQL. TCP abstracts an unreliable network into a reliable stream, until the connection drops and the abstraction hands you the mess back. Your cloud provider abstracts servers, until a region goes down.
your clean code
═══════════════════════════════════ ← the abstraction
↑ leak ↑ leak ↑ leak
slow query timeout cold start
the real world
Enter fullscreen mode Exit fullscreen mode
This is not an argument against abstractions. It’s an argument against ignorance of what lies beneath them.
The engineers I trust most can drop one level down when the leak appears. They use the ORM daily and can still read a query plan. That is not over-engineering. That is knowing where the water comes in.
6. YAGNI: The Future You’re Building For Won’t Arrive
“Don’t add functionality until it is necessary.”
Every speculative feature you build today is a tax you pay forever: more code to read, more tests to maintain, more surface area for bugs, more weight to carry through every refactor.
And here is the quiet tragedy: when the future finally arrives, it almost never looks like your prediction. So you pay the tax and you rewrite the feature.
"We might need multi-currency support someday."
Day 1: +2,000 lines, +40 tests, +1 config system
Day 400: requirement arrives... for crypto, not fiat.
The 2,000 lines don't fit. Rewrite anyway.
Total cost: the tax AND the rewrite.
Enter fullscreen mode Exit fullscreen mode
YAGNI is not an excuse for bad design. Designing so that change is possible is cheap. Building the change in advance is expensive. Learn to feel the difference.
The senior move is almost always: make it easy to add later, then don’t add it now.
7. Knuth’s Principle: Optimise Last, Measure First
“Premature optimization is the root of all evil.”
— Donald Knuth
The most misquoted law in the field, so let’s restore the missing half:
“We should forget about small efficiencies, say about 97% of the time… Yet we should not pass up our opportunities in that critical 3%.”
Knuth was never telling you performance doesn’t matter. He was telling you that your intuition about where the slow part is will be wrong, and that optimising by guesswork trades readability for imaginary speed.
The discipline looks like this:
- Write it clearly
- Measure it honestly
- Optimise the 3% the profiler actually points at
- Leave the 97% readable
Every time I have skipped step 2, I have optimised the wrong thing. Every single time. The profiler has humbled me more often than any code review.
8. Kernighan’s Law: Don’t Write Code You Can’t Debug
“Debugging is twice as hard as writing the code in the first place. Therefore, if you write the code as cleverly as possible, you are, by definition, not smart enough to debug it.”
— Brian Kernighan
This might be the single best argument for boring code ever written.
When you write code, you have full context: the problem is fresh, the invariants are in your head, you are at your sharpest. When you debug it, months later, at 2 a.m., during an incident, you have none of that. You are meeting your own cleverness as a stranger.
WRITING DEBUGGING
──────── ─────────
full context no context
calm paged at 2 a.m.
you, at your best you, at your worst
Write for the second person. They are also you.
Enter fullscreen mode Exit fullscreen mode
I no longer ask “is this code impressive?” I ask “will the person holding the pager understand this in ninety seconds?” Those questions produce very different code, and only one of them survives production.
9. Broken Windows: Decay Is a Signal, and Signals Spread
“Don’t leave broken windows (bad designs, wrong decisions, or poor code) unrepaired.”
The theory comes from criminology: one visibly broken window signals that nobody cares, and the neighbourhood decays from there. Codebases work exactly the same way.
One ignored failing test teaches the team that tests are optional. One // TODO: fix this hack that survives a year teaches the team that hacks are permanent. One rushed PR that gets merged teaches everyone the real quality bar, regardless of what the wiki says.
Day 1: // TODO: temporary hack
Day 90: // TODO: temporary hack (+3 similar hacks nearby)
Day 365: nobody remembers what "clean" looked like
Enter fullscreen mode Exit fullscreen mode
The standard you walk past is the standard you accept.
This is why the small fixes matter disproportionately. Renaming the misleading variable. Deleting the dead code. Fixing the flaky test instead of retrying it. None of these are the feature. All of them are the culture.
10. Goodhart’s Law: The Metric Is Not the Mission
“When a measure becomes a target, it ceases to be a good measure.”
Measure test coverage, and you get tests that assert nothing but touch every line. Measure velocity, and story points inflate. Measure lines of code, and you get more lines of code. Measure closed tickets, and tickets get split into confetti.
None of this is dishonesty. It’s physics. People optimise what is rewarded, and every metric is a proxy for the thing you actually care about. The moment the proxy becomes the target, it detaches from the mission it was measuring.
MISSION PROXY TARGETED PROXY
─────── ───── ──────────────
quality ──▶ coverage % ──▶ empty tests, 100%
productivity ──▶ velocity ──▶ inflated points
reliability ──▶ ticket count ──▶ confetti tickets
Enter fullscreen mode Exit fullscreen mode
Use metrics as instruments, not as goals. Pair every quantitative measure with a human question: is the software actually better? If the number goes up and the answer is no, believe the answer.
What These Ten Have in Common
Read them again and a pattern appears. Almost none of these laws are about code.
They are about people. How teams communicate (Conway). How humans depend on what they observe (Hyrum). How we fool ourselves about the future (YAGNI), about performance (Knuth), about our own cleverness (Kernighan), about what numbers mean (Goodhart).
Software is a technical artifact produced by a deeply human process. The laws endure because human nature does.
In part two, I’ll cover the laws that govern time and scale: why projects are always late (Hofstadter, Brooks), why distributed systems force impossible choices (CAP), why rewrites go wrong (the Second-System Effect), and a few more that have left marks on me.
Until then, a simple exercise: pick one law from this list and find where it is currently operating in your codebase. I promise it’s there.
Thanks for reading.
The laws in this post are collected at lawsofsoftwareengineering.com by Dr. Milan Milanović. Worth bookmarking.
답글 남기기