Cognitive Load in Software Architecture
Introduction
The psychologist John Sweller was studying problem-solving in the 1980s when he made a surprising discovery. He found that people have a limited capacity for processing information. When they are presented with too much information at once, they become overwhelmed and have difficulty solving problems. This phenomenon is known as cognitive load. His main focus was on the cognitive load of students in the classroom, but the concept has since been applied to many other fields, including software architecture.
Cognitive load is one of the main limiting factors in software development. It affects how quickly developers can learn new concepts, how easily they can understand existing code, and how well they can solve problems. By understanding cognitive load and how it affects software development, architects can design systems that are easier to understand, maintain, and extend.
Short-Term Memory Limitations
The exact limits of short-term memory are still a matter of debate among psychologists, but most agree that it is limited to around 7 ± 2 items. This means that people can only hold around 5 to 9 pieces of information in their short-term memory at once. When they are presented with more information than this, they become overwhelmed and have difficulty processing it.
Think about the last time you read a piece of complex code. Did you have to keep scrolling up and down to remember what each variable was? Did you have to draw a diagram to understand how the different parts of the code interacted? This is a sign that the code was overwhelming your short-term memory. You were trying to hold too much information in your head at once, and it was slowing you down.
Some cognitive load is unavoidable. There is intrinsic complexity in the problems that we're trying to solve, and we can't eliminate it entirely. But there is also extraneous complexity that we can eliminate. This is the complexity that is introduced by the way that we design our systems. By reducing extraneous complexity, we can make our systems easier to understand and maintain.
Sources of Extraneous Cognitive Load
Deep or Inconsistent Nesting
Each level of nesting that you descend into adds another layer of complexity. You have to keep track of where you are in the code, what each level of nesting does, and how they all fit together. This can have a significant impact on cognitive load.
Code that contains inconsistent levels of abstraction is also an issue. It's hard to be certain what dependencies you should be accessing at any given time, and tends to lead to tighter coupling and more fragile code.
Long Methods
Robert Martin, in his book Clean Code, recommends extremely short methods and suggests a hard limit no longer than 20 lines. Personally, I think that's a bit too impractical, but the idea is sound. Long methods are a significant source of cognitive load. Longer methods simply require us to track more state in our heads. We have to remember what each variable is, what each subroutine does, and how they all fit together. This can be overwhelming, especially when the method is complex and convoluted.
While a hard limit isn't always practical, it's a good idea to keep methods relatively short. Certainly small enough that the entire method can fit on a single screen.
Tiny Modules or Methods
Just like with long methods, tiny modules or methods can also be a source of cognitive load. When developers are presented with a large number of tiny modules or methods, they have to spend time figuring out how they fit together and what each one does. You might find yourself flipping back and forth between different files, trying to piece together the overall structure of the system.
This difficulty is why it's better to have deep modules with narrow interfaces than shallow modules with wide interfaces. Deep modules encapsulate more functionality. They provide a higher-level view of the system, which makes it easier to see how the different parts fit together. Wide interfaces, on the other hand, expose more details, which can be overwhelming.
Complex Conditionals
Mentally parsing complex conditionals can be a significant source of cognitive load. When developers are presented with a long, convoluted conditional, they have to spend time figuring out what it does and what states reach each branch.
Introduce Intermediate Variables
Consider a conditional like this:
if (value > 100 &&
(user.isMember || user.isGuest) &&
(item.isEligible && !item.isOutOfStock)) {
// Do something
}
This conditional is hard to understand at a glance. It mixes different boolean operators, making it difficult to interpret. By introducing intermediate variables, you can break it down into smaller, more manageable pieces:
val itemExceedsValue = value > 100
val userIsValid = user.isMember || user.isGuest
val itemIsAvailable = item.isEligible && !item.isOutOfStock
if (itemExceedsValue && userIsValid && itemIsAvailable) {
// Do something
}
Extract Methods
We could also extract the conditional into a separate method:
if (isEligibleForDiscount(value, user, item)) {
// Do something
}
Use Guard Clauses
As already mentioned, deeply nested code is harder to understand. One way to reduce nesting is to replace nested conditionals with guard clauses. Guard clauses are early returns that check for exceptional cases and return immediately if they are met.
Starting with something like this:
fun isAuthorized(user: User, action: Action?): Boolean {
if (user.isAdmin) {
if (action != null) {
return false
} else {
return true
}
} else if (user.isMember) {
if (action == Action.READ) {
return true
} else {
return false
}
} else {
return false
}
}
We can refactor it to use guard clauses and direct returns:
fun isAuthorized(user: User, action: Action?): Boolean {
if (user.isAdmin) {
return action == null
}
if (user.isMember) {
return action == Action.READ
}
return false
}
The advantage for cognitive load is that once you pass the guard clauses, you know that the condition is met and you don't have to worry about it anymore.
Clever Code
Programmers are often tempted to write clever code. They want to show off their skills and impress their peers. But clever code is often hard to understand. It takes time to parse mentally, and it can be difficult to debug and maintain. Clever code is a significant source of cognitive load. It forces developers to think harder about what the code is doing and how it works. This can slow them down and make it harder for them to solve problems.
Certainly programming languages tend to be more prone to clever code than others. These languages sometimes even encourage write-only code, and provide language features that make it easy to write code that is hard to understand. But clever code is a problem in any language. It's a sign that the developer is more interested in showing off than in writing code that is easy to understand and maintain.
That particularly opaque line of code is a string copy in C:
while (*dest++ = *src++);
Unless you're familiar with the idiom, it's not immediately clear what it does. It's clever, but it's also hard to understand. It relies on the side effects of the assignment operator to work, which is a subtle and non-obvious feature of the language, and relies on the convention that strings are null-terminated, which also halts the loop.
Strive to make your code as clear and straightforward as possible. Don't try to be clever. Don't try to show off. Write code that is easy to understand and maintain. Your peers and your future self will thank you.
Interrupted Flow
Most developers prefer to work in long, uninterrupted blocks of time. This allows them to get into a state of flow, where they are fully focused on the task at hand and can make rapid progress. But this state is fragile. It can be broken by interruptions, such as meetings, emails, or instant messages. When developers are interrupted, they have to switch contexts, which can be jarring and disruptive. It takes time to get back into the flow, and this can slow down their progress.
Their short-term memory gets dumped, and they have to reload it with the information they were working on before the interruption. This can be a significant source of cognitive load. It's like trying to solve a puzzle with half of the pieces missing. You have to spend time figuring out where you left off and what you were trying to do.
Conclusion
There are many more sources of cognitive load in software development, but these are some of the most common. By understanding how cognitive load affects software development, you can design systems that are easier to understand, maintain, and extend.
This article by Artem Zakirullin provides a good overview of the topic, and goes over some things that I didn't cover here. I recommend checking it out if you're interested in learning more.