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:
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:
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:
public interface NotificationSender {
NotificationType supports();
void send(NotificationMessage message);
}Each channel implements that contract:
EmailNotificationSender
SmsNotificationSender
PushNotificationSender
WhatsAppNotificationSenderThe 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:
Validate payment request
Find payment processor
Process payment
Save payment result
Return responseThe variable part is:
How the payment is processed.That variable responsibility can be represented by an interface.
public interface PaymentProcessor {
PaymentMethod supports();
PaymentResult process(PaymentRequest request);
}Implementations may include:
CardPaymentProcessor
UpiPaymentProcessor
WalletPaymentProcessorThe 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/elseorswitchblocks 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.
@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:
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:
"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/elsechain 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
public enum PaymentMethod {
CARD,
UPI,
WALLET
}Refund Processor Contract
public interface RefundProcessor {
PaymentMethod supports();
String process(RefundRequest request);
}Card Refund Processor
@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
@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
@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
@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
@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:
RefundProcessorAdding 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:
CardRefundProcessor
UpiRefundProcessor
WalletRefundProcessorThis is the practical benefit of OCP.
12. Bad Code vs Improved Code
| Area | Bad Code | Improved Code |
|---|---|---|
| Adding provider | Modify existing if/else chain | Add processor implementation |
| Coupling | Service knows every gateway | Service depends on processor abstraction |
| Readability | Provider logic mixed together | Each provider has focused implementation |
| Testability | All branches tested through one service | Each processor can be tested separately |
| Regression risk | Existing selection code changes | Stable orchestration remains mostly unchanged |
| Team development | Developers modify same class | Provider implementations are separated |
| Type safety | Raw strings | Enum |
| Provider discovery | Hidden in conditions | Explicit implementations |
13. Real Project Scenario
Consider a reporting platform used by an enterprise application.
Initially it supports:
- CSV export
- Excel export
The implementation contains:
if (format == CSV) {
generateCsv();
} else if (format == EXCEL) {
generateExcel();
}Later customers request:
- 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:
ReportExporterwith implementations such as:
CsvReportExporter
ExcelReportExporter
PdfReportExporter
JsonReportExporterThe 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:
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:
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:
supports() == PaymentMethod.CARDthe 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:
if/elseand:
Map lookup + interface callis usually insignificant in enterprise backend applications.
Database and network I/O dominate runtime cost.
Registry Lookup
A map-based lookup is typically constant-time:
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:
BankTransferRefundProcessortests 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:
if type == A
else if type == B
else if type == Cespecially 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:
RefundProcessor.process()Step 4: Introduce Domain Type
Replace raw strings with an enum where appropriate.
Step 5: Move Existing Branches Incrementally
Create:
CardRefundProcessor
UpiRefundProcessor
WalletRefundProcessorPreserve 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
- *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?*
- *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.*
- *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.*
- *Please replace the raw provider strings with a domain enum so supported variants are explicit and type-safe.*
- *The orchestration service currently injects every gateway implementation directly. A processor registry would reduce coupling and make future provider additions more localized.*
- *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.*
- *Could we fail application startup when duplicate processors register the same PaymentMethod? Silent selection would make configuration defects difficult to diagnose.*
- *Provider-specific validation is leaking into the central service. Please keep only common request validation here and move provider rules to the relevant processor.*
- *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.*
- *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
@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
public enum NotificationType {
EMAIL,
SMS,
PUSH
}Sender Contract
public interface NotificationSender {
NotificationType supports();
void send(NotificationRequest request);
}Email Sender
@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
@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
@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
@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
@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:
WhatsAppNotificationSenderand 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:
PaymentProcessorEach provider would implement it:
CardPaymentProcessor
UpiPaymentProcessor
WalletPaymentProcessorSpring can inject all implementations:
List<PaymentProcessor>A registry can convert that list into:
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/elsechains - Large provider-specific
switchblocks - 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:
NewPaymentProcessor
NewNotificationSender
NewReportExporterwithout 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.