Software changes. This is not a contingency — it’s the nature of software development. Requirements evolve. Systems scale. Technologies improve. Teams change. The useful question is not “will this change?” but “when it changes, how expensive will the change be?”
Code that’s designed for change is not over-engineered. It’s code where the natural boundaries of the design align with the natural boundaries of change. When a change is required, it affects a small, predictable, well-bounded area. When it’s not, the design hasn’t added unnecessary complexity “just in case.”
The Stability-Volatility Axis
Before designing for change, understand what’s likely to change and what’s likely to be stable.
Stable things: core business concepts, fundamental domain rules, external interface contracts you’ve published, standards.
Volatile things: third-party integrations, UI layouts, business rules that are actively being refined, infrastructure choices, data formats for internal use.
The principle: volatile things should depend on stable things, not the reverse.
Your order management domain shouldn’t depend on Stripe’s API types. The dependency should be inverted: the Stripe integration depends on your payment domain interface.
// Stable: your domain interface
public interface PaymentGateway {
PaymentResult charge(PaymentRequest request);
}
// Volatile: Stripe integration (can change without affecting domain)
@Component
public class StripePaymentGateway implements PaymentGateway {
@Override
public PaymentResult charge(PaymentRequest request) {
// Stripe-specific code contained here
}
}
When Stripe’s API changes or you switch providers, only the volatile implementation changes. The stable interface and everything that depends on it are untouched.
Encapsulation: Not Just Private Fields
Encapsulation means hiding implementation details behind stable interfaces. Most programmers understand this at the field level (private fields, public getters). The more important form is at the module and service level.
A module boundary is the interface other code uses to interact with a domain. If the internals of a module change — the data model, the processing logic, the storage structure — that change should not propagate to callers.
// Module public interface: stable, intentional
public interface OrderService {
OrderId placeOrder(PlaceOrderCommand command);
Optional<OrderSummary> findOrder(OrderId id);
void cancelOrder(OrderId id, CancellationReason reason);
}
// Internal: volatile, hidden
class OrderDomainService implements OrderService {
private final OrderRepository repository;
private final InventoryPort inventory;
private final OrderPricingEngine pricingEngine; // Can change without affecting callers
// Internal implementation changes here don't break callers
}
The test for encapsulation: if you need to change the internal representation of orders (from a single table to multiple tables, from synchronous to event-sourced), how many call sites need to change? If the answer is “zero — they use the interface,” the encapsulation is working.
Stable Interfaces: What You Publish Is a Contract
Any interface you expose to callers is a commitment. Breaking it requires coordinating with all callers — and in a distributed system, that means versioned APIs, migration periods, and careful rollout.
Design published interfaces conservatively:
- Return only what callers need: a response that includes everything returns data that callers then depend on, even if you didn’t intend them to
- Don’t expose internal concepts: internal IDs, internal statuses, database structure — these change and you’ll have callers depending on the old form
- Use explicit versioning for breaking changes
// Don't return the internal entity directly
@GetMapping("/orders/{id}")
public ResponseEntity<OrderResponse> getOrder(@PathVariable String id) {
Order order = orderService.findOrder(id);
return ResponseEntity.ok(orderMapper.toResponse(order)); // Explicit mapping
}
// OrderResponse contains only what callers need
public record OrderResponse(
String id,
String status,
BigDecimal total,
String currency,
Instant placedAt
// Not: internal ID, database rowversion, audit columns
) {}
When you need to change the response, you can do it without breaking callers — the mapping layer absorbs the change.
Boundaries and Feature Flags
Feature boundaries are a design tool. When new functionality is added behind a clear boundary, it can be:
- Tested independently
- Deployed without affecting existing behavior
- Removed cleanly if it doesn’t work out
// Clear boundary: new checkout flow behind interface
public interface CheckoutProcessor {
CheckoutResult process(CheckoutRequest request);
}
@Service
@ConditionalOnProperty(name = "feature.new-checkout", havingValue = "true")
public class NewCheckoutProcessor implements CheckoutProcessor { ... }
@Service
@ConditionalOnProperty(name = "feature.new-checkout", havingValue = "false", matchIfMissing = true)
public class LegacyCheckoutProcessor implements CheckoutProcessor { ... }
The boundary makes the two implementations independently modifiable. The flag controls which is active. Both can be maintained in parallel during a migration period, then the legacy one removed cleanly.
Designing Around Likely Changes
Over-engineering happens when you design for hypothetical changes that never happen. Under-engineering happens when you design for a static system that turns out to need significant change.
The sweet spot: design around the changes that are reasonably likely given what you know about the domain.
Likely to change:
- Business rules in domains being actively developed
- External integrations (APIs change, providers switch)
- UI/presentation layer details
- Configuration and operational parameters
Less likely to change:
- Core domain concepts that are well-established
- Infrastructure primitives (relational database storage)
- Mathematical operations, validation rules
For things likely to change, invest in abstraction and boundary clarity. For things unlikely to change, don’t over-engineer.
// Pricing rule — likely to change
public interface PricingStrategy {
Money calculatePrice(Product product, Customer customer, int quantity);
}
// Standard tax calculation — unlikely to change for a given jurisdiction
// Direct implementation is fine, no abstraction layer needed
public Money calculateVAT(Money amount) {
return amount.multiply(VAT_RATE);
}
Configuration Over Hardcoding
Configuration at the edges of your system — timeouts, batch sizes, feature thresholds, third-party endpoints — changes frequently and should be externalized.
// Hardcoded: requires a deployment to change
private static final int BATCH_SIZE = 100;
private static final Duration TIMEOUT = Duration.ofSeconds(5);
// Configurable: can change without deployment
@ConfigurationProperties(prefix = "processing")
public record ProcessingProperties(
int batchSize,
Duration timeout,
boolean enableRetry
) {}
What belongs in configuration vs code:
- Configuration: operational parameters, timeouts, batch sizes, feature flags, environment-specific URLs
- Code: business rules, validation logic, algorithm selection
Business rules in configuration files become untestable and auditable only by checking config history. Configuration files that contain business logic are the same problem in the other direction.
The Cost of Not Designing for Change
A system that can’t be changed without risk is a system that stops evolving. Features take longer because every change is a mine field. Engineers avoid refactoring because the risk isn’t worth the benefit. The system calcifies around its first design, regardless of how well that design fit the original requirements.
The practical test: when you need to make a change, how confident are you that the change is correct and complete? How confident are you that you haven’t broken something else?
High confidence comes from:
- Clear boundaries that limit the scope of changes
- Tests that verify behavior independent of implementation
- Explicit interfaces that define what callers depend on
- Observable, understandable code that can be reasoned about
These properties are worth investing in — not because change is inevitable (it is), but because they reduce the cost of change over the lifetime of the system. That cost compounds. Systems designed for change get cheaper to operate over time; systems that aren’t designed for change get more expensive.