This article is Part 1 of the “Write Better Laravel” series.
Read Part 2 here.
In order to write better code, we first must understand our enemy.
- What is “complexity”?
- How can you tell if the system is unnecessarily complex?
- What causes the system to become complex?
Once you understand complexity and how to spot it, you will start writing better code that is simpler to understand, use, extend and maintain.
Complexity is anything related to the structure of a software system that makes it hard to understand and modify the system.
Complexity can take many forms. Here are a few examples:
- it might be hard to understand how a piece of code works,
- it might take a lot of effort to implement a small improvement,
- it might not be clear what parts of the system need to be modified to make the improvement,
- it might be difficult to fix one bug without introducing another.
You can also think of complexity in terms of cost and benefit. In a complex system, it takes a lot of work (cost) to implement a small change. In simple systems, event larger changes can be implemented relatively easy.
Complexity does not necessarily refer to the size of the system, but rather to a particular piece of the software that is complex. It also depends on how frequently that part of the system is used; a complicated implementation that is never touched does not add much to the overall complexity of the system.
It’s worth noting that complexity is much more apparent to readers than to writers. If you write a piece of code and it appears simple to you, but complex to others - it is complex. Code reviews can be a great tool to reduce complexity in the system. Ask others why the code seems complex - it’s a great opportunity to learn more about software design and understand what exactly makes your code complex. Remember, your job is not only to write code for yourself but to also write code that others can work with easily.
Symptoms of complexity
Complexity usually manifests itself in three general ways, which are described below. Each of these makes it harder to work on the system.
Change amplification - When a simple change requires code modifications in many different places. For example, consider a website consisting of multiple pages, each containing button elements with a background colour of blue. In many early websites, In order to change the colour of all buttons on the website, you would have to change hundreds, if not thousands, of files. This is nearly impossible to update safely (without changing something else by accident), even with today’s efficient code editors in mind. Fortunately, modern websites use component-based design, where the button would be extracted into its own component and the background colour of such button would only be defined in a single place. Using this approach, the background colour of the button can be changed with a single line of code. One of the goals of good software design is to reduce the amount of code that is affected by each design decision, so that future code or design changes don’t require many code modifications.
Cognitive load - refers to how much a developer needs to know in order to complete the task. A higher cognitive load means that developers have to spend more time learning the required information before they can begin to work on the task, and there is a greater risk of bugs in case they have missed some important information. Consider a class that is responsible for fetching data from an API. If the class expects the API key and the headers to be set on every request, that adds to the cognitive load. The developer using the class must now know what are the correct headers for this request and where to find the API key. If the class could be restructured so that each request would use the same headers and API key that exists somewhere in the configuration, it would reduce the cognitive load. Cognitive load arises in many ways, such as dependencies between classes/modules, global variables, APIs with many methods, and inconsistencies.
System designers can sometimes think that complexity can be measured by lines of code; that the shorter implementation is simpler, or if it only takes a few lines of code to make a change, then the change must be easy. That is not always true. Sometimes an approach can be simpler even with more lines of code because it reduces cognitive load.
Unknown unknowns - when it is not obvious which pieces of code must be modified to complete the task, or what information a developer must have to carry out the task successfully. A great example I have seen recently:
If you had registered the model observer far from the related model, you might forget later it even exists. It would be even harder to notice for another developer. If you had to change how the model is handled after being saved, you would not immediately think to check the
AppServiceProvider or even the existence of an
[Model]Observer class, unless you had been burned by it before.
Unknown unknowns are the worst of code complexity. It means that there is something you need to know, but there is no way for you to find out what it is, or even whether there is an issue. It’s not until bugs appear that you will find out about the change you should have made. With unknown unknowns, it is unclear what needs to be done or whether a proposed solution will even work. The only way to be sure is to read the whole code base, which is time-consuming and, frankly, unfeasible. What's worse, is if the change depends on a subtle design decision that was never documented.
One of the most important goals of good design is for a system to be obvious, which is the opposite of high cognitive load and unknown unknowns. In an obvious system, a developer can quickly understand how the code works and what is required to make the change. A developer can, without thinking very hard, make a quick guess about what is needed to be done and be very confident that the guess is correct. In later articles, we will talk more about techniques for making code more obvious.
Causes of complexity
Complexity is most often caused by two things: dependencies and obscurity. Let’s go over these concepts at a high level.
A dependency exists when a given piece of code cannot be understood and modified in isolation; the code relates in some way to other code, and the other code must be taken into consideration and/or modified as well.
Dependencies are a fundamental part of software and cannot be completely eliminated. We often intentionally design the code with dependencies in mind. However, one of the goals of software design is to reduce the number of dependencies and to make the dependencies that remain as simple and obvious as possible.
Consider the example of multiple web pages containing buttons with the background colour. In a bad example, each page would have its own definition of the button. If a button colour is changed on one page, it would have to be changed on all other pages as well, thus each page depends on one another. If we extract the button definition into its own component and use the component instead, then each page would still have a dependency - a dependency on the button component. But in this case, the dependency would be much simpler and more obvious. It is clear that the page depends on the button component, which is defined in a single file, which the developer can edit to easily change the button colour on all pages. The component replaced a non-obvious and difficult-to-manage dependency with a simpler and more obvious one.
The second cause of complexity is obscurity. It occurs when important information is not obvious. A simple example is a variable name so generic that it does not convey much useful information (e.g.
$i). Not only it would most likely require comments/documentation to explain what the variable is responsible for, but it would be difficult to change the name of the variable if it’s being used in multiple unrelated places. Another example would be the need to make the same code modifications in multiple places (e.g. changes to how a post is stored would need to be changed in
PostsWebhookHandler). The developer is not immediately aware that the (possibly) complex logic for the creation of a post exists in multiple places in the system, which results in obscurity. Inconsistency is also a major contributor to obscurity: if the same variable name is used for two different purposes, it won’t be obvious to the developer which of these purposes a particular variable serves, or even that they serve different purposes.
In most cases, obscurity comes from inadequate documentation, although is often also a design issue. If a system has a clean and obvious design, then it will need less documentation. The need for extensive documentation is often a red flag that the design can be improved. The best way to reduce obscurity is yo simplify the system design.
Complexity isn’t caused by a single terribly bad design decision. Instead, it accumulates in small chunks. A single dependency or obscurity by itself is unlikely to affect the maintainability of the system. Complexity comes because hundreds or thousands of small dependencies and obscurities build up over time. Eventually, there are so many of these small issues that every possible change to the system is affected by several of them.
This makes complexity hard to control. It’s easy to convince yourself that a little bit of complexity introduced by your current change is not a big deal, but if every developer takes this approach for every change, complexity will accumulate rapidly. Once accumulated, complexity is hard to eliminate, since fixing a single dependency or obscurity will not make a big difference.
As complexity increases, it leads to change amplification, a high cognitive load, and unknown unknowns. As a result, it causes bugs, and it takes more code modifications to implement each new feature. Developers spend more time acquiring enough information to make the change safely and, in the worst case, they can’t even find all the information they need. The bottom line is that complexity makes it difficult and risky to modify the existing code base.
Let's continue our journey towards writing better Laravel and learn the difference between tactical and strategic programming in Part 2 of this series.
Make sure to subscribe to receive new articles of the "Write Better Laravel" series right in your inbox!