Learnability is the single most important non-functional aspect of code

and more than ever in the age of AI

Best practices are a dime-a-dozen. They come and they go, and they’re different depending on who you ask. What I want to talk about here is something more fundamental, a human constraint that outlives whichever framework is winning this week.

Nothing matters more than learnability: how readable, greppable, and understandable your code is. Not consistency, not DRY.

Code is written once and read a hundred times — by your teammates, by the you who comes back in a year having forgotten everything, and now by the coding agents you’ve put on the payroll. Learnability is just a measure of how expensive all that reading is, and that bill has never been higher.

Our industry has lost the plot on writing simple code. System design interviews everywhere expect a microservice architecture. Conferences are stuffed with talks on surviving microservices and not one on the virtues of a monolith. An otherwise standard CRUD app gets wrapped in Apollo GraphQL to transmit a single real-time event.

Instagram scaled a monolith to 14 million users with 3 engineers, yet teams will deploy 14 microservices to run an app with 0.1% of that traffic. Now a single feature touches three repos and four pull requests, and releases have a mandatory deployment order. None of that complexity was demanded by the problem. We added it, mostly because somebody read that FAANG does it this way.

The pitch for AI coding tools is that code is now cheap. And the mechanical act of typing it is cheaper than ever. But code was never expensive because of the typing. It was expensive because of the reading, the understanding, the modifying, the not-breaking-the-thing-next-to-it. AI didn’t make that part cheaper. It made it the whole game.

A coding agent is bounded by its context window, which is not so different from the very human notion of cognitive load. And context isn’t free. Longer contexts mean degraded output and a bill that grows with every token. So when tangled code forces an agent to read five files where one would do, you pay twice, in worse results and a bigger invoice. Point an agent at a clean codebase and it’s remarkable; point it at a mess and you’ll watch it flail.

This is why Ousterhout’s distinction between deep and shallow modules1 matters more than ever. A deep module does a lot behind a small interface; like Unix I/O, where five calls (open, read, write, lseek, close) hide hundreds of thousands of lines of implementation. A shallow module is the reverse: a big interface wrapped around barely any functionality, spilling its complexity out onto everything that touches it. Deep modules are easier to learn, easier to test, and easier to use correctly for humans and machines alike; because what a reader has to fit in their head is the interface, not the implementation.

So how do we measure learnability? Two axes carry most of the weight: cognitive load and greppability. The rest of this post is about those two.

The average person can only hold about 5-ish things in mind at once2. Variables, nested calls, levels of inheritance, conditionals; you get about five before it becomes much harder for anyone to follow what’s actually happening. Many anti-patterns are just different ways of blowing past that budget.

And nobody blows past it on purpose. These messes accumulate one reasonable-looking commit at a time. When there are two conditionals, a third doesn’t hurt; by the time there are thirty, a thirty-first doesn’t seem to either. There is no force that simplifies a codebase on its own, only the deliberate “nitpicky” choice to push back.

Say we’re asked to change a few things for admin users.

We have: AdminController extends UserController extends GuestController extends BaseController

  • Part of the functionality is in BaseController
  • Basic role mechanics got introduced in GuestController
  • Things got partially altered in UserController
  • Finally we arrive at AdminController 😵‍💫

Oh, and there’s a SuperuserController that extends AdminController, so changing AdminController might break that too.

mind-blown

Prefer composition over inheritance. Every link in that extends chain is one more thing you have to load into your head before you can safely touch the one in front of you — and the budget is only five.

“Don’t repeat yourself” gets wired in so deeply that a few duplicated lines start to feel physically painful. It’s a good rule until it’s the only rule. Stamp out every last repetition and you trade duplication for coupling. Two things that merely looked alike are now welded together through one abstraction, and a change demanded by one silently breaks the other. You reached for DRY to make the code simpler and instead added a layer of indirection every future reader (and every agent) has to unwind first.

Pike’s line cuts deepest at the dependency level. We’re so allergic to reinventing the wheel that we’ll pull in a heavy library to avoid writing a ten-line function. All of your dependencies are your code. You just don’t get to read them until something breaks.

The fix isn’t to abandon DRY, it’s to wait. Good abstractions are earned by doing it the hard way a few times first.

Remember those 14 microservices? They’re what happens when you draw boundaries before you understand the problem. It’s very hard to find the right logical boundaries up front; the key is to decide as late as you responsibly can, when you have the most information. A network layer introduced early makes those decisions nearly impossible to revert.

A well-crafted monolith is usually more flexible than a swarm of microservices and far cheaper to keep in your head.

Sometimes the complexity is real. The business logic is convoluted, the third-party API obtuse, and your code has to model it. When that happens, don’t smear the mess evenly across the codebase so no single file looks too scary. Do the opposite: build a wall around the hard part. Hide it behind a deep module with a small interface (think: Unix I/O) so the other 90% of your code gets to stay boring.

The most important stuff belongs at the top of the file; the public functions, the main component, the exports, the “what”3. Shove helpers and incidental details to the bottom or into another file. When you open a file, you should know what it does within a couple of seconds.

Cognitive load is the cost of holding code in your head. Greppability is the cost of finding it in the first place: how easily a plain text search turns up the thing you need, across the repo or across the whole company. And an agent’s grep fails in exactly the same places yours does.

The name you declare something with should be the only name used to reference it. Avoid default exports in JS for exactly this reason. Keep the same name, and even the same casing, across application boundaries. If a name is bad, either rename it everywhere or just deal with it.

When a developer needs to debug an API call, the first thing they do is search for the source of that endpoint. For GET /users/u_123, that grep is probably /users/ or /users/:userId.

fastify.register(userRoutes, {prefix: '/users'})
function userRoutes(fastify) {
fastify.get('/:userId', () => {})
}

But neither search returns anything here. This code is too DRY. The same instinct that bloats cognitive load also sabotages greppability.

Both problems vanish the moment you declare the full route in the endpoint definition.

fastify.register(userRoutes)
function userRoutes(fastify) {
fastify.get('/users/:userId', () => {})
}

Every antipattern here is the same mistake in different clothes: it forces a reader to hold more in their head than they should have to. That tax used to be paid only by people, now it’s paid by people and the tools we’ve hired to help them; in worse output and real dollars.

  • If you’re a senior engineer, learnability is the highest-leverage thing you can optimize for. Not cleverness, not a perfect DRY score.
  • If you’re earlier in your career, resist proving yourself with clever one-liners. The most senior move in the room is usually the simplest thing that works.
  • If you’re reaching for microservices because the big companies do it, measure your actual requirements first.

Best practices will keep coming and going. But as long as code is read far more than it’s written — by humans or machines — the reading bill keeps growing, and learnability will remain the single most important non-functional aspect of code.




I pulled from several other articles in order to write this one:



  1. A Philosophy of Software Design by John Ousterhout

  2. The Magical Number Seven, Plus or Minus Two: Some Limits on our Capacity for Processing Information by George A. Miller

  3. The TL;DR Rule: How I structure Files to Not Annoy My Team by Doogal Simpson