Controlling Complexity
Introduction
Real-world software systems are much too complex to be understood all at once. They are made up of thousands or millions of lines of code, written by many different people over a long period of time. Each system is unique, with its own set of requirements, technologies, and constraints. There will be interactions with hardware, other systems, and with the physical world. They easily exceed the limits of human comprehension.
In order to create systems that are reliable, maintainable, and scalable, software designers must manage this complexity. This is the primary goal of software design. Complexity is the enemy of software maintainability, and it is the primary reason why software projects fail.
Systems that allowed to accumulate complexity over time become progressively harder and harder to maintain, until they reach a point where they are no longer viable. They will either be replaced by a new system, or they will be abandoned altogether.
We tend to talk about complexity as if it was our enemy, but it is a necessary part of software. There is inherent complexity that must exist in order for our system to solve real problems. The goal of software design is not to eliminate all complexity, but to keep it under control. We want to make sure that the complexity in our system is necessary and that the system can be understood.
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 we am using the term. Complexity in software design is a description of how difficult it is to understand 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 and concentrate on a tiny piece at a time. 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 in order to make any progress.
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.
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 details. It is very good at finding patterns and making assumptions. As you work on a system, you will get into a flow, and the complexity of the system will fade into the background.
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 until you revisit the code much later. 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 tend 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.
This subjective approach is extremely valuable and is the reason we talk about managing complexity rather than eliminating it. We can't eliminate complexity, but we can reduce the impact it has on our work.
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. These consequences can manifest in a number of ways. Ousterhout gives three main symptoms of complexity, change amplification, cognitive load, and unknown unknowns.
New developers tend to be much worse at managing complexity than experienced developers. It is consistently the largest difference between the two groups.
The main reason for this is that new developers have never suffered the consequences of complexity. They have never had to maintain a large system that is difficult to understand, or spent hours tracking down a problem that should have been obvious. Their experiences have been restricted to small projects that are easy to understand.
Most developers only need to experience the consequences of complexity once before they start to take it seriously. Until then, they will be much more likely to make decisions that increase complexity. Controlling system complexity requires more coding and time, and new developers don't understand why that extra time is worth it.
Change Amplification
Complex systems are more difficult to maintain and evolve. New features take longer to implement and bugs are more likely to be introduced during that process. For example, if the complexity comes from tight coupling then a simple change can require changes in many places.
Regardless of the source of the complexity, changes will always be harder to make safely in such a system. In order to make changes safely, you need to understand at least some of the system. If the system is complex, then you need to understand more of the system. Without clear modules we can't work with isolated portions of the system, we need to understand the entire system. This is known as change amplification. It is a sign that the system is too complex.
In poorly designed systems, changes can have a cascading effect. A common example is when a data model defined as part of an API, or database, 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.
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, or some future developer, make a guess about how the system works, it 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.
Deep Nesting
Deeply nested code is harder to understand. This is especially true if the nesting is not consistent. Each level of nesting is another thing that you need to keep in your head in order to understand the code. If the nesting is inconsistent, then you can't make assumptions about how the code works.
Take a look at the leading whitespace in your code. If it makes a jagged pattern, then you probably have deep nesting. This is a sign that the code is too complex.
Direct Calls to External Dependencies
Any time you allow your code to directly interact with external dependencies, you are increasing complexity. This increases the coupling between your codebase and a system that you don't control. This makes it harder to test your code, and harder to maintain your code. If the external dependency changes, then your codebase is in trouble.
Complexity is Incremental
A complex system rarely comes about as the result of some terrible decision. It accumulates over time. The analogy I like best is that 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 fix 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.
Conclusion
Complexity is the enemy of software. Our objective as developers is to keep the system as simple as possible. This is not easy. It requires constant vigilance and a willingness to make hard decisions. It is much easier to add complexity than to remove it. It has a sort of gravity, complexity attracts more complexity. A system that is already complex is more likely to have complex features added to it.
Be vigilant. Control the complexity of your system. It is the most important thing you can do to ensure the long term viability of your project.