On taking design decisions
When designing and implementing software, how to invest just enough? How to ensure that future changes won’t be a torture to fit into the codebase?
In the current project at work, we are reimplementing an important part of our product. There are a number of requirements, both functional and non-functional, which either cannot or are very hard to support within the current architecture. The project has quite a lot of novel things for our company: due to a new technology, a new paradigm, and new combinations of already known things. And of course, we are having enough design discussions throughout the project.
In some discussions, it’s clear we need to invest in the right thing from the very beginning. Other discussions boil down to the ease of building now versus maintainability in the future. In other words, how much effort do we want to invest now, and how much effort is reasonable to push into the future?
You’ve surely experienced it before, even if in hindsight. An over-engineered solution to account for a lot of future use cases that had never come. A quick implementation to address the requirements right here and right now, which is later unable to accommodate requested changes, and requires an expensive redesign.
How to find balance in such situations?
Think about the future
Think about product enhancements that are very likely to come. Ask for a broader picture when starting “just this one feature”. What would it cost to account for the enhancements now? How much simplicity would you need to sacrifice now? If potential features can be easily added later by putting just a bit of effort now, great! Invest now if you have strong evidence.
Robert C. Martin wrote that architecture is about postponing decisions. That might sound counterintuitive: isn’t architecture about making decisions? What design looks like, which database to use, and so on. The reality is that every complex enough design balances different requirements which are often at stake with each other. Sometimes we just don’t have enough information or understanding, so to move forward, we effectively need to make a bet. Meaning it might not work, and we might need to make another bet later. That’s why we need enough flexibility built into solutions. So that later, when there is enough information, we can make an informed decision and still fit it into the current design.
Architecture is about having flexibility in important places. These are places where you’re not sure that the current choice is the final one or when the choice will be expensive to change later, or both. Starting a new service and not sure that the current database choice is here for the long run? Invest in having a junction: abstract away all database operations and try not to lean on database-specific details. A junction is basically everything that connects and separates various pieces: classes, components, services.
If you understand that a certain side of the current design is sub-optimal, you might invest in making that part a junction as well. So that it’s easier to change or even completely drop it later.
The goal here is not to predict the future. The goal is to be intentional about design choices and allow decisions to be easily changed or reversed. Modular systems are cheaper in that regard.
Don’t think about the future
Don’t think about the future too much. Our ability to see the future is very, very limited.
The more you dive into concrete scenarios that you think will happen, the more probable it is that you waste your time now. And the more likely that solution becomes “asymmetric”: chances are it becomes too rigid for other use cases that might come. You’ll be flexing the solution in one direction, while it'll need to flex in the opposite direction later.
Trying to cover most of the future scenarios leads to generalized solutions. The more cases you try to cover, the more complicated the solution becomes. Exponentially complicated. If there are no requirements or strong evidence for generalized solutions, don’t do it. It is likely that it would be a wasted effort that introduces complexity into the codebase.
And that complexity will bite you. Imagine it’s 2 am, production is burning, and you’re debugging the solution, trying to understand what has happened and how to at least hotfix it, let alone to make a proper fix. The simpler the solution, the better you and your colleagues understand it, the easier it is to figure the problem out and to fix it, even under pressure
Remember, we humans are bad at predicting the future. So keep in mind KISS and YAGNI principles. Keep things rather simple now and avoid things that are not strictly needed right away.
You don’t need to predict future use cases and support them now. If you’re not sure a use case will come, pass it. When you have more information, you will make a better decision. You only need to bake in some flexibility to be able to fit things to come.
The system that is simple enough to accommodate any kind of changes does not need any predictions.
Document decisions
What about that non-extensible implementation you needed to rework? Seems like the one who created it just did the first thing that came into their mind without thinking about the future at all. Don’t rush. People usually try to do their best according to their abilities, the information at hand, and constraints. Every decision made sense to the person back then, even if right now it seems completely wrong. What we miss right now are the reasons and the thought process behind.
So do your future colleagues and yourself a favor: document your decisions and design. Explain the problem and constraints at hand. List all the considered alternatives. Describe the reasons why you picked what you picked. In every complex enough (sub)system, the best solution rarely exists because we need to balance multiple characteristics, often contradicting. So we usually take the least worst solution, which has certain drawbacks or suboptimal parts. Pay attention to describing these drawbacks as well as the path to the optimal solution.
Put decision documentation as close to the code as possible. Code comments for smaller decisions. Architecture decision records in repositories for higher-level decisions. Links in the readme file to design documents. Even comments in the tickets will work.
With this information being available, future changes or revisiting the implementation become easier. Documenting decisions tends to preserve edge cases and details that were important when a decision was made but can be forgotten later. Decision documentation is your time machine that allows travel to the past.
Write tests
Good tests capture requirements as well as important edge cases. Although they do not explain why exactly certain decisions were made, they serve as a safety net when it comes to future changes. As you might need to adjust the current implementation, tests will help to ensure that existing requirements are not broken.
It is important to note that the scope of the tests must be bigger than the decision scope. Thus, as code changes, tests only break if the code functionality was not fully preserved. It can be unit tests if the decision scope is limited to one class. It can be sociable unit tests or integration tests if the scope involves multiple classes and components. And up to end-to-end tests when the scope affects the whole system. More coarse-grained, high-level tests usually bring more value here.
The scope of some decisions might be too large to be covered by tests. In these cases, decision documentation is your best friend.
Conclusion
The question of how much to invest now versus how much to pay later is, without a doubt, a hard one. While experience and gut feeling help here, they only do it to some extent.
If there is a universal advice, then it is this: be intentional about design choices, don’t overthink it, document decisions, and have tests.
And in case you’re up for a deep dive on the topic, I’d recommend reading Dan North’s “Best Simple System for Now”.