Skip to main content

Understanding Complexity

Introduction

Data Complexity Icon

The heart of all software design is managing complexity. If you can understand all parts of a system, then you can probably implemented it without much difficulty. This implies that your objective when designing a system is to make it as easy to understand as possible.

Unfortunately even the most basic real world systems are too complicated to be fully understood. The human brain can only hold so much information at once. This is known as cognitive load. The more information you have to hold in your head, the more likely you are to make mistakes. Most of the strategies for managing complexity are really strategies for managing cognitive load. Loose coupling, abstraction, encapsulation, and modularity allow you to ignore parts of the system that are not relevant to the problem you are trying to solve.

Common non-functional requirements such as performance, scalability, and security all become simpler if you can manage the complexity of the system. If you can understand how the system works, then you can understand how to make it faster, how to make it handle more users, and how to make it more secure.

Everything about a software system is easier if you can manage the complexity. Simpler systems are even easier to validate and verify. You can write tests that cover all the important parts of the system. You can use static analysis tools to find bugs before they become a problem. You can use monitoring tools to find problems before they become bugs.

Unfortunately, as a program evolves and new features are added, it always becomes more complex. Subtle dependencies between components are introduced. Complexity accumulates making it harder for programmers to understand the system. New feature become more difficult to implement and bugs become more difficult to fix. If we want maintain the pace of development, we need to actively strive to make the software simpler. Complexity will always increase over time, but simpler designs will build larger systems before they become unmanageable.

What is Complexity?

The term complexity is used a lot in computer science. It is often used to describe the difficulty of a problem. A problem is said to be complex if it is difficult to solve. This is not the sense in which I am using the term. I am using the term to describe the difficulty of understanding a system.

There are many objective definitions of software complexity. One possible definition would be:

The number of elements the system contains, the number of relationships among these elements, and the degree of heterogeneity of the elements and their relationships.

This makes some sense, but it is not very useful. A strict measurement of a system doesn't tell us much about how easy the system is to work on or extend. A system could contain a huge number of components and relationships, but if the system is well designed I might be able to ignore most of them. A system could contain a small number of components and relationships, but if the system is poorly designed I might have to understand all of them.

I prefer to use a more subjective definition of complexity. In A Philosophy of Software Design, John Ousterhout defines complexity as:

Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.

This definition is much more useful in practice. It describes complexity almost as a disease affecting your code. Something that can be identified and treated. It is not a measure of the system, but a measure of how difficult it is to work on the system.

It is important to recognize that systems are never uniformly complex. Some parts of a system will always be more complex than others. If those portions are rarely changed, then the system will not feel complicated when you're working on it. If those portions are changed frequently, then the system will feel very complicated.

Strong communication and documentation can act as a buffer against complexity. If you can't simplify a complex part of the system, you can at least make sure that everyone understands it. This is not ideal, but it is better than nothing. It is always better to simplify the system if you can.

Why is Managing Complexity Difficult?

Managing complexity in software systems is hard for several reasons:

  1. Complexity Grows Gradually Most complexity doesn't arrive all at once—it accumulates slowly over time. Small design compromises, shortcuts, and unclear abstractions seem harmless in isolation, but they compound. By the time it becomes a problem, it’s often deeply embedded.
  2. You Can't See It Easily Unlike bugs, complexity doesn't crash the system or throw errors. It silently increases the effort required to understand, modify, or extend the code. It’s invisible in test results and hard to detect with metrics.
  3. Short-Term Pressure Favors Complexity Teams are often under pressure to ship quickly. In the short term, it's faster to "just make it work" than to design thoughtfully. But those quick fixes add hidden costs that surface later as technical debt.
  4. Complexity Hides in Interactions Even when individual components are simple, the ways they interact can create surprising complexity. Dependencies, shared state, and implicit assumptions all contribute to a system that behaves in unpredictable ways.
  5. No Single Owner Understands Everything As systems grow, no one person can hold the full architecture in their head. Without strong design principles and documentation, knowledge becomes fragmented and complexity spreads.

Recognizing Complexity

The first step in managing complexity is recognizing that it exists. This is not always easy. The human brain is very good at ignoring complexity. It is very good at finding patterns and making assumptions. This is why it is so easy to write code that is difficult to understand. You are not aware of the complexity that you are creating. It is much easier to see the complexity in someone else's code than in your own.

Subjective Complexity

Ousterhout uses a very subjective approach to recognizing complexity. A system is complex if you find it hard to understand or modify. Some signs that a system is complex include:

  • You have to read a lot of code to understand a small change.
  • You have to make changes in many places to make a small change.
  • Fixing a bug often introduces other bugs.
  • You are afraid to make changes because you might break something.

In all of these cases, complexity is based on what the developer experiences while trying to achieve some goal. It doesn't relate to the size of the project or the number of components. It is about how difficult it is to work on the system. In practice, all large systems tends to be complex and difficult to work on, but that doesn't need to be true.

If the complexity is based on the experience of the developer, then it is influenced by how frequently a portion of the code changes. If a portion of the code changes frequently, then the complexity of that portion will be more apparent and should have a bigger impact on our decisions. This means that isolating complexity into parts of the system that are more stable will make the system feel simpler and is almost as effective as eliminating that complexity entirely.

Objective Complexity

There have been many attempts to create more objective measures of complexity. One of the most famous is cyclomatic complexity. Cyclomatic complexity is a measure of the number of linearly independent paths through a program's source code. It is a measure of the number of decisions that need to be made to understand a piece of code. The higher the cyclomatic complexity, the more difficult the code is to understand. Putting hard limits on cyclomatic complexity can be a useful way to identify bad code. As a general tool for managing complexity, it is less useful. It lacks nuance and can be easily gamed. It also doesn't take into account the experience of the developer.

An alternative approach to recognizing complexity is given in Your Code as a Crime Scene by Adam Tornhill. Rather than use a single measurement of complexity, Tornhill offers a set of strategies for identifying complex portions of the codebase. He also offers ways to tell if those portions are likely to be the source of defects in the future. The approach he uses is language agnostic and is based on a combination of version control data and static analysis. I'll be using a few of his strategies but encourage you to read the book for yourself.

Consequences of Complexity

A complex systems is a drain on developer time and energy. We suffer when working on a system that is difficult to understand and are more likely to make mistakes or introduce bugs. This can manifest in a number of ways. Ousterhout gives three main symptoms of complexity, change amplification, cognitive load, and unknown unknowns.

Change Amplification

When a simple change requires changes in many places, this is a sign of complexity. This is known as change amplification. It is a sign that the system is not well modularized.

A common example of this is when a magic number of string is used in multiple places. If that number or string needs to change, then it needs to be changed in multiple places. We would need to hunt down all the places where it is used and make the change. If the value is difficult to search for with automated tools then we're likely to miss some of the places where it is used. The obvious solution is to create a constant or a variable that holds the value and use that everywhere instead.

Another example is when a data model defined is directly used throughout the system. If the data model changes, then the entire system needs to be updated. This is a sign that the data model is not well encapsulated. We should be able to change the data model without changing the entire system. The Ports and Adapters pattern is a good way to achieve this.

A well designed system should allow for localized changes. We should reduce the amount of code that is impacted by some design decision. This will reduce the chances of cascading changes and make the system easier to understand. Many of the principles of software design are aimed at reducing change amplification. Things like the DRY principle, the Single Responsibility Principle, and the Open/Closed Principle all aim to reduce the impact of a change.

Cognitive Load

Complex systems require developers to know a great deal of information in order to complete some task. Higher cognitive load means that developers will need to spend more time learning the system before they can make a change safely. This is a drain on productivity and increases the chance the something will be missed, leading to defects.

Increased cognitive load can come from a wide variety of sources. Complicated interfaces, global variables, and dependencies between modules will all increase cognitive load. In general, anything that forces a developer to look at more code than they need to is increasing cognitive load. If we can allow tasks to be accomplished without looking at the entire system, then we can reduce cognitive load.

It's easy to assume that shorter code would be easier to understand, but this is not always the case. I've worked with code that was very short, but it was very difficult to figure out what that code was trying to do. In most cases this is because the code is leveraging some framework or library. While these can allow for terse code, understanding that code requires a deep understanding of the framework. Optimizing to minimize the amount of typing you need to do will not lead to simpler systems. Simpler systems are usually longer, but that comes with added clarity.

Unknown Unknowns

Complex code contains hidden information. Details are that are not obvious from the code itself. Code changes or extra steps that must be taken in order to complete some task. Ousterhout calls these unknown unknowns. There is something that you need to know, but there is no way for you to find out what it is until there is a problem.

I prefer to think of these as traps left in the code for unsuspecting developers who come later. Subtle pieces of design that are not obvious from the code itself and never documented. No matter how much you study the code, or how careful you are, there is no way to avoid these traps. They are a source of defects and a source of frustration.

Out of all of the consequences of complexity, unknown unknowns are the most dangerous. They are the hardest to identify and the hardest to fix. They are also the most likely to cause defects. If you can't see a problem, then you can't fix it. If you can't see a trap, then you can't avoid it.

A well designed system needs to be obvious. You should be striving to create a system that is easy to understand. If you make a guess about how the system works, you should be right. If you make a change to the system, you should be able to predict the outcome. If you can't do these things, then the system is too complex.

Causes of Complexity

At the highest level, there are two main sources for software complexity: dependencies and obscurity.

Dependency is a little bit of a loaded term. It is common to hear about dependencies in the context of package management or build systems. I'm using it in a more general sense. A dependency is anything that forces you to understand more of the system than you need to in order to complete some task. You can't build software without dependencies, but you can manage them. The goal is to reduce the number of dependencies and to make the dependencies that you do have as obvious as possible.

Obscurity is when important information is hidden from the developer. A simple example would be a bad variable name. If we can't tell what a variable is for, then we have to read more code to understand it. Obscurity can also come from a lack of documentation or a lack of tests. If we can't tell what a piece of code is supposed to do, then we have to read more code to understand it.

Obscurity and dependencies often go hand in hand. Hidden dependencies are a much bigger problem than obvious dependencies. If you can't tell that a piece of code is dependent on another piece of code, then you don't know that you need to understand that other piece of code.

Obscurity can come from inadequate documentation, but it is also a result of poor design. If a system has a clear design, then documentation is less necessary. The best way to reduce obscurity is to make the system simpler, not to add more documentation.

Signs a System is Complex

There are a number of signs that I look for when trying to identify complexity in a system. These are not hard and fast rules, but they are good indicators that a system might be difficult to work on. Most of these are from personal experience, but I've also interviewed other engineers and taken some from various literature.

Inconsistency

If the system is inconsistent, then you can't make assumptions about how it works. This makes it harder to understand and modify.

The most common form of inconsistency is actually code style. If the code is not formatted consistently, then it is harder to read. This is why most companies have a style guide. It is not because one style is better than another, but because consistency is more important than the style itself. This also implies that multiple developers are working on the codebase, which can be a source of complexity in itself. It also implies that code reviews are not being done properly, increasing the likelihood of defects.

Another form of inconsistency is naming. If the same concept is named in multiple ways, then it is harder to understand the code. This idea is at the heart of Domain Driven Design. If you can't agree on the names of things, then you can't agree on how they work.

Lack of Tests

Tests help us to understand how a system works. They can act as a form of documentation. More importantly, good tests will act as a clamp or vice. They allow us to make changes to the system and be confident that the existing functionality will be preserved. If there are no tests, then we have to understand the entire system in order to make a change. This is a huge cognitive load.

A lack of testing also implies a lack of discipline in the development process. If developers are too rushed to add tests, then they are probably too rushed to write good code. Rushed code is more likely to deviate from whatever design principles are in place. This makes the code harder to understand.

Complex code also test to be hard to unit test. Just the act of writing tests can encourage developers to simplify the code.

Complexity is Incremental

A complex system never comes about as the result of some terrible decision. It accumulates over time. The description I like best is the complexity has mass, it attracts more complexity. Little decisions and shortcuts make the system harder to understand, and our tolerance for complexity increases. A system that is already complex is more likely to have complex features added to it.

Hundreds of small dependencies and obscurities build up over time. Each one makes the system a little harder to understand and work on. It's easy to convince yourself that a little bit of added complexity is no big deal, but if every developer thinks that way the system will quickly become unmanageable. Once complexity has accumulated to a certain point, it is very difficult to remove. It is much easier to prevent complexity than to remove it.

Created a well designed systems requires us to have a low tolerance for complexity. We need to be constantly on the lookout for ways to simplify the system.

Complexity in Practice

Complexity manifests in many ways in real-world software systems. Here are some common examples:

  • Tight Coupling: When components are too interdependent, a change in one requires changes in many others. This leads to fragile systems where small modifications can have wide-ranging effects.
  • Poor Modularization: If modules or components are not well defined, it becomes hard to isolate changes. This leads to a tangled codebase where understanding the impact of a change is difficult.
  • Unclear Responsibilities: When components take on too many roles or have overlapping responsibilities, it becomes hard to understand what each part does. This leads to confusion and makes it difficult to reason about the system.
  • Lack of Encapsulation: If internal details are exposed unnecessarily, it increases cognitive load. Developers must understand more of the system than they need to, leading to confusion and mistakes.

Conclusion

Software complexity is one of the most significant factors affecting the long-term health and lifespan of a system. As complexity grows unchecked, it becomes increasingly difficult to maintain, extend, and reason about the codebase. Systems become fragile, change becomes risky, and developer productivity declines. Over time, this leads to higher costs, slower delivery, and ultimately, systems that must be rewritten or abandoned.

Strong design skills are critical in mitigating this outcome. Good design doesn’t eliminate complexity, it manages it. Developers must be able to structure code and systems in ways that isolate changes, reduce cognitive overhead, and make behavior predictable. This requires not only technical knowledge but also the ability to make thoughtful trade-offs and recognize design decisions that have long-term consequences.

Complexity arises from many sources: tight coupling, poor modularization, unclear responsibilities, lack of encapsulation, and hidden dependencies. It also stems from external factors like shifting requirements, team growth, and unclear architecture. Understanding these causes allows developers to take proactive steps, such as applying design principles, documenting assumptions, and emphasizing clarity over cleverness.

In the end, managing complexity is not about perfection, it's about intentionality. With careful design, deliberate practice, and ongoing reflection, we can build systems that stand the test of time and remain a joy to work on.