In the previous chapters, we explored how low-level decisions and poor code organization contribute to growing complexity in software systems. These topics dealt mostly with how we write code. In this chapter, we take a step back to look at a higher-level cause of complexity — one that often lies deeper than syntax or structure: the misalignment between the problem and the solution.
When the solution doesn’t truly fit the problem it’s trying to solve, it introduces friction. Even if the code is well-written or follows best practices, if it doesn’t address the right shape of the problem, it becomes harder to understand, harder to maintain, and harder to evolve.
Causes of Mismatch
There are many ways a solution can become misaligned with the problem it’s supposed to address:
-
Suboptimal decisions at the start
We may choose an ill-fitting solution early on due to time pressure, incomplete information, or lack of experience. -
The problem evolved, but the solution didn’t
A solution that once worked well may become awkward as requirements or context shift over time. -
Overgeneralization or copy-pasting
We might borrow a pattern from another part of the app or from industry examples without fully understanding it. Similarity on the surface doesn’t mean the underlying problems are the same.
Signs You Might Be Dealing with a Problem–Solution Discrepancy
Recognizing the mismatch is the first step. Here are some examples:
-
You have to frequently explain “why it’s done this way.”
When working with some piece of logic you need to gather some additional context. The design needs extensive documentation to justify itself. -
You’re writing a lot of glue code, wrappers, or adapters.
These often appear when the parts don’t naturally fit together — you’re forcing compatibility where there isn’t any. -
You’re breaking abstractions or layering rules.
For example, reaching deep into another layer’s details, using downcasting, adding one-off flags, or relying on fragile type checks — all signs that the abstraction isn’t holding up. -
The code feels unintuitive or awkward.
Even if it “technically works,” it doesn’t feel natural or straightforward. You or your teammates might avoid touching it unless absolutely necessary.
Why Recognizing Mismatch Is Hard
Recognizing a mismatch isn’t always easy. We tend to be biased toward what already exists (more about the biases in the following articles). Familiarity can be misleading — just because we understand a tool doesn’t mean it fits the problem. In high-pressure environments, we often optimize for short-term speed, making it easier to move forward with a known (but imperfect) solution than to pause and reconsider.
What makes this even harder is that problems often evolve gradually. We may not notice how much the current solution has drifted away from what the problem really requires — until the weight of complexity becomes too heavy to ignore.
Consequences of a Mismatch
Imagine you try to use a spoon to cut instead of a knife. Technically, it works — you can apply enough pressure and eventually get the job done. But it’s slow, awkward, and prone to mistakes. To make it more effective, you might start modifying the spoon, developing special techniques for using it, or building accessories to help stabilize what you’re cutting.
The more you try to improve the wrong tool, the more complexity you add. You end up with a mess of modifications, documentation, and rituals to support something that was never meant to do the job in the first place.
This is what happens when a solution doesn’t fit the problem: you pay an ongoing cognitive tax. The system becomes harder to understand, harder to explain, and harder to change.
Two Reactions: Redesign or Patch
Once you recognize the mismatch, you’re usually left with two options. One is to redesign the solution from the ground up — to realign it with the actual shape of the problem. This is more difficult and time-consuming, but it typically results in a system that is simpler, more maintainable, and easier to work with in the long run.
The second, more common option is to patch the existing solution. We layer on exceptions, conditionals, adapters, and tweaks to make it “work.” But every patch is a small deviation from clarity, and over time, these patches accumulate. Eventually, the structure becomes difficult to reason about, and changes require more and more mental effort.
Although redesigning feels expensive upfront, it often saves time and complexity in the long term. In contrast, the patching approach is a form of debt — it accumulates quietly and compounds with every change.
How to Avoid It
-
Understand the problem deeply before jumping into the solution.
Rephrase it in multiple ways, sketch alternatives, and discuss with teammates. Don’t settle for the first idea that seems to work. -
Start with the simplest viable shape.
Avoid overengineering or premature abstraction — you can always refactor once the problem is better understood. -
Design by subtraction.
After your first draft, ask: What can I remove without breaking the essence of the solution? -
Be pragmatic — validate fit over elegance.
The best solution is not always the most elegant one. Sometimes the right shape is the boring one that just works. -
Try to find several solutions.
Exploring a few directions allows you to compare trade-offs and better understand what matters most in your context. -
Regularly revisit assumptions.
What worked before may not work now. Be willing to let go of solutions that no longer serve the problem.
Agile architecture
Misalignment doesn’t only affect individual pieces of logic — it can also affect the architecture of an entire module or even the whole app.
In modern development, we typically follow Agile methodologies. We start with a general idea and evolve the product incrementally, discovering the path as we go. This approach works well for products, but it also introduces a challenge: our architecture must adapt just as fluidly. If the system architecture is rigid or overly predetermined, it inevitably becomes misaligned with the evolving needs of the product.
That’s why architecture needs to be agile too. It must be designed with evolution in mind. This doesn’t mean skipping structure altogether, but rather building in flexibility: making it modular, keeping boundaries clean, favoring composition over inheritance, isolating decisions that are likely to change, and avoiding premature generalization.
Whether it’s called evolutionary architecture, emergent design, or just pragmatic refactoring — the principle is the same: keep your architecture open to change, so it can continue to match the problem it’s meant to solve. This alignment is what keeps complexity from spiraling out of control as your app grows.