I recently read an article by the main maintainer of InversifyJS describing the journey of rebuilding its dependency resolution algorithm. What caught my attention wasn't the performance improvements or the technical details. It was something much more familiar.
As I was reading, I realized they were experiencing the exact same phenomenon I had experienced years ago while creating InversifyJS. It reminded me of something that, until now, I had never really put into words.
The temptation to solve the hardest problem first
Every engineer has experienced it. You're implementing a feature when you encounter a design problem that feels wrong. You know the current approach won't scale, and you know there must be a beautiful abstraction somewhere, so you stop writing code and start designing.
Sometimes that's the right thing to do. Many times it isn't.
While building InversifyJS, I eventually adopted a different habit. Whenever I found myself thinking, "This feels too complicated, and I can't find a simple, elegant solution right now," I decided to wait.
Not because I ignored the problem, but because I didn't think I understood it well enough yet.
Instead, I focused on features where I had a reasonable level of confidence. I kept improving the parts of the system that felt obvious, leaving the difficult problems untouched.
At first, this almost felt irresponsible. Over time, it became one of the most valuable engineering lessons I have learned.
The magic wasn't finding the solution
The interesting part is that I rarely came back later with a better idea. Something stranger happened.
Implementing those simpler features changed the system itself. New abstractions naturally appeared. Responsibilities became clearer. Concepts that previously seemed unrelated suddenly fit together.
Eventually, I would return to the "hard" problem only to discover it wasn't hard anymore. Not because I had become smarter or because inspiration had struck overnight. The problem itself had changed.
Or perhaps a better way to put it is that the problem dissolved instead of being solved.
Looking back, I think this is one of the defining characteristics of good software design.
Why this happens
Every feature teaches you something about the problem domain. When we invent abstractions too early, we are making educated guesses. Sometimes we are right, but often we are creating abstractions around our current understanding rather than around the problem itself.
As more concrete features are implemented, patterns emerge naturally. The code starts revealing what actually belongs together. The abstraction is not invented; it is discovered.
This is why experienced engineers often say things like "the abstraction revealed itself," "the design fell out naturally," or "the code told me what it wanted."
These expressions sound almost mystical, but they describe a very practical engineering process. You are collecting evidence. Eventually, there is enough evidence that the right design becomes obvious.
Gall's Law explains it perfectly
Years later, I came across Gall's Law from Systemantics:
"A complex system that works is invariably found to have evolved from a simple system that worked."
I think this is one of the best observations ever made about software architecture.
Notice what it doesn't say. It doesn't say complex systems cannot be designed. It says the ones that actually work usually evolve.
That perfectly matches my experience with InversifyJS.
Many of the abstractions people now associate with the library were not part of some grand master plan. They emerged because earlier, simpler decisions created the right conditions for them.
Each feature made the next decision a little clearer. Each change revealed more about the shape of the problem. Eventually, some of the hardest problems no longer looked hard at all.
Emergent design as pragmatism, not procrastination
The approach I am describing is often called emergent design.
Emergent design is the idea that a software system’s architecture should evolve from the implementation of real requirements rather than being fully designed upfront. Instead of trying to predict every future need and creating abstractions in advance, engineers start with the simplest solution that works and allow the design to emerge through usage, feedback, and refactoring.
This does not mean designing without a plan or letting a codebase grow chaotically. It means recognizing that some architectural decisions are easier to make when we have more information. The structure of a system becomes clearer as we solve concrete problems, discover recurring patterns, and learn which concepts genuinely deserve to exist as abstractions.
It is important to distinguish this from simply avoiding difficult work. Emergent design is not an excuse to postpone decisions forever or to avoid thinking deeply about architecture. It is a pragmatic approach to dealing with uncertainty.
One of the easiest mistakes in software development is trying to solve problems before we fully understand them. We start designing abstractions for scenarios that might never happen, or we optimize parts of the system before we know where the real problems are.
This is how overengineering and premature optimizations often begin.
The problem is not that we are thinking too much. The problem is that we are making decisions based on assumptions instead of evidence.
A pragmatic engineer understands that some decisions become easier when the system has more information. Instead of building a complex solution for a hypothetical future problem, they focus on solving the problems that exist today in the simplest way possible.
Each solved problem gives you more information. Each refactoring changes the design space. Each feature teaches you something new.
The goal is not to avoid complexity. The goal is to introduce complexity only when it is justified.
A lesson I keep relearning
Reading the recent article about InversifyJS reminded me that this was not a unique experience. I had always assumed it was just a consequence of how I happened to build the project, but it was fascinating to see another maintainer independently arrive at the same place years later.
It is easy to think good architecture comes from moments of brilliance or from correctly predicting the future. My experience has been almost the opposite.
The best designs I have worked on did not come from trying to anticipate every possible problem or by creating the most sophisticated architecture from the beginning. They came from being pragmatic, solving real problems as they appeared, and allowing the design to evolve with better information.
Sometimes the most productive thing an engineer can do is not to solve the hardest problem immediately. It is to solve the next simplest one with confidence, learn from it, and let that knowledge reshape the system.
This approach avoids the trap of overengineering while also preventing premature optimizations based on assumptions.
If you are patient, and if the design is evolving in the right direction, you will occasionally discover something surprising.
The hard problem no longer exists.
It didn't get solved.
It dissolved.
Top comments (0)