We would all like to work on code that is easy to read and understand, but achieving such simplicity is not always easily done. This is because unnecessary code complexity can creep up on us without anybody noticing. In this article, I will define complexity and try to make it a less abstract concept, so that steps can be taken to avoid it.
After working on many projects over the years, I’ve noticed that projects tend to start with simple code. The team working on it then goes on to add a few extra lines of this ‘simple’ code each week until, eventually, an incredibly complex system emerges. The team doesn’t notice the growing complexity because the codebase is familiar to them as they have been working on it daily. At some point, the team will start calling the system ‘complex’, but at this stage the complexity has been growing for a long time. The evolution from a simple codebase to a complex one can be confusing, and many teams don’t know where to begin unravelling it.
Because a person can only keep track of a small number of things in their mind at a time, it is natural that the various moving parts of big systems quickly become too much to remember. When developers lose context, due to the complexity of the system, it can often result in bugs, poor performance or additional complexity.
The key to avoiding complexity is identifying it quickly. If you can do this, you can take steps to keep your code easily readable, maintainable and understandable.
What makes a system complex?
A system’s complexity can be judged briefly by how easy it is for someone to read, understand and keep track of. The following concepts are what I consider to be the core cause of code complexity:
- Irreversible decisions
- Dependencies and side effects
- Lack of clarity and visibility
Let’s take a look at each of these elements, as well as measures that I’d recommend taking to avoid any complexity that they may bring.
A project increases in complexity when there are many parts that cannot be changed. An irreversible decision is one that is so expensive to reverse that the code may as well be rewritten, kind of like when a car is written-off – you could repair it but it’s cheaper to just buy a new one. Often, you can’t reverse decisions that are made about things like the programming language used, the database, third party packages, public models, exposed schemas, and the communication method between services.
While irreversible decisions are not always necessarily bad decisions, and may be something you never want to reverse anyway, such as the programming language you’ve used, sometimes what is a good decision now may become a poor decision later. In these cases, if it is highly coupled into the system it becomes a problem. All future decisions are limited by these irreversible decisions which, in turn, create rigid structures that devs have to ‘hack’ around. Once a dev has been coded into a corner by excess irreversible decisions, the workarounds inevitably add extra complexity to the matter.
Complexity Smells: Things you might hear that indicate that an irreversible decision is causing complexity
“This is just a temporary fix. We will do it properly later.”
- When you have to make temporary fixes or workarounds it’s a sign that the system may be too rigid to accommodate change.
“This will never change.”
- Building a system while making assumptions that certain parts will never change, is the first step to complexity, as this mindset leads to building inflexible systems. You should rather just assume change will happen.
“We can’t change that because something else is dependent on it.”
- Tightly coupling code leads to rigidity, which is a decision that may eventually become irreversible.
Low Coupling is key
To be agile and have the ability to easily change and adapt to new requirements, a system should have minimal irreversible decisions. Good design reduces the amount of irreversible decisions that are needed, allowing for future changes – even though you don’t know what they will be. If you can not avoid an irreversible decision, then strong design will allow for the decision to be deferred to the last responsible moment.
The key to achieving flexibility is decoupling your design. Some great examples of things you can look at to improve your design and coding processes, are:
- SOLID principles – a set of principles to guide maintainable code.
- Onion architecture – an architecture to move focus towards the business logic and away from the technologies used.
- Test Driven Development – a way of working iteratively, which requires using decoupled code for testing.
- Lean Software Development: An Agile Toolkit – a book that describes some processes to help you build software in an agile fashion.
Move fast and take action on reversible decisions, but take time and plan around irreversible decisions.
Dependencies and side effects
The more dependencies and side effects there are in a project, the more complex the system is going to be. To get an idea of how dependencies and side effects can cause complexity, consider a simple static HTML site with no functionality, versus a complex application that is built on a framework and includes multiple packages, communicates with 30 other applications over HTTP and AMQP, logs data to files and persists data to a database.
Clearly the static HTML site, which has no dependencies or side effects, is far more simple. The complex application, on the other hand, has a lot going on that developers need to understand and keep track of.
More often than not, as developers, the applications we work on have requirements that cause us to keep adding dependencies and side effects until it looks like the complex application described above. So, we need to learn how to handle this.
Complexity Smells: Things you might hear that indicate that dependencies have got out of hand
“We have to make sure our packages are on specific versions otherwise we have conflicts.”
- This may be a sign that you have too many dependencies or aren’t handling dependencies in a clear fashion.
“Which table does this application read from?” or “What depends on this module?”
- When devs are struggling to understand the dependencies of an application, it’s probably too complex.
“There is something wrong with the state…”
- When devs are struggling to understand or manage the side effects of an application, it’s also probably too complex.
Always aim for minimalism
Only add further dependencies and side effects if you really_ really_ need them and only when you need them!
Having a lot of dependencies and side effects makes a system complex, and, if they are unclear or scattered around, it becomes even more complex. For example, it may seem like a good idea to have a repository class hold all of its own settings as well as everything else it needs for its dependencies, including the database’s information and the table it will be using, but problems will inevitably occur. When I tried to do this, I experienced difficulty when I attempted to move the application to a new environment or rewrite it and had to go through 500 classes to see what was needed to do this.
What I’ve learned to do now is to find a way to get dependencies to bubble up to one place so that they are all visible. For example, I use the Dependency Inversion Principle with a DI container, and put more detail into the configs so that I can see all the dependencies in the DI container and the config file.
The key to simplifying dependencies and side effects really is minimalism and visibility. If possible, always aim to leave them out but if you can’t, make sure they are very visible.
Here are some great examples of things you can look into to help simplify dependencies and side effects:
- Command Query Separation(CQS): this stresses the importance of clarifying which methods have side effects and which don’t (this is not to be confused with CQRS.)
- Idempotency: this helps you understand the side effects of calling the same method multiple times.
- YAGNI: ‘You Ain’t Gonna Need It!’ is a principle about not adding features that are not required at the moment.
- Single Choice Principle: this states that, “the exhaustive list of alternatives should live in exactly one place”. While you may not be dealing with alternatives, keeping things in one place greatly improves maintainability.
Lack of clarity and visibility
It is important for a dev to understand the current system that they’re working on, so that they can add features or modify code easily. When there is a lack of clarity around how a system works, there is inevitably going to be an increase in the time required to make changes to the system.
Common solutions to this problem include asking devs to write documentation and code comments. But, I don’t like this as a solution because it requires more time on an already time-hungry problem. This is because devs have to maintain these documents separately from the code and, let’s be honest, how often is documentation actually accurately maintained?
This also falls under the DRY principle which states that “Every piece of knowledge should not be duplicated”, and documentation is knowledge. Also, I find that some documentation is as difficult to understand as the code itself.
Complexity Smells: Things you might hear that indicate that the system is confusing or unclear
“What does this do?” or “What does that acronym stand for?” or “How do you spell that?” or “Why is this spelt differently here?” or “What does this mean? Am I missing a joke or something?”
- The name used may be a non-descriptive one, making it more difficult to read and understand the system.
“What are the dependencies of this app?” or “What does this app do?” or “What is the public contract of this app?”
- The application lacks clarity and visibility around its configuration.
“I didn’t know that functionality was there.”
- The service/module/class may be doing too much or its name may not accurately describe its bounds.
Clarity, clarity, clarity!
Instead of putting so much effort into documentation, I rather focus on code readability, because unlike documentation, code never lies. Developers should always be aware that documentation and comments may be incorrect, whereas code always shows exactly what the system does. Therefore, readable code cannot be reliably replaced by documentation. Clean code also means saving time on implementing the actual changes, which then doesn’t have to be spent writing extra docs.
If the code in front of you is clear, you’ll be able to see exactly what it’s trying to do, and how it does it. This means that there is a reduced need for comments or external documentation to understand it.
At the end of the day, if your code can’t speak for itself, then it is probably too complex and should be simplified.
Here are some simple ideas to help you keep clarity and visibility in your code:
- Meaningful Names: Don’t name services like ‘YoDawg’. This makes it difficult to understand what the service does. Rather, choose a clear name like ‘AuthorisationService’ that makes what it does obvious.
- Choose a name that conveys functionality: If a service contains functionality, which does not fit within the service name’s expected bounds, it will increase the complexity around understanding the system. For example, if our “AuthorisationService” authorises users, but also has the extra functionality of emailing invoices to users, this makes it difficult for everyone to keep track of what the service does, and the extra functionality will often be forgotten about. To fix this, you can pull the invoicing functionality into a separate service named ‘InvoiceService’, so that ‘AuthorisationService’ does authorisation and the ‘InvoiceService’ does invoices. This creates clear visibility and bounds on the functionality of the service.
- Build living documentation: Living documentation must either automatically change as the code changes, or be forced to change as the code changes. You may not think of the following suggestions as documentation, but I found that changing my mindset around them led to improved clarity and visibility. I use these suggestions as a source of truth:
- Tests, such as unit tests, integration tests, UI tests, contract tests, etc. all provide living documentation for systems. When unit tests are written in a way that shows how classes should be used, it makes the application more clear overall.
- Swagger provides a clear public interface to the system’s end points.
- Config files can provide more clarity into the system’s dependencies and public interfaces/boundaries. For example, if the db connection string is the config file, then it’s clear that the application has a dependency on a database.
- CI/CD files or Docker files with env vars and exposed ports, can provide more clarity into the system’s dependencies and public interfaces/boundaries.
Keep It Simple
A principle which greatly influences my software design decisions is Keep It Simple Stupid, or KISS. Simplicity is important because, essentially, we write code for humans to understand. All code eventually results in binary but, we write it in languages that are easier for humans to understand, even if it means sacrificing some efficiency along the way.
Robert C. Martin made an important point when he said, “Indeed, the ratio of time spent reading versus writing is well over 10 to 1.” Since developers spend so much of their time reading code, improving simplicity greatly improves productivity. Taking steps toward simplicity must be an active decision, where KISS is given careful consideration in planning and designs.
Brandon Pearman is a Software Engineer currently working in Cape Town. He has a broad understanding of different technologies but his focus is on software design, architecture, distributed systems and code quality. He posts content on his personal site which you can check out here..