
Cognitive Load Is the Architecture Metric I Actually Care About
I like architecture discussions, but I’ve learned to be careful with them. They don’t always lead to a clear solution.
At some point, the words start losing their meaning. Clean architecture, modularity, separation of concerns, coupling, cohesion, maintainability. They are all useful words, but they are also easy to hide behind.
Two people can look at the same code and use the same vocabulary to defend completely different designs. One person’s clean separation is another person’s unnecessary indirection. One person’s good abstraction is another person’s debugging nightmare.
Recently, I started using a simpler question when I look at code:
How much do I need to keep in my head to change this?
That question has started to matter more to me than whether the architecture looks elegant from far away. Because at the end of the day, architecture is not only about dependency direction or layer boundaries. It is also about how much active mental effort the system requires from the people working inside it.
This is why cognitive load started to feel like the architecture metric I actually care about.
Reading code is the work
We spend much more time reading code than writing code.
Even when I am implementing a feature, most of the time is not spent typing. It is spent understanding what already exists.
- Where does this data come from?
- Who owns this state?
- Why does this mapper exist?
- Is this callback called once or multiple times?
- Can this function be called before initialization?
- If I change this, which other screen breaks?
All of these questions consume working memory.
Some of that is unavoidable. If the domain is complex, the code will be complex too. Payment flows, offline synchronization, lifecycle problems, personalization, distributed state, permissions, analytics. These are not easy problems. We should not pretend they are.
But sometimes the domain is not the only source of difficulty.
Sometimes we make the problem harder by the way we structure the solution.
We add a layer because it feels cleaner. We extract an abstraction because there are two similar-looking functions. We split a flow into tiny pieces and then wonder why debugging requires twelve file jumps. We hide business rules behind framework magic. We create generic solutions before we understand the second use case.
The result looks architectural from a distance, but feels heavy when you need to change it.
I have worked on flows where understanding a single button click required tracing:
- UI state mapping
- ViewModel events
- reducers
- use cases
- delegates
- analytics wrappers
- repository calls
- lifecycle-aware side effects
- feature flag checks
None of those pieces were individually difficult.
The difficulty came from needing to simulate all of them together just to answer a simple question:
Why did this button stop working?
That kind of complexity is easy to normalize once you are familiar with the codebase. But it still consumes attention every time someone touches the system.
Not all complexity is equal
A distinction I found useful is intrinsic complexity vs extraneous complexity.
Intrinsic complexity is the real difficulty of the problem. If the business rule is hard, it is hard. If the product has many edge cases, the code needs to represent those edge cases somehow.
Extraneous complexity is the difficulty we add on top. Naming, layering, framework choices, abstractions, folder structures, implicit conventions. These can help, but they can also make the problem harder to understand.
Good architecture does not remove intrinsic complexity. It gives that complexity a better shape. Bad architecture adds more things to remember.
This is the part I care about. I don’t expect a complicated screen to become trivial. But I also don’t want to jump between a ViewModel, three use cases, two mappers, a delegate, a base class and a provider just to understand one rule.
At that point, I am not solving the product problem anymore. I am solving the codebase problem.
Abstractions should let me forget things
I don’t think abstractions are bad. Obviously, we need them.
But I think I used to judge abstractions too much by how nice they looked and not enough by what they allowed me to forget.
A good abstraction hides details that I don’t need right now. It gives me a smaller surface area. It lets me reason about the current task without loading the whole system into my head.
A bad abstraction gives a name to some code, but still requires me to understand everything underneath it.
This is why reducing lines of code is not always the same thing as reducing cognitive load. Sometimes duplication is cheaper than a shared abstraction. Especially when two things look similar today but are likely to change for different reasons tomorrow.
I have seen this many times.
We extract a shared helper. Then one caller needs a small difference. Then another caller needs a flag. Then the helper gets a mode. Then it gets optional callbacks. Then every user of the helper has to understand every other user.
We removed duplication, but we created a new mental model. That is not always a good trade.
A small example:
fun trackEvent(event: Event, screen: Screen, addUserProperties: Boolean) { val properties = mutableMapOf<String, String>() properties["screen"] = screen.name
if (addUserProperties) { properties["user_type"] = userProvider.currentUser.type properties["is_logged_in"] = userProvider.currentUser.isLoggedIn.toString() }
analytics.track(event.name, properties)}This starts innocently.
Then another caller needs campaign properties. Another one needs experiment properties. Then we add another boolean. Then the helper becomes a small configuration language.
trackEvent( event = ProductClicked, screen = ProductDetail, addUserProperties = true, addCampaignProperties = false, addExperimentProperties = true,)At this point, the caller needs to understand the helper’s internal modes.
The abstraction reduced duplicated lines, but it increased the number of things I need to remember.
Sometimes this is easier to read:
analytics.track( name = ProductClicked.name, properties = productClickProperties(product, user, experiment),)The duplication did not disappear completely.
But the concept is now closer to the thing I am actually doing.
The common path should be boring
The common path should be very easy to see.
This sounds obvious, but it is surprisingly easy to lose.
A function gets one more condition. A class gets one more responsibility. A module gets one more exception. After a while, the happy path is hidden between guards, callbacks and special cases.
I don’t think every piece of code needs to be extremely short. Sometimes a longer function with clear steps is easier to understand than five tiny functions with clever names. Named intermediate variables are often good. Early returns are often good. Boring domain names are good. Explicit values are good. A little bit of repetition can be good.
The goal is not to write verbose code. The goal is to avoid making the reader simulate the program in their head for no reason. If I need to remember five conditions at the same time to understand the happy path, the code is already making me work too hard.
For example, I prefer this kind of shape most of the time:
fun submitOrder(order: Order) { if (!order.isValid()) return if (!networkMonitor.isOnline()) return
val request = order.toRequest() orderRepository.submit(request) analytics.track(OrderSubmitted(order.id))}Instead of this:
fun submitOrder(order: Order) { if (order.isValid()) { if (networkMonitor.isOnline()) { val request = order.toRequest() orderRepository.submit(request) analytics.track(OrderSubmitted(order.id)) } }}The second version is not terrible.
But the first version lets me discard invalid paths immediately. I can focus on the normal flow instead of mentally carrying multiple nested conditions forward. This is a very small thing. But codebases are mostly made of small things.
Familiar does not mean simple
A codebase can feel simple because you already know it.
You know the weird lifecycle edge. You know which module is allowed to call which API. You know why a class has a strange name. You know that one function should not be called before another function. You know the conventions because you were there when they were created.
Then someone new joins the team and gets confused.
It is tempting to think they are just inexperienced. And of course, some of that is true. Every codebase has a learning period. But newcomer confusion is also a signal. It reveals the parts of the system that depend on oral tradition. It reveals which patterns are invisible from the code itself. It reveals where the team moved complexity from working memory into long-term memory and then forgot the complexity still exists.
This is why I think juniors and newcomers can be extremely valuable in architecture discussions.
They may not know the whole history, but they can still feel the cognitive load more clearly than the people who have adapted to it.
Layers are not free
I like layered systems when the layers genuinely mean something.
A layer is useful when it protects domain logic from framework details. It is useful when it creates a testing seam. It is useful when it prevents unrelated parts of the system from depending on each other. It is useful when it hides substantial complexity behind a small interface.
But a layer that only forwards data with different names is suspicious.
Every new layer creates another place to look. Another concept to name. Another boundary to remember. Another mapping to maintain. Sometimes we think we are separating concerns, but we are actually spreading one concern across many files.
That is usually worse.
When debugging a simple issue requires walking through many shallow layers, the architecture is not helping me. It is slowing me down in a very respectable way.
API design is cognitive load design
This also applies to APIs.
A good API makes the correct thing feel natural. It should not require me to understand the internals before I can do something simple.
When an API feels painful, it is not always because functionality is missing. Sometimes the functionality exists, but the API exposes the wrong mental model. If I need to register multiple things in the right order, remember hidden lifecycle rules, or manually connect pieces that always belong together, the API is leaking internal complexity.
A better API does not only reduce boilerplate. It reduces the number of decisions I need to make before I can express my intent. This is something I have been thinking about a lot while working on library code.
The implementation can be technically correct and still feel exhausting to use. That exhaustion is useful information. It usually means the user is carrying too much of the library’s model in their head.
One example is a transformer-style API.
The older style can be powerful, but still have too many ways to define the same thing. You can override handlers, add handlers later, update them, register computations separately and compose router inputs manually.
A transformer could look like this:
class CounterTransformer : Transformer() { private var count = 0
override val handlers = handlers { onSignal<Increment> { count += 1 send(CounterData(count)) } }}This is fine in isolation.
The problem appears when the transformer also owns state, computations, executions and lifecycle behavior. The definition becomes spread across override points and helper calls. The user has to remember which APIs replace existing definitions and which ones append to them.
A simpler direction is to move toward one canonical composition block:
class CounterTransformer : Transformer() { private val state = dataHolder(CounterData(0), counterDataContract)
init { configure { onSignal<Increment> { state.update { data -> data.copy(count = data.count + 1) } }
computation(counterDataContract) { state.getValue() } } }}Or for smaller cases:
val counter = transformer { val state = dataHolder(CounterData(0))
onSignal<Increment> { state.update { it.copy(count = it.count + 1) } }}The runtime model is not radically different. The cognitive load is. There is one place to look. The API communicates a simpler rule:
Define the behavior of this transformer here.
The same thing applies to router setup. This is technically clear:
val router = TransmissionRouter { addTransformerSet( setOf( CounterTransformer(), LoggingTransformer(), ) )}But this carries implementation detail into the call site. Why do I need to think about Set here?
This reads closer to the intent:
val router = TransmissionRouter { transformers( CounterTransformer(), LoggingTransformer(), )}Small API choices like this matter.
They decide whether the user thinks about their feature or about the library’s internal wiring.
Questions I want to ask more often
Instead of only asking whether code follows a certain pattern, I want to ask questions like:
- What does a developer need to keep in mind to change this?
- Does the common path stand out?
- Are we hiding complexity or just moving it somewhere else?
- Would a newcomer know where to start?
- Does debugging require too many file jumps?
- Are the names doing enough work?
- Is this abstraction based on a real concept or just similar-looking code?
- Are we making the future easier, or just making the current code look cleaner?
These questions are useful because they make architecture discussions less aesthetic.
Instead of saying
This feels cleaner
I can say:
This version requires fewer hidden rules to understand.
That is a much stronger argument.
Useful simplicity
I do not want to turn this into a simplistic argument against abstractions, patterns or frameworks.
Some problems need structure. Some boundaries are important. Some abstractions are worth it. Some patterns give teams a shared language. I am not arguing for primitive code.
The point is different.
We should spend our mental energy on the real problem:
- the domain
- the product behavior
- the edge cases
- the user experience
- the failure modes
Everything else should be as boring as possible. Good architecture does not impress me by having many boxes in a diagram. It helps me ignore the things I do not need right now. That is why cognitive load feels like the metric I actually care about.
If a design leaves more mental space to solve the real problem, it is probably good. If a design requires everyone to memorize more local rules, mappings, lifecycle edges and indirections, it is probably making the system harder to change, even if it looks clean from far away.
The best architecture is not the one that looks the most elegant.
It is the one that makes the next change easier to understand.
Tags: software , architecture , productivity
Share this article