How Do SOLID Principles Help When Extending Functionality?
Imagine LEGO: to add a roof to a house, you don't tear down the walls — you just place a new block on top. SOLID turns your code into such a building set.
🟢 Junior Level
SOLID principles are a set of code design rules that make a system easily extensible. The main idea: when you need to add a new capability, you write new code rather than rewriting old code.
Imagine LEGO: to add a roof to a house, you don’t tear down the walls — you just place a new block on top. SOLID turns your code into such a building set.
Example without OCP (bad):
public class PaymentProcessor {
public void process(String type, BigDecimal amount) {
if (type.equals("card")) {
processCard(amount);
} else if (type.equals("paypal")) {
processPaypal(amount);
}
// To add SBP, you need to change this method
}
}
Example with OCP (good):
public interface PaymentProvider {
void pay(BigDecimal amount);
}
public class CardPaymentProvider implements PaymentProvider {
@Override
public void pay(BigDecimal amount) {
System.out.println("Card payment: " + amount);
}
}
public class PaymentProcessor {
private final List<PaymentProvider> providers;
public PaymentProcessor(List<PaymentProvider> providers) {
this.providers = providers;
}
public void process(Class<? extends PaymentProvider> type, BigDecimal amount) {
providers.stream()
.filter(p -> p.getClass().equals(type))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown provider"))
.pay(amount);
}
}
// Adding a new payment method — a new class, no changes to old code
public class SbpPaymentProvider implements PaymentProvider {
@Override
public void pay(BigDecimal amount) {
System.out.println("SBP payment: " + amount);
}
}
When to use:
- When you expect functionality to grow (new payment types, report formats, notification channels)
- When a team of several people works on the project
- When frequent releases and changes are the norm
🟡 Middle Level
Cost of Change and SOLID
In software architecture, there is a concept of “cost of change.” Without SOLID, this curve typically grows: each new feature breaks something old, requires more testing and refactoring. SOLID helps keep this curve flatter.
How Each Principle Helps Extension
| Principle | Extension Mechanism | Project Example |
|---|---|---|
| SRP | Localized changes — changing one responsibility doesn’t affect others | Changed PDF receipt generation — didn’t break tax calculation |
| OCP | Adding new code without modifying old | New payment provider — one new class, zero changes to existing ones |
| LSP | Substitution guarantee — new subtypes work in existing algorithms | New discount type correctly handled by cart without changes |
| ISP | Narrow interfaces — new clients depend only on needed methods | Notification microservice doesn’t pull the entire UserService interface |
| DIP | Dependency on abstractions — swapping implementation without rewriting clients | Switching from PostgreSQL to ClickHouse only in the repository layer |
Common Mistakes
-
Mistake: Adding new functionality through an
if-elsechain instead of polymorphism. Solution: Use the Strategy pattern — eachifbecomes a separate interface implementation. -
Mistake: Modifying an existing class with every requirement change. Solution: Ask yourself — can I create a new class instead of changing the old one?
-
Mistake: LSP violation during extension — a new subtype breaks existing code. Solution: Ensure the new subtype doesn’t throw unexpected exceptions or change the contract.
When NOT to Apply SOLID for Extension
- Prototype / MVP: Speed matters more than extensibility. A simple
if-elseis better than 10 interfaces. - One-time script: Data migration, logging utility — here SOLID only slows you down.
- Stable module: If a module hasn’t changed in 2 years and isn’t planned to — don’t “prepare it for the future.”
Approach Comparison
| Approach | Speed Now | Speed in a Year | Maintainability |
|---|---|---|---|
| Without SOLID | Fast | Slow (spaghetti) | Low |
| With SOLID | Slower | Fast (building set) | High |
| Over-engineering | Very slow | Slow (10 layers) | Low |
🔴 Senior Level
Internal Implementation: Why OCP Works
The Open/Closed Principle (OCP) works through dynamic dispatch (virtual dispatch) in the JVM. When you call a method through an interface:
paymentProvider.pay(amount);
The JVM doesn’t know at compile time which class will be called. At runtime, it uses a vtable (virtual method table), which allows substituting any implementation without recompiling the calling code. This is the “extension without modification” mechanism.
Performance cost: Virtual call costs ~1-3ns vs ~0.3ns for a static (direct call). The JIT compiler compensates through inline caching — if the same type is called in 95% of cases, the call gets devirtualized.
Architectural Trade-offs
Full SOLID:
- ✅ Pros: Minimal blast radius for changes; high testability; easy component replacement
- ❌ Cons: File count bloat (10-50x); cognitive load; allocation and call overhead
Pragmatic SOLID:
- ✅ Pros: Balance between development speed and maintainability; SOLID only where real variability exists
- ❌ Cons: Requires experienced architects; need to predict what will change
Without SOLID:
- ✅ Pros: Maximum speed at start; less boilerplate
- ❌ Cons: Exponential growth in change cost; regressions with every change; impossible parallel team work
Edge Cases
-
Over-Abstraction Trap: Chasing extensibility creates 10 layers of abstraction. Adding one field to the DB requires changes in
Entity→DTO→Mapper→Service→Controller→Request→Response. Solution: Apply SOLID only where the business actually expects changes. Use YAGNI (You Aren’t Gonna Need It: don’t create code for hypothetical future requirements) as a counterweight. -
LSP Violation in “Invisible” Behavior: A subtype formally respects the contract but changes side effects (e.g., caches results when the base class doesn’t). Solution: Document not only signatures but also side effects, consistency guarantees, caching expectations.
-
DIP in Legacy Code: A class directly calls
new JdbcTemplate(dataSource)— impossible to replace the implementation. Solution: Use the Strangler Fig pattern (gradual replacement of legacy code with new code, where the new code wraps the old and gradually intercepts more and more calls) — gradually wrap direct calls in interfaces, redirecting traffic to the new layer.
Performance
- Inline caching: Hotspot JIT, after ~15-30 calls of the same type, deoptimizes the virtual call into a direct one. If types vary (megamorphic), the call goes through
itable— ~10-20ns. - Memory overhead: Each interface + implementation = 2 class objects in metaspace (~1-3KB each). With 500 interfaces — ~1-1.5MB metaspace.
- GC impact: Additional objects for delegation (especially Decorator/Adapter patterns) increase young generation allocations. For highload systems with >100K RPS, this can be noticeable.
Production Experience
War Story: E-commerce Payment System (2023)
A team of 12 developers maintained a Spring Boot 3.x monolith. Initially, there was one PaymentService with if-else for 15 providers. Each new integration (SBP, SberPay, QR) took 3-5 days and required regression testing of the entire module.
Refactoring:
public interface PaymentGateway {
PaymentResult process(PaymentRequest request);
boolean supports(PaymentMethod method);
}
@Component
public class PaymentOrchestrator {
private final List<PaymentGateway> gateways;
public PaymentOrchestrator(List<PaymentGateway> gateways) {
this.gateways = gateways;
}
public PaymentResult process(PaymentRequest request) {
return gateways.stream()
.filter(g -> g.supports(request.getMethod()))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentException(request.getMethod()))
.process(request);
}
}
Spring automatically injects all @Component implementations of PaymentGateway. A new provider — one new class annotated with @Component. Integration time for a new provider dropped from 3-5 days to 1-2 hours. Regression bugs dropped significantly.
Monitoring and Diagnostics
- ArchUnit: Automatic SOLID checks in CI/CD:
@ArchTest static final ArchRule no_service_should_depend_on_concrete_repository = classes() .that().haveSimpleNameEndingWith("Service") .should().onlyDependOnClassesThat().haveNameMatching(".*Repository") .orShould().onlyDependOnClassesThat().haveNameMatching(".*Mapper"); - SonarQube: “Cognitive Complexity” metric — if an extension method grows beyond 15, it’s a signal of OCP violation.
- Regression Rate: Track the number of bugs in old code after adding new features. If it grows — the architecture isn’t SOLID.
- Lead Time for Changes (DORA metric): In SOLID projects, it stays stable; in “spaghetti” — grows each quarter.
Best Practices for Highload
- Feature Toggles: OCP + DIP allow keeping two implementations (old and new) and switching via config without rebuilding. This is critical for canary deploys.
- Stateless Design (follows from SRP): Each handler doesn’t store state — easily scales horizontally.
- Avoid Speculative Generality (the anti-pattern of “abstractions in reserve” — creating interfaces and layers for hypothetical future requirements): Don’t create abstractions “for the future.” Experience shows most “planned” extension points are never used.
Future Trends
- Java 21 Record Patterns + Pattern Matching: Simplify variant handling without inheritance, but OCP still applies through
sealedinterfaces:public sealed interface PaymentMethod permits Card, Crypto, Sbp {} public final class Card implements PaymentMethod { /* ... */ } // The compiler guarantees exhaustiveness — a new type requires changes in match, // but DIP allows adding handling without changing existing code. - GraalVM Native Image: Static compilation makes dynamic extension (SPI) harder. Requires explicit registration via
@RegisterForReflectionandnative-image.properties.
Summary
- SOLID turns a monolithic rock into a LEGO set: new code attaches rather than embeds.
- The best code for extension is the code you don’t touch when extending the system.
- SOLID is an investment in the future, but the investment must be justified by real business needs.
🎯 Interview Cheat Sheet
Must know:
- SOLID turns code into a LEGO set: new code attaches, not embeds
- SRP localizes changes, OCP adds without modification, LSP guarantees substitution
- ISP — narrow interfaces, new clients depend only on needed methods
- DIP — swapping implementation without rewriting clients (PostgreSQL → ClickHouse only in repository)
- Virtual call ~1-3ns vs direct ~0.3ns; JIT compensates through inline caching
- Orchestrator Pattern: Spring injects all implementations, new provider — one class
Frequent follow-up questions:
- Cost of extension without SOLID? — Exponential growth: each feature breaks old code, 3-5 days → 1-2 hours
- What is the Over-Abstraction Trap? — 10 layers of abstraction, adding a field requires changes in 6+ classes
- Feature Toggles and OCP? — OCP + DIP allow keeping two implementations and switching via config (canary deploy)
- What is Speculative Generality? — Anti-pattern of “abstractions in reserve”; don’t create what will never be needed
Red flags (DO NOT say):
- “SOLID is always needed for extension” (prototypes/MVPs — speed matters more)
- “More abstractions = better” (10 layers = over-engineering, cognitive load)
- “OCP means old code never changes” (sometimes refactoring old code is the right path)
Related topics:
- [[3. What is Open Closed principle]]
- [[9. Why do we need SOLID principles at all]]
- [[20. Can you follow all SOLID principles at once]]
- [[22. What anti-patterns contradict SOLID principles]]