How My Design Thinking Has Evolved
A candid look at how my approach to system design has changed over the years, from over-engineering to pragmatism, and the experiences that drove each shift.
Context
The way I design systems today is fundamentally different from how I designed them ten years ago. Not because the technology changed (it did, but that is not the main factor). Because my understanding of what matters changed. Each phase of my evolution was driven by specific experiences, usually failures, that forced me to reconsider assumptions I did not know I was making.
Phase 1: The Over-Engineer
Early in my career, I treated every project as an opportunity to build the most comprehensive, flexible, future-proof system possible. If a feature might be needed someday, I built the abstraction for it. If a component might need to scale, I distributed it.
A typical design from this phase:
- Three levels of caching ("just in case")
- Plugin architecture for components that would never have plugins
- Abstract factory patterns wrapping simple constructors
- Configuration for every possible parameter, most of which nobody would ever change
The systems were architecturally impressive and practically painful. Adding a simple feature required understanding layers of abstraction that existed for hypothetical requirements. Debugging meant traversing indirection that served no current purpose.
What broke this mindset: A project where I spent three weeks building a generic event processing pipeline. The business pivoted two months later, and the pipeline was scrapped entirely. The flexibility I had engineered for was irrelevant because I had predicted the wrong future.
Related: Building a Minimal Feature Flag Service.
Lesson: You cannot predict the future. Flexibility for hypothetical requirements is waste. Build for what you know, and make the system easy to change for what you do not know.
Phase 2: The Minimalist
The over-engineering experience swung me to the opposite extreme. I stripped everything down. Minimal abstractions. No caching unless proven necessary. Monolithic deployments. Simple data models.
A typical design from this phase:
- Single database, single service, single deployment
- Inline SQL instead of ORMs
- Flat file structures instead of layered architectures
- Minimal error handling ("it will be obvious when something fails")
See also: Handling Partial Failures in Distributed Mobile Systems.
The systems were fast to build and easy to understand initially. But they aged poorly. Without clear boundaries, changes in one area leaked into others. Without proper error handling, failures were discovered by users instead of by monitoring. Without any abstraction at seams, replacing a component required rewriting everything that touched it.
What broke this mindset: A system that grew from 5,000 lines to 50,000 lines without any structural investment. Adding a feature in one area reliably broke something in another. The team spent more time fixing regressions than building new functionality.
Lesson: Minimalism without structure is not simplicity. It is just less code. Simplicity requires deliberate design, not the absence of design.
Phase 3: The Pragmatist
The current phase, which has held for several years, balances structure with simplicity. The guiding principle: invest in structure where change is likely, and keep things simple where it is not.
A typical design from this phase:
- Clear module boundaries within a monolith (not microservices by default)
- Abstractions only at genuine seams: where implementations might change or where testing requires substitution
- Caching only where measured performance data justifies it
- Error handling proportional to the severity of the failure mode
- Observability as a core concern, not an add-on
This approach is less satisfying than either extreme. It requires judgment calls that feel arbitrary. "Do we abstract this or not?" depends on how likely it is to change, which requires domain knowledge and experience, not a rule you can apply mechanically.
The Specific Shifts
From "What might we need?" to "What do we need?"
I no longer design for hypothetical requirements. I design for the requirements I have, with seams that allow extension. The difference is subtle but important: a seam (an interface, a well-defined boundary) costs very little to create and enables future change. A full implementation of a hypothetical requirement costs a lot and may never be used.
From "How can I make this elegant?" to "How can I make this debuggable?"
Elegance optimizes for the reader who understands the system deeply. Debuggability optimizes for the person who is encountering the system at 3 AM during an incident and needs to understand what went wrong quickly. I now optimize for the second person.
From "distributed by default" to "monolith first"
Distributing a system introduces latency, partial failure modes, data consistency challenges, and operational complexity. These costs are justified when you have the problems that distribution solves (team independence, heterogeneous scaling, failure isolation). For most systems I have built, those problems did not exist on day one. Starting as a monolith with clear internal boundaries and extracting services when specific pressures require it has proven more effective.
From "build vs. buy" to "build only what differentiates"
I used to default to building because building gave me more control. Now I default to buying (or using managed services) for everything that is not core to the business differentiation. Databases, message queues, monitoring systems, deployment pipelines: these are infrastructure. Use established solutions. Build the thing that only you can build.
From "zero technical debt" to "managed technical debt"
I used to believe that any technical debt was a failure of engineering discipline. Now I understand that some technical debt is a rational choice: taking a shortcut to ship faster when the cost of the shortcut is well understood and the payoff is worth it. The key is managing it: tracking the debt, understanding its cost, and paying it down before it compounds.
The Judgment Call Problem
The hardest part of pragmatic design is that it requires judgment. Over-engineering has a clear rule: build for everything. Minimalism has a clear rule: build for nothing beyond the immediate need. Pragmatism says: it depends. It depends on the domain, the team, the timeline, the risk, the likely evolution of the requirements.
This judgment improves with experience but never becomes formulaic. Every system is a new context, and the right trade-offs depend on that context. The best I can offer is the questions I ask:
- How likely is this to change in the next year?
- What is the cost of changing it later versus abstracting it now?
- Who will maintain this? What is their experience level?
- What are the failure modes, and how severe are they?
- What will I wish I had built when I am debugging this at 3 AM?
Key Takeaways
- Over-engineering for hypothetical requirements is waste. Build for known requirements with seams for extension.
- Minimalism without structure is not simplicity. It is unstructured code that ages poorly.
- Pragmatic design requires judgment that improves with experience but never becomes formulaic.
- Optimize for debuggability over elegance. The person debugging at 3 AM matters more than the person reviewing the PR.
- Start with a monolith with clear boundaries. Extract services when specific pressures require it.
- Build only what differentiates. Use established solutions for everything else.
- Some technical debt is rational. The key is tracking it and paying it down before it compounds.
Further Reading
- How I'd Design a Scalable Notification System: System design for a multi-channel notification system covering delivery guarantees, rate limiting, user preferences, and failure handling...
- Event Tracking System Design for Android Applications: A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, an...
- Failure Modes I Actively Design For: A catalog of failure modes that experienced engineers anticipate and design around, from cascading failures to data corruption to clock s...
Final Thoughts
Design thinking evolves through failure. Each phase taught me something that the previous phase lacked. Over-engineering taught me that the future is unpredictable. Minimalism taught me that structure is necessary. Pragmatism taught me that every decision is a trade-off and the right answer depends on context. I expect this evolution to continue. The engineer who thinks they have figured out the right approach for all situations has stopped learning.
Recommended
Designing an Offline-First Sync Engine for Mobile Apps
A deep dive into building a reliable sync engine that keeps mobile apps functional without connectivity, covering conflict resolution, queue management, and real-world trade-offs.
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.