Stop reviewing for correctness only: a complexity-first design doctrine (and the skill/plugin that enforces it)
You’ve seen this PR. It compiles. Tests pass. The reviewer skims it, sees no obvious bug, drops an “LGTM” before their coffee gets cold. Six months later, nobody can touch that module without also touching four others, and the person who wrote it has moved teams - or, increasingly in 2026, asked an AI to write four teams’ worth of modules before lunch and moved on to the next ticket. Nothing was ever wrong with any single line. It just quietly became too expensive to change, and now it’s becoming too expensive to change at a speed nobody budgeted for.
That’s the failure mode this doctrine targets. Not bugs - complexity. Complexity didn’t get a new personality
recently. It got a keyboard, a code-generation button, and a mandate from someone with a Director title who has never
run git blame in their life.
Most review checklists optimize for one question: does this work? That’s necessary and nowhere near sufficient. A change can be functionally correct and still make the system harder to reason about, harder to extend, harder to delete. Nobody writes that PR comment because “does this work” has no line item for “does this make the next six months harder.”
I spent several weeks deliberately drilling this into habit - not skimming A Philosophy of Software Design once and moving on, but applying its models against real pull requests and real designs until “is this module deep or shallow” stopped being a lookup and started being a reflex. This post is that doctrine: the mental model, not a book summary. Credit where it’s due - the foundational framing (complexity as the root problem, deep modules, information hiding, defining errors out of existence) comes from John Ousterhout , professor at Stanford and creator of the Raft consensus algorithm and the Tcl scripting language. What follows is how I apply it.
- The complexity model I actually use in review: two causes, three symptoms, one formula
- Why AI-assisted “vibe coding” didn’t invent a new failure mode - it just multiplied the same two root causes by an order of magnitude, with receipts
- Why the loudest cheerleaders for that math have never opened a diff, and what the doctrine says about their own favorite dashboard
- The 15 principles, compressed to what changes your next PR comment
- A red-flags checklist you can run against a diff in five minutes
- A before/after example showing a shallow module becoming deep
- How this is complementary to, not the same as, the code review operating system I wrote about earlier
- Where I packaged this as a skill/plugin, why that’s the smaller part of this post, and what actually goes wrong when you rely on a skill to trigger itself
The problem with “working code”
Every engineer has approved a PR where the diff was small, the tests were green, and something still felt off. Usually that feeling has a name, and the name is one of three things:
- Change amplification - a conceptually simple change (rename a status, add a field) forces edits in five unrelated files.
- Cognitive load - to make a safe change, you have to hold six other modules in your head, even though none of them block you mechanically.
- Unknown unknowns - you genuinely don’t know what else needs to change, or even where to look, to do the task safely.
None of these show up in a test suite. That’s why “working code” is the wrong bar. The right bar is: does this decision reduce complexity, or does it just move the pain somewhere the tests can’t see?
Complexity itself isn’t one big architectural sin. It’s the sum of many small, individually-reasonable decisions:
| |
Each part’s complexity (cp), weighted by how much time developers actually spend in that part (tp). This is why a
sloppy shortcut in a file nobody touches is harmless, and the same shortcut in a hot module is a slow-motion incident.
It also means no single PR creates a complexity crisis - and no single PR fixes one. That’s the argument for an
investment mindset: spend roughly 10-20% of your time on ongoing design improvement instead of always taking the
fastest tactical path. Skip that consistently and you get what Ousterhout calls a “tactical tornado” - a codebase where
every fix is fast and every fix makes the next one slower.
Both symptoms above trace back to exactly two root causes:
| Root cause | What it looks like |
|---|---|
| Dependencies | Code cannot be understood or safely changed in isolation - touching it means touching something else. |
| Obscurity | Important information exists but isn’t visible where you need it - in the interface, the name, or the comment. |
flowchart LR
DEP["Dependencies:<br/>can't change code<br/>in isolation"] --> COMPLEX["Complexity<br/>C = Σ (cp · tp)"]
OBS["Obscurity:<br/>information exists<br/>but isn't visible"] --> COMPLEX
COMPLEX --> CA["Change amplification"]
COMPLEX --> CL["Cognitive load"]
COMPLEX --> UU["Unknown unknowns"]
style DEP fill: #ffe1e1, stroke: #cc0000, color: #000
style OBS fill: #ffe1e1, stroke: #cc0000, color: #000
style COMPLEX fill: #fff4e1, stroke: #cc8800, color: #000
style CA fill: #f0e1ff, stroke: #8800cc, color: #000
style CL fill: #f0e1ff, stroke: #8800cc, color: #000
style UU fill: #f0e1ff, stroke: #8800cc, color: #000
Every technique below is just a way to cut dependencies, reduce obscurity, or both. If a proposed fix doesn’t do either, it’s not actually addressing complexity - it’s rearranging it.
Then AI happened, and “10x” became the optimistic estimate
Everything above was already true before an LLM ever wrote a line of Scala. Then vibe coding showed up, and the complexity model didn’t change - the inputs to it did, by roughly an order of magnitude, which is the polite way of saying it got much worse, and everyone with a keyboard now has the receipts to prove it.
Apiiro analyzed tens of thousands of repositories and several thousand developers across Fortune 50 enterprises and
found that the same AI tools driving 4x commit velocity are shipping 10x more security
findings
-
over 10,000 new findings per month by June 2025, up from roughly 1,000 in December 2024, and the curve is
accelerating, not flattening. The flaws that grew fastest weren’t typos - those actually dropped. It was the
expensive kind: privilege-escalation paths up 322%, architectural design flaws up 153%, while pull-request volume
fell by nearly a third, because AI-assisted commits got batched into fewer, larger, harder-to-review changes.
Apiiro’s own product manager put it plainly: “AI is multiplying not one kind of vulnerability, but all of them at
once.” Translate that into the formula from the last section: nobody reduced cp. They just multiplied the number of
cp terms being generated in parallel, faster than any review cadence built for humans was ever designed to absorb.
Documentation gets it worse, not better, despite the AI - or because of it. Code changes faster than anyone updates the docs describing it, and the model writing the next round of code and docs is trained on, and prompted with, whatever’s already there, stale or not. curl’s maintainer eventually shut down the project’s six-year-old bug-bounty program in January 2026 because, in his own telling, roughly 20% of submissions were AI-generated “vulnerability reports” - confident, well-formatted, and wrong - while only about 5% turned out to be real, a hit rate too low for any maintainer to keep triaging by hand. An arXiv study coding 1,154 Reddit and Hacker News posts on “AI slop” found the same complaint on loop across every project: not that the AI is slow, but that everything it produces looks reviewed and isn’t - which is obscurity wearing a suit, at a scale no individual reviewer volunteered for. The paper’s own framing for it is a tragedy of the commons: one person’s productivity gain externalizes its cost onto every reviewer and maintainer downstream.
None of this is an argument against using AI to write code. It’s an argument that the two root causes of complexity - dependencies and obscurity - now compound at a speed no human review process was built for, and “the model wrote it” is not a design decision, a form of hiding, or an interface. It’s just volume, generated faster than anyone can verify it’s not garbage.
And the people cheering loudest have never opened a diff
Here’s the part that never makes the all-hands deck. A lot of the loudest “we’re AI-first now” energy isn’t coming from engineers weighing whether the resulting modules are deep or shallow - it’s coming from Directors, Senior Directors, Engineering Managers, Product Managers, and stakeholders whose entire interface with the codebase is a velocity chart and a slide that says “40% of code is now AI-generated,” 44-point font, italicized for emphasis.
Run that dashboard through the doctrine’s own test: can someone use it correctly without learning how the thing underneath it actually works? For most “AI adoption %” metrics, that’s a hard no. Which makes the dashboard itself a textbook shallow module - a big, confident interface that costs almost nothing to learn and hides absolutely nothing about whether the code behind it is any good, because the person who built the interface never learned what “good” would look like to begin with. They didn’t shorten the distance between the metric and the truth. They just added a name.
That’s not a personality flaw. It’s the exact pattern this doctrine already named, applied top-down instead of found
bottom-up. A mandate to “ship more with AI” is a dependency an entire engineering org can no longer change in
isolation - every team has to route around it - issued by someone with zero visibility into whether the resulting
throughput is deep functionality or ten thousand duplicated lines waiting for their CVE number. Reddit’s own CEO
said the quiet part out loud in
2026
:
his team’s bottleneck moved to code review, in his words, “because we can produce so much code” - a straight
admission that the org can now generate cp faster than anyone can verify it isn’t garbage, and the proposed fix is
rarely “review more carefully.” Coinbase’s CEO gave engineers a week to adopt AI tooling and fired the ones who
didn’t
, which is an efficient way to
select for compliance and select against the one skill this entire doctrine is actually about: judging whether an
interface hides what it claims to.
None of this is subtle once you’ve sat through enough of these town halls. A certain kind of shameless, jumped-up leader sprinkles “AI” over every sentence the way a diner sprinkles the same coriander-chutney garnish on every dish on the menu - not because the dish needs it, but because it’s cheap, it’s visible, and it makes the plate look like something happened. AI-first decks, AI-first OKRs, AI-first town halls, AI-everywhere-all-the-time: garnish, not substance, and the substance was never theirs to provide in the first place. Then, at 2 a.m. when the thing built on that garnish falls over - or eighteen months later when the tech-debt bill from Apiiro’s 10x number actually comes due - the same people who have never opened a diff page the developers, and L2/L3 straight out of bed, with a straight face, like it’s a shock, like they had nothing whatsoever to do with the mandate that produced it. That’s the whole move: take credit for the interface, hide none of the cost, and hand the pager to whoever’s on call.
So the next time a Director asks why velocity didn’t 10x the way the AI vendor’s slide promised, you now have the honest answer, and it isn’t a soft one: the model was never the constraint. Judging depth always was, and no amount of leadership enthusiasm for a shallow adoption-rate metric changes the two things that were always going to decide the outcome - dependencies and obscurity. It just adds a third, larger, more confident source of both, mandated from a level of the org chart that has never had to hide a single piece of information behind an interface in its life.
In plenty of places, that mandate isn’t a suggestion anymore - using AI to write code is now compulsory, handed down from exactly the level of the org that just got roasted above. You don’t get a vote on the mandate. You still get a vote on whether you can tell the difference between what it produces and what actually belongs in your codebase. That’s what the rest of this post, and the skill/plugin at the end of it, is for: a mental model for fighting complexity that works whether the diff in front of you came from a colleague or a language model - offered as a complement to your own judgment, not a replacement for it.
The 15 principles, compressed
I don’t recite all fifteen in review. In practice, five or six carry most of the weight on any given PR. Here’s the full list - treat it as a reference, not a script:
- Complexity is incremental: sweat the small stuff.
- Working code isn’t enough - it must also be well-designed.
- Make continual small investments to improve system design.
- Modules should be deep.
- Interfaces should make the most common usage as simple as possible.
- A simple interface matters more than a simple implementation.
- General-purpose modules are deeper.
- Separate general-purpose code from special-purpose code.
- Different layers should have different abstractions.
- Pull complexity downward (into the implementation, away from callers).
- Define errors (and special cases) out of existence.
- Design it twice.
- Comments should describe things that are not obvious from the code.
- Software should be designed for ease of reading, not ease of writing.
- The increments of software development should be abstractions, not features.
The one that changes the most review comments in practice is #4: modules should be deep. A deep module has a simple interface hiding substantial functionality. A shallow module has an interface that costs almost as much to learn as the implementation behind it - you’ve added a name without hiding anything, which is pure overhead.
Deep vs. shallow, with a real example
Here’s a pattern from org.llm4s.core.safety.Safety (the same codebase I use for review guideline
examples
) - a small function that’s a good example of the difference.
Shallow version - the interface barely hides anything; the caller still has to think about Try, Success,
Failure, and how to map exceptions to your error type, every time:
| |
Deep version - the interface is one call; everything about Try, exception mapping, and the shape of the domain
error is hidden inside:
| |
Nothing about Try, pattern matching on Success/Failure, or how exceptions become domain errors leaks into the
caller. That’s the whole test for depth: can someone use this correctly without learning how it works? If the
answer is no, the module is shallow no matter how clean the code inside it looks.
This is also principle #11 in action - define errors out of existence. ErrorMapper isn’t a try/catch scattered
at every call site; it’s a single seam where “how do exceptions become our error type” gets decided once, and every
caller inherits that decision for free.
The red-flags checklist
This is the part I actually run against a diff. Each row is a checkable symptom, not a vague smell - if you can point to the line, you can write the comment.
| Red flag | Symptom to look for |
|---|---|
| Shallow module | Interface isn’t much simpler than the implementation behind it. |
| Information leakage | The same design decision (a file format, a data layout) is baked into more than one module. |
| Temporal decomposition | Code structure mirrors execution order (“first read, then parse, then write”) instead of knowledge boundaries. |
| Overexposure | Using a common feature requires learning about rarely-used features first. |
| Pass-through method | A method does almost nothing but forward its arguments to another method with a similar signature. |
| Repetition | The same nontrivial code appears over and over. |
| Special-general mixture | Special-purpose logic is tangled into general-purpose code instead of cleanly separated. |
| Conjoined methods | Two methods are so coupled you can’t understand one without reading the other, with nothing marking the connection. |
| Comment repeats code | Everything the comment says is already obvious from the adjacent code. |
| Interface contaminated by implementation | An interface-level comment leaks implementation details the caller never needed. |
| Vague name | A name is so generic it conveys almost no information (result, data, tmp, handle). |
| Hard to pick a name | Struggling to name something cleanly usually means the thing itself isn’t cleanly designed. |
| Hard to describe | If documenting something completely requires a long comment, the abstraction is probably wrong. |
| Nonobvious code | A reader can’t tell what a piece of code does or why just by reading it. |
Each row is a symptom, not the disease. The follow-up question is always the same: what dependency or obscurity is actually causing this, and does my proposed fix remove it or just relocate it? If a reviewer just says “this method is shallow” and stops there, that’s a lazier version of “LGTM.” The useful comment names what the method should absorb or hide instead.
How this differs from a review checklist
The code review operating system I wrote about covers the process: severity rubrics, CI gates, PR workflow, comment templates. This doctrine is upstream of that - it’s what should be in your head before you write the comment, whether you’re reviewing someone else’s PR or designing your own module. A severity rubric tells you a missing error type is a must-fix. This doctrine tells you why - because an untyped exception at a domain boundary is obscurity (the failure modes aren’t visible in the type) turning into change amplification (every caller has to guess what can go wrong).
Concretely, I use it in three moments:
- Designing something new - before writing an implementation, I ask what this module should hide, and design the interface around hiding it. Then I sketch at least two substantially different approaches (design it twice) before committing to either. If I can’t write a one-paragraph interface comment without getting long-winded, that’s a sign the abstraction is wrong, not that I need to write harder.
- Reviewing existing code - walk the diff against the red-flags table above. For each hit, trace it back to dependency-or-obscurity and name the fix, not just the symptom.
- Naming and commenting - a name that’s hard to choose cleanly is telling you the underlying thing isn’t cleanly designed. A comment that just restates the code is telling you the code needs a better name, not a comment.
Packaging it: a skill/plugin (the complementary part)
Once this became habitual, the natural next step was making it available where I actually do design review now - as a skill/plugin (built for Claude Code, since that’s the coding assistant I use) so it triggers automatically on the phrases that actually show up in review: “is this a good design,” “does this class do too much,” “how should I structure this.”
| |
To be clear about what this is and isn’t: the plugin is a convenience, not the point. It doesn’t know anything the doctrine above doesn’t already say - it just means I don’t have to re-explain “deep modules” from scratch every time I ask my coding assistant to review a diff. If you don’t use a tool that supports this kind of skill/plugin, the doctrine still applies exactly the same way it did before I automated the reminder. The mental model is the artifact. The skill is a delivery mechanism for a mental model I’d already spent weeks internalizing - it doesn’t shortcut that part for you.
One honest caveat, because I’ve been burned by the opposite claim before: description-based skill triggering isn’t reliable by default. Glean’s production data (covered in my harness-engineering post ) showed that simply making a skill available initially dropped correct routing by about 20% in their evals, and it only recovered once they added explicit negative examples (“don’t call this for X, do Y instead”) to the skill description. I hit the same thing at a much smaller scale while writing this skill’s description - the router sometimes reached for it on plain refactoring questions that had nothing to do with module depth. The fix isn’t a bigger model or a cleverer prompt; it’s a narrower, more negatively-specified description.
If you install this and want it to fire every time rather than sometimes, don’t rely on the description at all - just say “use the software-design-depth-doctrine skill” in your prompt. That’s the same reliability lever from that post: explicit invocation beats hoping the router guesses right. For a single-user skill like this one, a missed trigger just means you re-ask; it’s not the kind of failure mode you’d tolerate in a team-wide rollout, but it’s worth knowing about before you assume the skill is “always on.”
References
- A Philosophy of Software Design - John Ousterhout. The source of every principle in this doctrine: complexity as the root problem, deep modules, information hiding, defining errors out of existence.
- John Ousterhout - Stanford professor, creator of Raft and Tcl.
- Apiiro: “4x velocity, 10x vulnerabilities” https://apiiro.com/blog/4x-velocity-10x-vulnerabilities-ai-coding-assistants-are-shipping-more-risks/ ) - the Fortune 50 data behind the AI-complexity numbers above.
- curl mulls nixing bug bounty awards to stop AI slop https://www.theregister.com/2025/07/15/curl_creator_mulls_nixing_bug/ the maintainer-burden story behind the AI-documentation paradox.
- “An Endless Stream of AI Slop” - Baltes, Cheong, Treude. 1,154 Reddit/HN posts coded into the tragedy-of-the-commons framing referenced above.
- Reddit CEO Steve Huffman on AI and code review becoming the bottleneck
- Coinbase’s CEO fired engineers who didn’t adopt AI tooling within a week
- Code review operating system - the process layer this doctrine sits upstream of.
- Harness engineering: why skills don’t trigger themselves - Glean’s skill-routing data referenced above.
- 🔗 View on GitHub: vim89/software-design-depth-doctrine-skill - the skill/plugin packaging this doctrine (built for Claude Code).
TL;DR
- “Working code” is not the bar. Complexity - change amplification, cognitive load, unknown unknowns - accumulates from many small decisions, and it has exactly two root causes: dependencies and obscurity.
- AI-assisted coding didn’t invent a third root cause. It multiplies the same two by an order of magnitude - see Apiiro’s 10x security-finding spike and curl’s AI-slop-driven bug-bounty shutdown - faster than any human review cadence was built to absorb.
- An “AI adoption %” slide is a shallow module: a big, confident interface that hides nothing about whether the code behind it is any good, because whoever built the interface never learned what “good” looks like. A mandate built on it is a dependency and an obscurity, both issued from a level of the org that’s never had to hide anything behind an interface in its life.
- Every design technique in this doctrine is just a way to cut one of those two things. If a proposed fix doesn’t, it’s rearranging complexity, not removing it.
- The single highest-leverage question in review is: is this module deep? A simple interface hiding real functionality beats a shallow one that mirrors its implementation.
- Use the red-flags checklist as a five-minute pass over a diff - each row names a checkable symptom, and the useful review comment always traces the symptom back to its cause.
- This doctrine sits upstream of process (severity rubrics, CI gates, comment templates) - it’s what decides why something is a must-fix, not just that it is.
- I packaged it as a skill/plugin (built for Claude Code) for my own workflow. Use it if it helps; the doctrine works with or without it.
- Skill triggering by description alone isn’t reliable - if you want it every time, ask for it by name instead of hoping it fires.
