Question 19 · Section 18

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.

Language versions: English Russian Ukrainian

🟢 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

  1. Mistake: Adding new functionality through an if-else chain instead of polymorphism. Solution: Use the Strategy pattern — each if becomes a separate interface implementation.

  2. Mistake: Modifying an existing class with every requirement change. Solution: Ask yourself — can I create a new class instead of changing the old one?

  3. 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-else is 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

  1. Over-Abstraction Trap: Chasing extensibility creates 10 layers of abstraction. Adding one field to the DB requires changes in EntityDTOMapperServiceControllerRequestResponse. 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.

  2. 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.

  3. 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.
  • Java 21 Record Patterns + Pattern Matching: Simplify variant handling without inheritance, but OCP still applies through sealed interfaces:
    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 @RegisterForReflection and native-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]]