A principle I come back to is: optimize for the next few likely changes, not every imaginable change.
The tension is real. Systems that are too rigid become expensive to modify, but systems designed to accommodate every possible future often become more complex than the problem they're solving.
Here are the principles I tend to follow:
-
Design for known variability, not imagined variability.
If you've already seen three different payment providers, abstracting the payment interface makes sense. If you've only ever had one provider and no realistic plans to change, adding a plugin architecture is usually unnecessary.
-
Keep decisions reversible when possible.
Prefer choices that are easy to change later over elaborate abstractions today. For example:
- Encapsulate external APIs behind a small interface.
- Keep configuration separate from business logic.
- Avoid scattering assumptions throughout the codebase.
This creates flexibility without requiring a full abstraction layer.
-
Duplicate before you abstract.
A useful rule is the "Rule of Three": if you solve the same problem three times and notice the same pattern, then extract the common abstraction. Premature abstractions often fit only the first use case.
-
Optimize module boundaries, not internal implementation.
Stable interfaces matter more than clever internals. If modules communicate through clear contracts, you can rewrite one without affecting the others.
-
Minimize coupling.
Adaptability usually comes from reducing dependencies, not adding extension points. Questions I ask include:
- Can this component change independently?
- Does this module know too much about another?
- Can I test it in isolation?
-
Separate policy from mechanism.
Business rules change more often than infrastructure. Keeping "what we do" separate from "how it's implemented" makes systems easier to evolve.
-
Pay for complexity only when it earns its keep.
Every abstraction has costs:
- More code to understand
- More indirection
- More debugging complexity
- More documentation
I assume complexity is a liability unless it solves a demonstrated problem.
-
Use data to guide flexibility.
Ask questions like:
- How often has this changed in the past?
- How expensive would it be to change later?
- What's the probability this requirement actually arrives?
High likelihood × high cost of change is where upfront design effort pays off.
A mental model
I like to think in terms of cost curves.
- Rigid design: low initial cost, high future modification cost.
- Over-engineered design: high initial cost, low modification cost—even for changes that never happen.
- Well-balanced design: modest initial cost and modest future change cost.
The goal isn't minimizing today's effort or maximizing future flexibility; it's minimizing the expected lifetime cost.
A practical checklist
Before adding an abstraction, I ask:
- Have I seen this variation more than once?
- Would removing this abstraction make the code noticeably simpler?
- If the anticipated change never happens, will this abstraction still make sense?
- Can I defer this decision until I have more information?
- Am I solving an actual requirement or an imagined one?
If most answers suggest the change is speculative, I usually defer it.
A quote often attributed to Kent Beck captures this mindset well: "Make the change easy, then make the easy change." Rather than trying to predict every future requirement, structure the system so that when real requirements emerge, adapting it is straightforward.