Designing for Change Without Over-Engineering

Dhruval Dhameliya·May 6, 2025·8 min read

How to build systems that accommodate future changes without building unnecessary abstraction layers, speculative features, or premature generalization.

The tension between "plan for the future" and "build what you need now" is the defining challenge of software architecture. Lean too far toward future planning and you build systems with abstraction layers nobody uses. Lean too far toward the present and you build systems that resist every subsequent change. This post is about finding the balance.

The Cost of Speculation

Speculative generalization is one of the most expensive mistakes in software design. It looks like this: "We only support one payment provider today, but we might support five in the future, so let's build an abstract payment interface with a plugin system."

The costs of this speculation:

  • Development time to build the abstraction (2-4x the time of a direct implementation)
  • Cognitive overhead for every developer who encounters the abstraction and must understand why it exists
  • Maintenance burden of keeping the abstraction working as the underlying implementation evolves
  • Wrong abstraction risk because the actual future requirements will differ from what you imagined

The worst outcome: you build the abstraction, the second payment provider arrives 18 months later, and their API is different enough that the abstraction does not actually accommodate it. Now you must modify the abstraction and the existing implementation, costing more than if you had built the abstraction when the second provider arrived.

The Rule of Three

I do not build an abstraction until I have three concrete instances. One instance is an implementation. Two instances might be a coincidence. Three instances reveal the actual pattern.

With three instances, you can see:

  • What is genuinely common across all three
  • What varies between them
  • Where the abstraction boundary naturally falls

This is dramatically more accurate than guessing the abstraction from a single instance and hoping future instances fit.

The rule of three means accepting some duplication in the short term. That is intentional. Duplication with the ability to evolve independently is cheaper than the wrong abstraction that couples everything together.

Designing for Change Without Abstraction

See also: Designing a Feature Flag and Remote Config System.

You do not need abstraction layers to accommodate change. You need well-structured code with clear boundaries. Specific techniques:

Separation of Concerns

Keep business logic separate from infrastructure concerns. The order processing logic should not know whether it is invoked by an HTTP endpoint, a queue consumer, or a batch job. This separation means you can change how the logic is invoked without changing the logic itself.

This is not an abstraction layer. It is basic structural discipline. The business logic is a function that takes inputs and produces outputs. The infrastructure code calls that function. No interface hierarchy, no plugin system, no dependency injection framework required.

Small, Focused Modules

Large modules resist change because every modification risks side effects across the module. Small modules that do one thing are easier to replace entirely than to refactor incrementally.

My guideline: if a module has more than 300 lines or more than one reason to change, it should be split. Not into abstract interfaces, but into concrete modules with clear responsibilities.

Well-Defined Data Boundaries

Changes often involve modifying the data a component works with. If the data is passed around as untyped dictionaries or generic objects, every component that touches the data must change when the data structure changes.

Typed data structures at component boundaries act as contracts. When the data changes, the compiler (or type checker) tells you exactly which components are affected. This is change management without abstraction overhead.

Configuration Over Code for Variable Behavior

When behavior varies by environment, tenant, or feature flag, externalize it as configuration rather than code. A system that reads its retry count from configuration can be tuned without a deployment. A system that hardcodes the retry count requires a code change, review, and deploy.

But limit configuration to genuinely variable values. Over-configuring a system creates a different kind of complexity where the behavior depends on a combination of 50 configuration values that interact in non-obvious ways.

Seams, Not Interfaces

A seam is a place in the code where you can change behavior without modifying the code on either side. It is a less formal concept than an interface, but it serves the same purpose for change management.

Practical seams:

  • Function parameters that accept callbacks or strategy functions. When behavior needs to change, you pass a different function.
  • Configuration values that control branching logic. When behavior needs to change, you change the configuration.
  • Dependency injection at the constructor level. When you need to swap a component, you inject a different one.
  • Feature flags that gate new behavior. When you want to enable or disable behavior, you flip the flag.

These are lightweight. They do not require abstract base classes, interface hierarchies, or plugin registries. They provide the flexibility you need without the overhead of full abstraction.

How to Evaluate "Should We Plan for This?"

Related: Building a Minimal Feature Flag Service.

When a team member suggests building for a future requirement, I apply this checklist:

  1. Is this future requirement confirmed by a stakeholder, or speculated by an engineer? Confirmed requirements justify investment. Speculated requirements usually do not.

  2. How expensive would it be to accommodate this requirement later vs. now? If the cost is roughly the same (within 2x), wait. If accommodating it later would require a significant rewrite, invest now.

  3. How confident are we in the specific shape of the future requirement? High confidence (we know exactly which payment providers we will add) justifies planning. Low confidence (we might need "something like" multi-tenancy) does not.

  4. Does building for this now add complexity that the team must maintain? If yes, the maintenance cost must be weighed against the future benefit.

ScenarioDecision
Confirmed requirement, expensive to add later, shape is knownBuild now
Confirmed requirement, cheap to add laterWait
Speculated requirement, any difficultyWait
Confirmed requirement, shape is uncertainBuild seams, not the full solution

The Evolutionary Architecture Approach

Instead of predicting future requirements, invest in the ability to change quickly:

  • Fast deployment pipeline. If you can deploy in 15 minutes, you can respond to new requirements in hours instead of days. The speed of change matters more than the prediction of change.
  • Comprehensive test suite. Tests give you confidence that changes do not break existing behavior. Without tests, every change is risky, which makes the team resistant to change.
  • Small, independent services (when warranted). Independent deployment means one service can evolve without coordinating with others. But only when the service boundaries are well-defined and stable.
  • Feature flags for gradual rollout. New behavior can be deployed, tested with a subset of users, and rolled back without a deployment. This reduces the risk of change.

The common thread: these investments make the cost of future changes low, which eliminates the need to predict which specific changes will be needed.

Over-Engineering Warning Signs

  • You have more interfaces than implementations
  • Configuration files are longer than the code they configure
  • The team spends more time debating architecture than building features
  • New developers ask "why does this layer exist?" and the answer is "we might need it someday"
  • Adding a simple feature requires modifying four abstraction layers
  • The system has a plugin architecture with exactly one plugin

When you see these signs, simplify. Remove unused abstraction. Inline unnecessary interfaces. Convert speculative configuration to hardcoded values. The system will be easier to understand and easier to change.

Key Takeaways

  • Speculative generalization costs 2-4x the development time of a direct implementation and often produces the wrong abstraction.
  • Wait for three concrete instances before building an abstraction. The pattern is clearer with three examples than with one.
  • Separate business logic from infrastructure code, use small focused modules, and define typed data boundaries. These provide change resilience without abstraction overhead.
  • Use seams (function parameters, configuration, dependency injection, feature flags) instead of formal interface hierarchies.
  • Invest in deployment speed, test coverage, and feature flags to reduce the cost of future changes rather than predicting which changes will be needed.
  • If you have more interfaces than implementations, you have over-engineered.

Further Reading

Final Thoughts

The best designs I have worked with were not the ones that predicted the future correctly. They were the ones that made changing direction cheap. When the cost of change is low, you do not need to predict the future. You just respond to it.

Recommended