Open Closed Principle

21 min read

Object-Oriented Design and SOLID Review — review Java code for growing conditionals that should open to extension instead of repeatedly reopening stable logic.

1. Introduction

The Open Closed Principle, commonly called OCP, is one of the most practical SOLID principles when Java applications start supporting multiple business variants.

A simple application often begins with logic such as:

JAVA
if (paymentType.equals("CARD")) {
    processCardPayment();
} else if (paymentType.equals("UPI")) {
    processUpiPayment();
}

Initially, this may be acceptable.

Later the business adds:

  • Net banking
  • Wallet
  • Buy Now Pay Later
  • International cards
  • Bank transfer

The same method keeps growing.

Every new payment type requires developers to modify existing working code.

That increases:

  • Regression risk
  • Pull Request size
  • Testing effort
  • Merge conflicts
  • Cognitive complexity

The Open Closed Principle helps developers design stable components that can support new behavior with minimal modification to existing tested logic.

The principle is commonly summarized as:

Software entities should be open for extension but closed for modification.

In practical Java development, this means:

  • New behavior should often be added through new implementations.
  • Stable existing workflow code should not require repeated modification for every new variation.

OCP is especially useful when the business has a clear family of behaviors that is expected to grow.

Examples include:

  • Payment methods
  • Notification channels
  • Discount rules
  • Shipping providers
  • Export formats
  • Authentication methods
  • File processors
  • Tax calculators
  • External vendor integrations

2. What This Topic Means

Open for extension means new functionality can be added.

Closed for modification means existing stable code does not need substantial changes every time the new functionality is introduced.

Consider notification delivery.

A poor design may contain:

JAVA
if ("EMAIL".equals(type)) {
    sendEmail();
} else if ("SMS".equals(type)) {
    sendSms();
} else if ("PUSH".equals(type)) {
    sendPush();
}

When WhatsApp notification is introduced, developers must edit this existing method.

If the application uses this conditional in several locations, every location must be updated correctly.

A more extensible design defines a common contract:

JAVA
public interface NotificationSender {
    NotificationType supports();
    void send(NotificationMessage message);
}

Each channel implements that contract:

JAVA
EmailNotificationSender
SmsNotificationSender
PushNotificationSender
WhatsAppNotificationSender

The orchestration code works with the abstraction.

New channels can be added primarily by creating new implementations.

This is the practical goal of OCP.

3. Why It Matters in Real Projects

Maintainability

Systems change continuously.

If every feature addition requires editing large conditional blocks, the application becomes increasingly difficult to maintain.

OCP helps isolate variation.

Reliability

Existing code that is already working and tested is less likely to break when new behavior can be added without changing its core decision logic.

Testing

Each new implementation can have focused tests.

Existing implementations do not need extensive regression changes just because another variation is introduced.

Team Development

Different developers can work on different implementations with fewer conflicts.

For example:

  • One developer adds WhatsApp notification.
  • Another modifies SMS behavior.
  • Another maintains email.

They are less likely to edit the same large switch statement.

Readability

A registry of well-named implementations usually communicates supported behavior more clearly than a large block of conditions.

Scalability of Features

As the number of variants grows, extensible designs scale better organizationally.

OCP primarily helps codebase scalability rather than raw application runtime scalability.

4. Core Concept

The key idea is:

Identify behavior that varies and place it behind a stable abstraction when that variation is real and expected.

Consider payment processing.

The stable business workflow may be:

JAVA
Validate payment request
Find payment processor
Process payment
Save payment result
Return response

The variable part is:

JAVA
How the payment is processed.

That variable responsibility can be represented by an interface.

JAVA
public interface PaymentProcessor {
    PaymentMethod supports();
    PaymentResult process(PaymentRequest request);
}

Implementations may include:

JAVA
CardPaymentProcessor
UpiPaymentProcessor
WalletPaymentProcessor

The orchestration service does not need a growing if/else chain.

It simply asks a registry for the correct processor.

This keeps the workflow stable while allowing processing strategies to expand.

5. Important Rules

  • Apply OCP where real variation exists.
  • Do not introduce abstractions for hypothetical future requirements.
  • Prefer abstractions around stable business contracts.
  • Avoid growing if/else or switch blocks that select implementations repeatedly.
  • Keep strategy selection in one clear place.
  • Use enums or domain types instead of arbitrary strings where appropriate.
  • Make unsupported variants fail explicitly.
  • Keep each implementation independently testable.
  • Do not modify unrelated existing implementations when adding a new variant.
  • Avoid reflection-based magic unless there is a strong reason.
  • Prefer constructor injection.
  • Avoid service-locator patterns with hidden global dependencies.
  • Keep the common interface small and behavior-focused.
  • Do not force unrelated implementations behind the same interface.
  • Keep transaction behavior consistent across implementations.
  • Ensure exception semantics are predictable.
  • Avoid creating interfaces for classes that have no meaningful variation.
  • Do not treat every conditional statement as an OCP violation.
  • Prefer simple conditionals when the behavior is stable and unlikely to expand.
  • Use Spring dependency injection where it naturally supports multiple implementations.

6. Bad Code Example

Consider a Spring Boot refund service supporting several refund channels.

JAVA
@Service
public class RefundProcessorService {
    private final CardGateway cardGateway;
    private final UpiGateway upiGateway;
    private final WalletGateway walletGateway;
    private final RefundRepository refundRepository;

    public RefundProcessorService(CardGateway cardGateway,
                                  UpiGateway upiGateway,
                                  WalletGateway walletGateway,
                                  RefundRepository refundRepository) {
        this.cardGateway = cardGateway;
        this.upiGateway = upiGateway;
        this.walletGateway = walletGateway;
        this.refundRepository = refundRepository;
    }

    @Transactional
    public RefundResponse processRefund(RefundRequest request) {
        if (request == null) {
            throw new IllegalArgumentException("Refund request is required");
        }

        if (request.getPaymentMethod() == null) {
            throw new IllegalArgumentException("Payment method is required");
        }

        String refundReference;

        if ("CARD".equals(request.getPaymentMethod())) {
            refundReference = cardGateway.refund(
                    request.getTransactionId(),
                    request.getAmount()
            );
        } else if ("UPI".equals(request.getPaymentMethod())) {
            refundReference = upiGateway.refund(
                    request.getTransactionId(),
                    request.getAmount()
            );
        } else if ("WALLET".equals(request.getPaymentMethod())) {
            refundReference = walletGateway.refund(
                    request.getTransactionId(),
                    request.getAmount()
            );
        } else {
            throw new UnsupportedOperationException(
                    "Unsupported payment method: "
                            + request.getPaymentMethod()
            );
        }

        Refund refund = new Refund();
        refund.setTransactionId(request.getTransactionId());
        refund.setPaymentMethod(request.getPaymentMethod());
        refund.setAmount(request.getAmount());
        refund.setRefundReference(refundReference);
        refund.setStatus(RefundStatus.SUCCESS);
        refund.setCreatedAt(LocalDateTime.now());

        Refund savedRefund = refundRepository.save(refund);

        RefundResponse response = new RefundResponse();
        response.setRefundId(savedRefund.getId());
        response.setRefundReference(savedRefund.getRefundReference());
        response.setStatus(savedRefund.getStatus());

        return response;
    }
}

7. Problems in the Bad Code

Every New Payment Method Requires Modification

If bank-transfer refunds are introduced, developers must edit:

JAVA
processRefund()

This method may already be stable and tested.

Growing Conditional Logic

Each payment provider adds another branch.

Over time the method becomes harder to understand.

High Coupling

RefundProcessorService directly depends on every payment gateway.

As more methods are added, constructor dependencies continue growing.

String-Based Selection

Values such as:

JAVA
"CARD"
"UPI"
"WALLET"

are weakly typed.

Typos can cause runtime failures.

Increasing Regression Surface

Adding one payment method requires modifying code responsible for all existing payment methods.

Difficult Independent Ownership

Developers working on different payment providers must modify the same service.

Repeated Pattern Risk

Similar payment-type checks may start appearing in:

  • Payment processing
  • Refund processing
  • Status lookup
  • Reconciliation

This spreads provider-selection logic across the application.

8. Code Review Findings

A senior reviewer should notice:

  • The if/else chain is selecting behavior that is likely to continue expanding.
  • The service directly depends on every gateway implementation.
  • New providers require modifying stable orchestration code.
  • Payment method should be represented with a domain type rather than raw strings.
  • Provider-specific behavior belongs behind a stable contract.
  • Unsupported payment methods should fail in one predictable location.
  • The service should coordinate refund processing rather than know how every provider performs a refund.
  • Provider selection should not be duplicated across multiple services.
  • A registry or strategy-based design would localize extension.
  • Refactoring should remain proportional to the actual variation.

9. Reviewer Comment Example

A useful PR comment could be:

This payment-method branch is already selecting three provider-specific implementations and will need modification for every new provider. Could we introduce a RefundProcessor contract and resolve the processor by PaymentMethod so adding another provider does not require changing this service?

Another:

Please consider replacing the raw payment-method strings with an enum. The current selection logic is vulnerable to runtime typos and makes supported methods harder to discover.

Another:

If this is the only stable two-case branch and no additional variants are expected, a strategy hierarchy may be unnecessary. OCP should be applied where extension pressure is real rather than preemptively.

10. Improved Code

Payment Method

JAVA
public enum PaymentMethod {
    CARD,
    UPI,
    WALLET
}

Refund Processor Contract

JAVA
public interface RefundProcessor {
    PaymentMethod supports();
    String process(RefundRequest request);
}

Card Refund Processor

JAVA
@Component
public class CardRefundProcessor
        implements RefundProcessor {

    private final CardGateway cardGateway;

    public CardRefundProcessor(
            CardGateway cardGateway) {
        this.cardGateway = cardGateway;
    }

    @Override
    public PaymentMethod supports() {
        return PaymentMethod.CARD;
    }

    @Override
    public String process(
            RefundRequest request) {

        return cardGateway.refund(
                request.getTransactionId(),
                request.getAmount()
        );
    }
}

UPI Refund Processor

JAVA
@Component
public class UpiRefundProcessor
        implements RefundProcessor {

    private final UpiGateway upiGateway;

    public UpiRefundProcessor(
            UpiGateway upiGateway) {
        this.upiGateway = upiGateway;
    }

    @Override
    public PaymentMethod supports() {
        return PaymentMethod.UPI;
    }

    @Override
    public String process(
            RefundRequest request) {

        return upiGateway.refund(
                request.getTransactionId(),
                request.getAmount()
        );
    }
}

Wallet Refund Processor

JAVA
@Component
public class WalletRefundProcessor
        implements RefundProcessor {

    private final WalletGateway walletGateway;

    public WalletRefundProcessor(
            WalletGateway walletGateway) {
        this.walletGateway = walletGateway;
    }

    @Override
    public PaymentMethod supports() {
        return PaymentMethod.WALLET;
    }

    @Override
    public String process(
            RefundRequest request) {

        return walletGateway.refund(
                request.getTransactionId(),
                request.getAmount()
        );
    }
}

Refund Processor Registry

JAVA
@Component
public class RefundProcessorRegistry {
    private final Map<PaymentMethod, RefundProcessor> processors;

    public RefundProcessorRegistry(
            List<RefundProcessor> refundProcessors) {

        this.processors = refundProcessors
                .stream()
                .collect(
                        Collectors.toUnmodifiableMap(
                                RefundProcessor::supports,
                                Function.identity()
                        )
                );
    }

    public RefundProcessor getProcessor(
            PaymentMethod paymentMethod) {

        RefundProcessor processor =
                processors.get(paymentMethod);

        if (processor == null) {
            throw new UnsupportedPaymentMethodException(
                    paymentMethod
            );
        }

        return processor;
    }
}

Refund Service

JAVA
@Service
public class RefundService {
    private final RefundProcessorRegistry processorRegistry;
    private final RefundRepository refundRepository;

    public RefundService(
            RefundProcessorRegistry processorRegistry,
            RefundRepository refundRepository) {
        this.processorRegistry = processorRegistry;
        this.refundRepository = refundRepository;
    }

    @Transactional
    public RefundResponse processRefund(
            RefundRequest request) {

        validateRequest(request);

        RefundProcessor processor =
                processorRegistry.getProcessor(
                        request.getPaymentMethod()
                );

        String refundReference =
                processor.process(request);

        Refund refund = createRefund(
                request,
                refundReference
        );

        Refund savedRefund =
                refundRepository.save(refund);

        return toResponse(savedRefund);
    }

    private void validateRequest(
            RefundRequest request) {

        if (request == null) {
            throw new IllegalArgumentException(
                    "Refund request is required"
            );
        }

        if (request.getPaymentMethod() == null) {
            throw new IllegalArgumentException(
                    "Payment method is required"
            );
        }

        if (request.getAmount() == null
                || request.getAmount()
                .compareTo(BigDecimal.ZERO) <= 0) {

            throw new IllegalArgumentException(
                    "Refund amount must be greater than zero"
            );
        }
    }

    private Refund createRefund(
            RefundRequest request,
            String refundReference) {

        Refund refund = new Refund();

        refund.setTransactionId(
                request.getTransactionId()
        );

        refund.setPaymentMethod(
                request.getPaymentMethod()
        );

        refund.setAmount(
                request.getAmount()
        );

        refund.setRefundReference(
                refundReference
        );

        refund.setStatus(
                RefundStatus.SUCCESS
        );

        refund.setCreatedAt(
                LocalDateTime.now()
        );

        return refund;
    }

    private RefundResponse toResponse(
            Refund refund) {

        RefundResponse response =
                new RefundResponse();

        response.setRefundId(
                refund.getId()
        );

        response.setRefundReference(
                refund.getRefundReference()
        );

        response.setStatus(
                refund.getStatus()
        );

        return response;
    }
}

11. Improved Code Explanation

Stable Refund Workflow

RefundService no longer knows how card, UPI, or wallet refunds work.

Its responsibility is:

  • Validate
  • Select processor
  • Execute refund
  • Persist result
  • Return response

Provider Behavior Is Extensible

Each provider implements:

JAVA
RefundProcessor

Adding another provider requires a new implementation rather than another branch in RefundService.

Registry Centralizes Selection

RefundProcessorRegistry keeps strategy resolution in one place.

Other services do not need to repeat provider-selection logic.

Enum Replaces Strings

PaymentMethod provides:

  • Type safety
  • IDE support
  • Discoverability
  • Compile-time references

Dependencies Are Reduced

The orchestration service no longer depends directly on every gateway.

Existing Strategies Remain Stable

Adding another implementation normally does not require editing:

JAVA
CardRefundProcessor
UpiRefundProcessor
WalletRefundProcessor

This is the practical benefit of OCP.

12. Bad Code vs Improved Code

AreaBad CodeImproved Code
Adding providerModify existing if/else chainAdd processor implementation
CouplingService knows every gatewayService depends on processor abstraction
ReadabilityProvider logic mixed togetherEach provider has focused implementation
TestabilityAll branches tested through one serviceEach processor can be tested separately
Regression riskExisting selection code changesStable orchestration remains mostly unchanged
Team developmentDevelopers modify same classProvider implementations are separated
Type safetyRaw stringsEnum
Provider discoveryHidden in conditionsExplicit implementations

13. Real Project Scenario

Consider a reporting platform used by an enterprise application.

Initially it supports:

  • CSV export
  • Excel export

The implementation contains:

JAVA
if (format == CSV) {
    generateCsv();
} else if (format == EXCEL) {
    generateExcel();
}

Later customers request:

  • PDF
  • JSON
  • XML
  • Fixed-width files

The export method grows steadily.

Each format requires different:

  • Libraries
  • Formatting rules
  • Streaming behavior
  • Error handling
  • Content types

Eventually developers must modify one central class for every new format.

A better design defines:

JAVA
ReportExporter

with implementations such as:

JAVA
CsvReportExporter
ExcelReportExporter
PdfReportExporter
JsonReportExporter

The report service coordinates export while individual exporters own format-specific behavior.

When PDF support is added, existing CSV and Excel implementations do not require modification.

14. Production Impact

OCP violations mainly increase production risk during ongoing feature development.

Regression Bugs

Adding a new branch can accidentally alter the ordering or logic of existing branches.

Unsupported Cases

A developer may update one conditional block but forget another similar block elsewhere.

For example, a new payment method may work for payment creation but fail during refund.

Inconsistent Behavior

Duplicated variant-selection logic can evolve differently across modules.

Difficult Rollbacks

Changes to one large implementation may contain modifications for both existing and new behavior, making rollback riskier.

Slower Incident Investigation

Large switch-based processors are harder to isolate during production failures.

Maintenance Cost

Every new variation makes the central class more complex.

15. Common Developer Mistakes

Treating Every if Statement as an OCP Violation

This is incorrect.

Simple business conditions do not automatically require polymorphism.

For example:

JAVA
if (orderTotal.compareTo(FREE_SHIPPING_LIMIT) >= 0) {
    shippingFee = BigDecimal.ZERO;
}

This is a simple rule, not necessarily a strategy family.

Introducing Interfaces Too Early

Developers sometimes create an interface for every service even when only one implementation exists and no real variation is expected.

Predicting Too Many Future Requirements

OCP should address known or strongly expected variation.

Do not design ten extension points for imaginary scenarios.

Huge Strategy Interfaces

Bad:

JAVA
public interface PaymentProcessor {
    void pay();
    void refund();
    void cancel();
    void reconcile();
    void sendReceipt();
    void generateReport();
}

Not every provider may support all operations.

The abstraction may be too broad.

Using Reflection to Avoid Conditionals

Replacing clear code with dynamic reflection-based loading can reduce readability and safety.

Hiding Dependency Selection

A static service locator may technically support extension but make dependencies harder to understand and test.

Duplicating Registries

Selection logic should not be independently rebuilt in several modules.

16. Edge Cases

Unsupported Implementation

A request may refer to a valid enum value for which no processor is registered.

Fail explicitly.

Duplicate Implementations

If two implementations declare:

JAVA
supports() == PaymentMethod.CARD

the registry should fail during startup rather than select one unpredictably.

Collectors.toUnmodifiableMap() naturally detects duplicate keys unless a merge function is supplied.

This can be useful because duplicate processor ownership should normally be considered a configuration error.

Missing Strategy

Spring configuration may exclude an implementation due to profiles or conditional beans.

Startup or integration tests should detect missing required strategies.

Null Strategy Key

Do not allow null payment methods to reach strategy resolution.

Provider-Specific Validation

Some processors may require additional fields.

For example:

  • Card refund may require gateway transaction ID.
  • Wallet refund may require wallet account ID.

Keep generic validation in the orchestration layer and provider-specific validation inside the appropriate implementation.

Transaction Behavior

Different implementations may perform different external calls.

Review whether all strategies should participate in the same transaction semantics.

Concurrency

Registries should normally be immutable after construction.

Avoid mutable runtime registration unless there is a real requirement.

17. Performance Considerations

OCP is primarily about maintainability and extension safety.

The difference between:

JAVA
if/else

and:

JAVA
Map lookup + interface call

is usually insignificant in enterprise backend applications.

Database and network I/O dominate runtime cost.

Registry Lookup

A map-based lookup is typically constant-time:

JAVA
O(1)

Bean Count

Adding several small Spring strategy beans generally has negligible application impact.

External Calls

The important performance differences are usually provider-specific.

For example:

  • One gateway may respond in 100 ms.
  • Another may respond in 800 ms.

Dedicated processors make provider-specific performance easier to monitor.

Large Strategy Lists

Even dozens of strategy implementations are generally manageable.

Avoid scanning lists repeatedly when a pre-built map can perform direct lookup.

Premature Optimization

Do not replace a clean strategy design with complex dispatch logic merely to avoid one map lookup unless profiling proves it matters.

18. Security Considerations

OCP is not directly a security principle, but extension points can affect security.

Provider-Specific Credentials

Each external integration may require:

  • API keys
  • Client secrets
  • Certificates

These should remain in secure configuration and not be embedded inside strategy-selection logic.

Validation

New implementations must preserve required security validation.

Adding a new payment processor should not bypass:

  • Authentication
  • Authorization
  • Transaction ownership checks
  • Amount validation

Sensitive Logging

Each processor should follow consistent logging rules.

Do not log:

  • Card numbers
  • CVV
  • Access tokens
  • Passwords
  • Sensitive personal information

Untrusted Dynamic Extensions

Avoid dynamically loading arbitrary implementation classes from untrusted input.

Extension mechanisms should remain controlled by application configuration.

19. Testing Considerations

OCP-based designs should include both strategy-level and orchestration-level tests.

Processor Unit Tests

For CardRefundProcessor, verify:

  • Correct transaction ID sent
  • Correct amount sent
  • Gateway result returned
  • Gateway exception propagated or translated correctly

Registry Tests

Verify:

  • CARD resolves to CardRefundProcessor
  • UPI resolves to UpiRefundProcessor
  • WALLET resolves correctly
  • Unsupported type fails clearly
  • Duplicate registrations fail

Refund Service Tests

The orchestration service does not need to retest provider implementation details.

Verify:

  • Request validation
  • Processor selected
  • Refund persisted
  • Response returned

Extension Test

When adding a new provider:

JAVA
BankTransferRefundProcessor

tests should focus on the new provider.

Existing processor tests should remain unchanged.

This is an important practical sign that OCP is working.

Integration Tests

Spring integration tests should verify:

  • All required processor beans are registered
  • Registry wiring succeeds
  • Request deserialization maps to expected enum
  • Provider-specific configuration loads correctly

20. Refactoring Guidelines

Step 1: Identify Real Variation

Look for repeated conditionals such as:

JAVA
if type == A
else if type == B
else if type == C

especially where each branch invokes different implementation behavior.

Step 2: Verify Future Extension Pressure

Ask:

  • Have new variants already been added?
  • Are more expected?
  • Does selection logic appear in multiple places?

If not, keep the design simple.

Step 3: Define the Stable Contract

Extract the common behavior.

Example:

JAVA
RefundProcessor.process()

Step 4: Introduce Domain Type

Replace raw strings with an enum where appropriate.

Step 5: Move Existing Branches Incrementally

Create:

JAVA
CardRefundProcessor
UpiRefundProcessor
WalletRefundProcessor

Preserve existing behavior.

Step 6: Add Registry

Centralize implementation selection.

Step 7: Simplify Orchestrator

Remove provider-specific conditions.

Step 8: Run Regression Tests

Verify all existing provider behavior.

Step 9: Add New Variant

Once the refactoring is complete, verify that adding a new provider mostly requires:

  • New implementation
  • New enum/configuration value where necessary
  • New tests

without editing existing strategy logic.

21. Best Practices

  • Identify real extension points.
  • Keep stable workflows separate from variable behavior.
  • Use small behavior-focused interfaces.
  • Prefer enums for known domain variants.
  • Centralize strategy resolution.
  • Use constructor injection.
  • Keep strategy registries immutable.
  • Fail explicitly for unsupported variants.
  • Test implementations independently.
  • Keep provider-specific validation inside the appropriate strategy.
  • Keep common validation outside individual strategies.
  • Use Spring collections of beans when appropriate.
  • Prefer composition over large conditionals when behavior genuinely varies.
  • Keep exception contracts predictable across strategies.
  • Avoid modifying existing implementations when adding new variants.
  • Document important extension expectations where necessary.
  • Review whether the abstraction remains cohesive as new strategies are added.

22. Practices to Avoid

Large Growing switch Statements

Especially when each case contains substantial behavior.

Strategy for Every Tiny Condition

Polymorphism is unnecessary for simple stable conditions.

Marker Interfaces Without Behavior

An interface should represent a useful contract.

Reflection-Based Dispatch

Avoid replacing explicit dependency injection with obscure runtime class resolution.

Static Service Locators

These hide dependencies and make unit testing harder.

Generic "Processor" Hierarchies With Unrelated Behaviors

Do not force unrelated concepts into one extensibility mechanism.

Excessive Configuration

A simple mapping should not require hundreds of lines of framework configuration.

Changing Every Strategy for a New Strategy

If adding one implementation requires editing every existing implementation, the abstraction may be poor.

Over-Engineering

Do not build plugin architecture when the application only has two stable cases.

23. Code Review Checklist

A reviewer can ask:

  • Does this code contain a growing conditional that selects implementation behavior?
  • Is this behavior likely to gain additional variants?
  • Does adding a new variant require modifying stable existing logic?
  • Is the same type-selection logic duplicated elsewhere?
  • Could the varying behavior be represented behind a focused interface?
  • Is an abstraction genuinely useful here, or would it be over-engineering?
  • Are raw strings being used to identify domain variants?
  • Are unsupported variants handled explicitly?
  • Is strategy resolution centralized?
  • Does the orchestration service depend directly on every implementation?
  • Can a new implementation be added with minimal changes to existing strategies?
  • Are individual implementations independently testable?
  • Does the interface contain only behavior common to its implementations?
  • Are implementation-specific validation rules kept with the implementation?
  • Is shared validation duplicated across strategies?
  • Are provider credentials kept outside source code?
  • Are external calls visible and monitorable?
  • Are transaction semantics consistent?
  • Can duplicate strategy registrations be detected?
  • Has the new design reduced complexity rather than only moved it around?
  • Would a simple conditional actually be clearer for this requirement?

24. Common Pull Request Review Comments

  1. *This switch now contains provider-specific behavior for four payment methods. Since additional providers are expected, could we move this behind a PaymentProcessor contract and keep the checkout service closed to provider-specific changes?*
  1. *The new notification channel requires updates in three separate if/else blocks. Please consider centralizing channel selection so adding a channel has one clear extension point.*
  1. *I would avoid adding a strategy hierarchy for this two-state validation rule. The condition is stable and the abstraction would add more complexity than value.*
  1. *Please replace the raw provider strings with a domain enum so supported variants are explicit and type-safe.*
  1. *The orchestration service currently injects every gateway implementation directly. A processor registry would reduce coupling and make future provider additions more localized.*
  1. *The new strategy implements several methods by throwing UnsupportedOperationException. That suggests the interface may be too broad and should probably be split into smaller contracts.*
  1. *Could we fail application startup when duplicate processors register the same PaymentMethod? Silent selection would make configuration defects difficult to diagnose.*
  1. *Provider-specific validation is leaking into the central service. Please keep only common request validation here and move provider rules to the relevant processor.*
  1. *This reflection-based processor lookup removes the switch, but it also removes compile-time safety and makes dependencies harder to trace. Constructor-injected strategies would be clearer.*
  1. *Please verify that introducing another processor does not require changes to the existing processor tests. One goal of this extension point is to keep existing behavior stable.*

25. Code Review Exercise

Review the following notification service.

Identify:

  • Problems
  • Code smells
  • Risks
  • Improvements
JAVA
@Service
public class NotificationService {
    private final EmailClient emailClient;
    private final SmsClient smsClient;
    private final PushClient pushClient;

    public NotificationService(
            EmailClient emailClient,
            SmsClient smsClient,
            PushClient pushClient) {
        this.emailClient = emailClient;
        this.smsClient = smsClient;
        this.pushClient = pushClient;
    }

    public void send(
            String type,
            NotificationRequest request) {

        if ("EMAIL".equals(type)) {
            if (request.getEmail() == null) {
                throw new IllegalArgumentException(
                        "Email is required"
                );
            }

            emailClient.send(
                    request.getEmail(),
                    request.getSubject(),
                    request.getMessage()
            );

        } else if ("SMS".equals(type)) {
            if (request.getPhoneNumber() == null) {
                throw new IllegalArgumentException(
                        "Phone number is required"
                );
            }

            smsClient.send(
                    request.getPhoneNumber(),
                    request.getMessage()
            );

        } else if ("PUSH".equals(type)) {
            if (request.getDeviceToken() == null) {
                throw new IllegalArgumentException(
                        "Device token is required"
                );
            }

            pushClient.send(
                    request.getDeviceToken(),
                    request.getMessage()
            );

        } else {
            throw new IllegalArgumentException(
                    "Unsupported notification type"
            );
        }
    }
}

26. Exercise Solution

Review Findings

Growing Channel Selection

Every new notification channel requires editing NotificationService.

Raw String Type

The API uses unrestricted strings.

Channel-Specific Validation Is Centralized Incorrectly

Email validation belongs with email notification behavior.

SMS validation belongs with SMS behavior.

Push validation belongs with push behavior.

High Coupling

NotificationService directly depends on every channel implementation.

Future Changes

Adding WhatsApp would require:

  • New dependency
  • Constructor modification
  • New condition
  • New validation branch
  • New send logic

Improved Design

Notification Type

JAVA
public enum NotificationType {
    EMAIL,
    SMS,
    PUSH
}

Sender Contract

JAVA
public interface NotificationSender {
    NotificationType supports();
    void send(NotificationRequest request);
}

Email Sender

JAVA
@Component
public class EmailNotificationSender
        implements NotificationSender {

    private final EmailClient emailClient;

    public EmailNotificationSender(
            EmailClient emailClient) {
        this.emailClient = emailClient;
    }

    @Override
    public NotificationType supports() {
        return NotificationType.EMAIL;
    }

    @Override
    public void send(
            NotificationRequest request) {

        if (request.getEmail() == null
                || request.getEmail().isBlank()) {

            throw new IllegalArgumentException(
                    "Email is required"
            );
        }

        emailClient.send(
                request.getEmail(),
                request.getSubject(),
                request.getMessage()
        );
    }
}

SMS Sender

JAVA
@Component
public class SmsNotificationSender
        implements NotificationSender {

    private final SmsClient smsClient;

    public SmsNotificationSender(
            SmsClient smsClient) {
        this.smsClient = smsClient;
    }

    @Override
    public NotificationType supports() {
        return NotificationType.SMS;
    }

    @Override
    public void send(
            NotificationRequest request) {

        if (request.getPhoneNumber() == null
                || request.getPhoneNumber().isBlank()) {

            throw new IllegalArgumentException(
                    "Phone number is required"
            );
        }

        smsClient.send(
                request.getPhoneNumber(),
                request.getMessage()
        );
    }
}

Push Sender

JAVA
@Component
public class PushNotificationSender
        implements NotificationSender {

    private final PushClient pushClient;

    public PushNotificationSender(
            PushClient pushClient) {
        this.pushClient = pushClient;
    }

    @Override
    public NotificationType supports() {
        return NotificationType.PUSH;
    }

    @Override
    public void send(
            NotificationRequest request) {

        if (request.getDeviceToken() == null
                || request.getDeviceToken().isBlank()) {

            throw new IllegalArgumentException(
                    "Device token is required"
            );
        }

        pushClient.send(
                request.getDeviceToken(),
                request.getMessage()
        );
    }
}

Registry

JAVA
@Component
public class NotificationSenderRegistry {
    private final Map<NotificationType, NotificationSender> senders;

    public NotificationSenderRegistry(
            List<NotificationSender> notificationSenders) {

        this.senders = notificationSenders
                .stream()
                .collect(
                        Collectors.toUnmodifiableMap(
                                NotificationSender::supports,
                                Function.identity()
                        )
                );
    }

    public NotificationSender getSender(
            NotificationType type) {

        NotificationSender sender =
                senders.get(type);

        if (sender == null) {
            throw new UnsupportedNotificationTypeException(
                    type
            );
        }

        return sender;
    }
}

Notification Service

JAVA
@Service
public class NotificationService {
    private final NotificationSenderRegistry senderRegistry;

    public NotificationService(
            NotificationSenderRegistry senderRegistry) {
        this.senderRegistry = senderRegistry;
    }

    public void send(
            NotificationType type,
            NotificationRequest request) {

        if (type == null) {
            throw new IllegalArgumentException(
                    "Notification type is required"
            );
        }

        if (request == null) {
            throw new IllegalArgumentException(
                    "Notification request is required"
            );
        }

        NotificationSender sender =
                senderRegistry.getSender(type);

        sender.send(request);
    }
}

Why These Changes Are Useful

Notification selection is centralized.

Channel-specific validation lives with the corresponding channel behavior.

The orchestration service no longer depends directly on:

  • EmailClient
  • SmsClient
  • PushClient

Adding WhatsApp support can primarily involve:

JAVA
WhatsAppNotificationSender

and its tests.

Existing senders do not need modification.

The design is now open to adding channels while keeping the stable notification-dispatch workflow largely unchanged.

27. Interview Perspective

OCP frequently appears in Java and senior developer interviews through scenario-based questions.

Interviewers may ask:

  • What does open for extension and closed for modification mean?
  • Is changing existing code always wrong?
  • When should you replace a switch with polymorphism?
  • How does OCP apply in Spring Boot?
  • How would you support new payment providers?
  • How can Spring inject multiple implementations of an interface?
  • Is Strategy Pattern the same as OCP?
  • Can OCP lead to over-engineering?
  • When should you keep a simple conditional?
  • How do you test an OCP-based design?

Strong answers should explain that "closed for modification" does not mean code can literally never change.

Existing code can still be:

  • Fixed
  • Refactored
  • Improved

The principle means new variations should ideally not require continuously reopening stable central logic.

28. Interview Questions and Answers

Basic Question

Question: What is the Open Closed Principle?

Answer:

The Open Closed Principle means software components should be open to supporting new behavior but should minimize changes to stable existing code when new variations are added.

In Java, this is often achieved through:

  • Interfaces
  • Polymorphism
  • Composition
  • Strategy-style implementations

For example, adding a new payment processor should ideally involve adding a new PaymentProcessor implementation rather than modifying a large payment-method switch.

Intermediate Question

Question: Does every switch statement violate OCP?

Answer:

No.

A switch is not automatically a design problem.

If:

  • The set of cases is small
  • The behavior is simple
  • The cases are stable
  • New variants are unlikely

then a switch may be clearer than introducing polymorphism.

OCP becomes more valuable when variant-specific behavior is substantial and new variants are expected.

Advanced Question

Question: How would you implement OCP in a Spring Boot application with multiple payment providers?

Answer:

I would define a focused contract such as:

JAVA
PaymentProcessor

Each provider would implement it:

JAVA
CardPaymentProcessor
UpiPaymentProcessor
WalletPaymentProcessor

Spring can inject all implementations:

JAVA
List<PaymentProcessor>

A registry can convert that list into:

JAVA
Map<PaymentMethod, PaymentProcessor>

The payment orchestration service resolves the required processor and calls the common contract.

Adding another provider primarily requires another implementation rather than modifying the orchestration service.

Scenario-Based Question

Question: A service supports two report formats and is unlikely to support more. Should you immediately create an exporter hierarchy?

Answer:

Not necessarily.

If both branches are small and stable, a direct conditional may be easier to maintain.

I would introduce the abstraction when:

  • More formats are expected
  • Each format contains substantial behavior
  • Format-specific dependencies differ
  • The condition is duplicated
  • Existing code is repeatedly modified for new formats

OCP should solve observed design pressure rather than hypothetical complexity.

Code-Review Question

Question: What would you comment on a PR adding the fifth branch to a payment-provider switch?

Answer:

I would write something like:

This provider-selection block now changes every time we add a payment method. Since the provider behavior is clearly growing, could we move it behind a PaymentProcessor abstraction and resolve the implementation by PaymentMethod? That would keep the checkout workflow stable as new providers are added.

Real-Project Question

Question: What is the main production benefit of OCP?

Answer:

The main benefit is reducing the change surface for new features.

When a new provider or channel can be added as an independent implementation, existing tested behavior requires fewer changes.

That reduces:

  • Regression risk
  • Review complexity
  • Merge conflicts
  • Retesting of unrelated variants

It also improves ownership when different teams maintain different integrations.

29. Quick Rule to Remember

If adding the next variant means editing the same growing switch again, review whether the varying behavior needs a stable extension point.

Do not create abstractions merely because a conditional exists.

Create them when change repeatedly occurs along the same variation axis.

30. Final Takeaway

The Open Closed Principle is about designing useful extension boundaries.

It does not mean existing Java code should never be modified.

It means that when a system contains a growing family of behaviors, new members of that family should ideally be added without repeatedly modifying stable central workflow logic.

What the Developer Should Remember

  • Identify behavior that genuinely varies.
  • Keep stable workflows separate from provider-specific behavior.
  • Use focused interfaces where multiple implementations are meaningful.
  • Use composition and dependency injection.
  • Centralize implementation selection.
  • Use type-safe domain values.
  • Keep strategies independently testable.
  • Do not over-engineer stable simple conditions.

What the Reviewer Should Check

A reviewer should determine:

  • Is this conditional growing because new variants are repeatedly added?
  • Will future variants require editing the same stable method?
  • Is selection logic duplicated?
  • Would a strategy interface improve change isolation?
  • Is the proposed abstraction genuinely cohesive?
  • Can a new implementation be introduced without modifying existing implementations?
  • Are unsupported variants handled safely?
  • Are duplicate implementations detectable?
  • Are transaction and exception semantics consistent?
  • Is the design easier to understand than the original conditional?

What Should Be Avoided in Production Code

Avoid:

  • Repeated growing if/else chains
  • Large provider-specific switch blocks
  • Raw strings for known variants
  • Duplicated strategy selection
  • Direct coupling from orchestration services to every implementation
  • Reflection tricks used only to avoid a switch
  • Huge generic strategy interfaces
  • Unnecessary abstraction for stable simple rules
  • Designing extension points for imaginary future requirements

A good OCP design allows a team to add:

JAVA
NewPaymentProcessor
NewNotificationSender
NewReportExporter

without repeatedly reopening and risking existing implementations.

The practical objective is simple:

Extend where the business changes frequently, and keep already working core behavior as stable as reasonably possible.