Keep software complexity under control
The key to maintainable software development
Developing software is expensive. Software can degenerate quickly and costs can add up without us realizing it, and the main culprit for these costs is complexity. Most of the problems we face in developing come from the inherent complexity of our creations.
The cost can come from many sources, but all of them are related to how much it costs us to change our software. If it takes us a long time to introduce a change, it is because it is too complex. If it’s hard for us to maintain or update existing code, it’s too complex. If we can’t find a simple way to test some of our features, it’s because they’re programmed too complexly.
As a bit of a distortion of the Pareto principle, I dare say that the day-to-day development in most teams is divided into 80% dealing with the complexity of the system and the other 20% to actually implement the change.
In this article I will try to give some tips to turn these percentages around so we can invest the time in what really matters: providing value to our users.
Most of our time is spent reading code
As I mentioned, we invest a lot of time in understanding our own code. As Uncle Bob once very wisely said:
Indeed, the ratio of time spent reading versus writing is well over 10 to 1. We are constantly reading old code as part of the effort to write new code. …[Therefore,] making it easy to read makes it easier to write.
So, the first step is to write code that’s easy to read, understandable, written for human beings.
To make our code easy to read, we first have to call things as they are. Stop naming variables like letters or with indecipherable names. Call things by simple, descriptive names that indicate their content or function. There is no better documentation than a good naming.
We tend to make things more complex than they should be
As developers, we are often tempted to set up a trade show. We have a need to use the latest technologies, the latest data structure or design pattern that we’ve learned, and a lot of times we do things in a more complex way than it could be.
KISS (Keep It Simple, Stupid!)
The KISS principle, or keeping it simple, is basically a wake-up call about this phenomenon. Simple software is easier to maintain, easier to evolve, and less error-prone. Therefore, when we implement a new feature we should always advocate for making things as simple as possible. We must not beat around the bush and we must resist the temptation to over-engineer, something that in the mid-term is very expensive.
Single Responsability Principle
An easy way to apply a simple design is to make things have only one function. The SRP or Single Responsibility Principle makes us think about this restriction that makes our software artifacts simpler by nature. If we have a class that is responsible for sending data to a third party, let’s not have it also write to our database, let’s delegate that functionality to another entity that is specialized in doing that and only that. By keeping the responsibilities of each entity in our code at bay, we will make everything much easier. After all, it’s easy to keep things simple when you only have one goal.
YAGNI (You Aren’t Gonna Need It)
As I’ve also mentioned, as developers we tend to make things more complex than they should be. And that’s not always because we’re carried away by over-engineering. Sometimes we make the systems more complex just in case. Just in case we have more than 100,000 users one day, just in case we are asked for this other functionality, just in case a meteorite falls on the data center. Life, like development, is full of “Just in cases” and in our software we must learn to limit them. I’m not saying that we shouldn’t take into account the future, but we have to develop our software without being so aware of what may happen and focus on what we want to achieve now. If we also make flexible software where it is easy to add and remove things, those just in case will not be a problem when they arrive (I emphasize, when they arrive).
We have to know how to scope our problem
Another great cause of complexity is that many times we do not know how to narrow down the problems we are solving or our own software. We fail many times to separate what things don’t go with other things and cause our code to be coupled with each other and difficult to maintain and change.
A good first step to reduce coupling and increase cohesion in our software is to make a good separation of the domain in the different contexts that compose it. For example, if you are developing an e-commerce, it is convenient to separate the entire context of the catalog from the context of payments. In the end, they are problems that require very different solutions, and separating them and diluting them as much as possible will make them much easier to develop and iterate.
In micro: at the level of software and code; and in macro: if different teams are responsible for different contexts, the potential blockage between them will be less than if they share contexts: Conway’s law in all its glory.
As we have mentioned, reducing the coupling between different contexts of our domain is essential to avoid blockages or possible problems when developing. A good tool for this is to always develop using interfaces to interact with other parts of our domain or our organization. Agree contracts with the parties involved and develop following those defined interfaces without worrying about the implementation behind them. That will allow us to work in an asynchronous and decoupled way that will speed up development and reduce problems.
On the other hand, when developing and maintaining software, it is advisable to have good cohesion. This is making things that work together stay together. In this sense, there are tools such as vertical architecture that can help us keep the code related to a feature in the same place. We can also see it from a horizontal point of view by separating our application into layers, using the repository pattern, for example, we make the logic of the persistence layer stay in the same place and decouple it from the rest of our business logic making it easier to modify it.
Hide complexity (when it can’t be avoided)
Another way to limit the complexity of our software (as long as it’s not avoidable) is to use abstraction layers. Using interfaces, as we have discussed before, we can hide the complexity of certain parts of the system behind simple contracts that help us focus more on our business logic and not so much on the inherent complexity of certain parts of the system.
book_any_available_slot function, we delegate the inherent complexity of the slot service to an easy-to-use and easy-to-understand interface.
We need to know what problem we’re solving
I’ve left this section for last, but I consider it to be the most important. When we develop a digital product, it is very important to be clear about what we are building. Just like if you can’t explain something you don’t understand it, if you don’t know what the needs of your product are, you’ll end up building something more complex than necessary and that may not even meet the requirements.
Get clear requirements
The first step to start developing a product is to always be super clear about the needs it is covering and the requirements that are asked of you. If you focus on complying with only what is strictly necessary, it will be easier to avoid accidental complexity and develop your software in the simplest way possible (again avoiding the “Just in cases”).
In this part, good communication and good day-to-day work with the rest of the roles in your team (designers, product managers, stakeholders) are essential. If you understand each other, this collection of requirements will be done organically without you noticing.
This advice may seem very dogmatic, and it is. One of the tools I use the most in my day-to-day life is TDD (Test-Driven Development). Doing TDD is more than just starting to develop by writing the tests. Doing TDD allows you to focus first on what’s most important: what is the problem you’re solving. Writing the tests first allows you to think about the requirement we’re covering before you think about how we’re going to implement it. Then, trying to turn the test green as soon as possible helps us not to beat around the bush, which usually leads to a simpler design. And finally, the refactor phase gives us space to see the problem in its completeness and simplify the implementation knowing that we continue meeting the needs (if our tests are still green). I can’t recommend it more.
The inherent complexity of software development can increase costs, decrease efficiency, and make it difficult to deliver value to users. The key to reversing this situation lies in approaching complexity from multiple angles:
Simplicity in design: Focusing on writing code that is clear and understandable to humans is the first step to facilitating future development.
Design Principles: Concepts such as KISS (Keep It Simple, Stupid!), SRP (Single Responsibility Principle), and YAGNI (You Aren’t going to Need It) highlight the importance of simplicity, clarity, and rejection of over-engineering.
Domain Division: Separating the different software contexts allows for less coupling and increased cohesion, facilitating development and iteration.
Abstraction and clear contracts: The use of interfaces and abstraction layers helps hide complexity when necessary, allowing you to focus on business logic.
Focus on clear requirements: Deeply understanding product requirements from the beginning helps avoid unnecessary complexity. To do this, work closely with your team.
Use TDD: Test-Driven Development not only ensures the quality of the software, but also directs attention towards solving the problem before thinking about its implementation, encouraging a simpler design and a deeper understanding of the problem.
In summary, simplifying the development process and keeping the focus on user needs are key elements in reducing complexity and maximizing the value delivered in software development.