Skip to main content

O โ€” Open/Closed Principle

"Software entities should be open for extension, but closed for modification." โ€” Bertrand Meyer, popularized by Robert C. Martin


๐Ÿง  What Does It Mean?โ€‹

Your class should be:

  • Open for extension โ†’ You can add new behavior
  • Closed for modification โ†’ You don't change existing, working code

The goal is to add new features without touching existing, tested code. Every time you modify old code, you risk introducing bugs.

A great analogy: Think of a power strip. You don't rewire the strip every time you want to plug in a new device โ€” you just plug in. The strip is "closed" for internal modification, but "open" for new devices.


๐ŸŽฏ Why Should I Care?โ€‹

Picture this: You're working on an e-commerce platform with a pricing engine. Every time marketing wants a new promotion type โ€” flash sales, buy-one-get-one, loyalty discounts, seasonal deals โ€” a developer has to:

  1. Open PricingService.java
  2. Add another else if branch to a 400-line method
  3. Hope the new branch doesn't break the 15 existing discount types
  4. Run the entire regression suite (which takes 45 minutes)
  5. Coordinate with 3 other developers who are also editing the same file

After 18 months, the method has 32 branches, nobody fully understands it, and marketing requests take 2 weeks instead of 2 days.

This is what happens without OCP:

  • ๐Ÿ› Regression risk โ€” every change to existing code can break proven behavior
  • ๐Ÿข Slow feature delivery โ€” adding a simple variant requires deep understanding of the entire method
  • ๐Ÿ’ฅ Merge hell โ€” multiple developers editing the same switch/if-else chain
  • ๐Ÿ˜ฐ Testing burden โ€” you must retest everything because the modification touches shared code

With OCP, adding a new discount type is just creating a new class. Zero risk to existing behavior.


โŒ Bad Example โ€” Violating OCPโ€‹

public class DiscountService {

public double calculateDiscount(String customerType, double price) {
if (customerType.equals("REGULAR")) {
return price * 0.05;
} else if (customerType.equals("PREMIUM")) {
return price * 0.10;
} else if (customerType.equals("VIP")) {
return price * 0.20;
}
// What happens when you need a new "STUDENT" type?
// You have to come back and modify this method! โŒ
return 0;
}
}

Why is this bad?

Every time a new customer type is added, you have to modify DiscountService. This risks breaking the existing logic for REGULAR and PREMIUM customers. And if this class is big, things get fragile fast.


โœ… Good Example โ€” Applying OCPโ€‹

Use abstraction (interfaces or abstract classes) to define a contract, then create separate implementations:

// Define a common contract (abstraction)
public interface DiscountStrategy {
double calculate(double price);
}
// Each customer type is its own class
public class RegularDiscount implements DiscountStrategy {
@Override
public double calculate(double price) {
return price * 0.05;
}
}
public class PremiumDiscount implements DiscountStrategy {
@Override
public double calculate(double price) {
return price * 0.10;
}
}
public class VIPDiscount implements DiscountStrategy {
@Override
public double calculate(double price) {
return price * 0.20;
}
}
// DiscountService never changes when you add new types
public class DiscountService {

public double calculateDiscount(DiscountStrategy strategy, double price) {
return strategy.calculate(price);
}
}

Now if you need a StudentDiscount:

// Just add a new class โ€” don't touch DiscountService! โœ…
public class StudentDiscount implements DiscountStrategy {
@Override
public double calculate(double price) {
return price * 0.15;
}
}

๐Ÿ” How to Spot Violationsโ€‹

SmellWhat It Looks Like
Growing if/else chainsEvery new feature adds another branch to the same method
Switch on typeswitch(type) with cases that grow over time
"Just add it here"PRs that always modify the same file for new features
Enum-driven logicA central enum where every new value requires editing multiple switch statements
Fragile testsExisting tests break when unrelated new features are added
String-based dispatchingUsing string comparisons to decide behavior (if type.equals("X"))

The golden rule: If adding a new feature requires you to open an existing file and add an if/else or switch case, that's a sign you're violating OCP.


๐ŸŒฑ In a Spring Boot Applicationโ€‹

This pattern fits naturally with Spring's dependency injection. You can use @Component on each strategy and inject the right one:

public interface NotificationSender {
void send(String message, String recipient);
}
@Component("email")
public class EmailNotificationSender implements NotificationSender {
@Override
public void send(String message, String recipient) {
System.out.println("Email to " + recipient + ": " + message);
}
}
@Component("sms")
public class SmsNotificationSender implements NotificationSender {
@Override
public void send(String message, String recipient) {
System.out.println("SMS to " + recipient + ": " + message);
}
}
@Service
public class NotificationService {

private final Map<String, NotificationSender> senders;

// Spring auto-injects all NotificationSender beans into this map!
public NotificationService(Map<String, NotificationSender> senders) {
this.senders = senders;
}

public void notify(String type, String message, String recipient) {
NotificationSender sender = senders.get(type);
if (sender == null) throw new IllegalArgumentException("Unknown type: " + type);
sender.send(message, recipient);
}
}

To add a new PushNotificationSender, just create a new @Component class. NotificationService stays unchanged. ๐ŸŽ‰


๐Ÿข Real-World Use Casesโ€‹

1. Payment Provider Integrationโ€‹

A fintech company starts with Stripe integration. Six months later, they need to support PayPal, then Apple Pay, then local payment methods for Southeast Asian markets.

Without OCP: The PaymentService grows a massive switch statement. Every new provider risks breaking Stripe processing โ€” the most critical revenue path.

With OCP:

public interface PaymentProvider {
PaymentResult charge(PaymentRequest request);
boolean supports(String method);
}

@Component
public class StripeProvider implements PaymentProvider { /* ... */ }

@Component
public class PayPalProvider implements PaymentProvider { /* ... */ }

// Adding GrabPay for Southeast Asia โ€” zero changes to existing code
@Component
public class GrabPayProvider implements PaymentProvider { /* ... */ }

The PaymentOrchestrator routes to the right provider dynamically. Adding GrabPay is a single new class โ€” no existing tests need to change.

2. Report Export Engineโ€‹

A SaaS analytics platform initially exports PDF reports. Customers request CSV, then Excel, then interactive HTML dashboards.

Without OCP: A single ExportService with format-specific if branches. Adding HTML export accidentally breaks PDF margins.

With OCP:

public interface ReportExporter {
byte[] export(ReportData data);
String getFormat();
}

@Component public class PdfExporter implements ReportExporter { /* ... */ }
@Component public class CsvExporter implements ReportExporter { /* ... */ }
@Component public class ExcelExporter implements ReportExporter { /* ... */ }
// New! No existing exporters touched
@Component public class HtmlDashboardExporter implements ReportExporter { /* ... */ }

3. Rule Engine for Insurance Underwritingโ€‹

An insurance company has dozens of underwriting rules that change quarterly. Business analysts need to add new rules without risking existing approved rules.

With OCP:

public interface UnderwritingRule {
RuleResult evaluate(Application application);
int priority();
}

@Component public class AgeEligibilityRule implements UnderwritingRule { /* ... */ }
@Component public class CreditScoreRule implements UnderwritingRule { /* ... */ }
@Component public class MedicalHistoryRule implements UnderwritingRule { /* ... */ }

@Service
public class UnderwritingEngine {
private final List<UnderwritingRule> rules; // auto-injected by Spring

public UnderwritingResult evaluate(Application app) {
return rules.stream()
.sorted(Comparator.comparingInt(UnderwritingRule::priority))
.map(rule -> rule.evaluate(app))
.reduce(UnderwritingResult::merge)
.orElse(UnderwritingResult.APPROVED);
}
}

New rules are just new classes. The engine never changes. Compliance auditors love it because approved rules are never modified.


๐Ÿ—๏ธ Architecture-Level Deep Diveโ€‹

Design Patterns That Implement OCPโ€‹

OCP isn't a single pattern โ€” it's a principle that multiple patterns serve:

PatternHow It Achieves OCPBest For
StrategySwap behavior via interchangeable algorithmsDiscount calculation, sorting, validation
Template MethodOverride specific steps while keeping the overall flow fixedProcessing pipelines, lifecycle hooks
DecoratorWrap and extend behavior without modifying the originalLogging, caching, retry logic
Observer/ListenerAdd new reactions to events without changing the emitterNotifications, audit trails, analytics
Chain of ResponsibilityAdd new handlers without modifying the chain managerValidation, filtering, request processing

OCP in Event-Driven Systemsโ€‹

Event-driven architecture is OCP at the system level:

Order Service โ†’ publishes OrderCreatedEvent
โ†“
Email Service โ†’ listens and sends confirmation (existing)
Inventory Service โ†’ listens and reserves stock (existing)
Analytics Service โ†’ listens and tracks conversion (NEW โ€” zero changes above!)
Fraud Detection Service โ†’ listens and flags suspicious orders (NEW โ€” zero changes above!)

Adding a new consumer is pure extension โ€” no modification to the publisher or existing consumers.

Compile-Time vs Runtime Extensionโ€‹

TypeMechanismExample
Compile-timeGenerics, abstract classes, sealed interfacesComparable<T>, template method
RuntimeDependency injection, plugin loading, service discoverySpring beans, SPI, OSGi

Most enterprise applications use runtime extension via dependency injection (Spring) or configuration โ€” this gives maximum flexibility without recompilation.

OCP and Plugin Architectureโ€‹

Frameworks like VS Code, IntelliJ, and WordPress are built entirely around OCP:

Core Application (closed for modification)
โ”œโ”€โ”€ Plugin Interface (the extension point)
โ”œโ”€โ”€ Plugin A (community-built)
โ”œโ”€โ”€ Plugin B (community-built)
โ””โ”€โ”€ Plugin C (your custom plugin โ€” no core changes needed!)

In Java, this maps to the Service Provider Interface (SPI) mechanism or Spring's auto-discovered @Component beans.


โš–๏ธ Trade-offs & When NOT to Applyโ€‹

When a Simple Switch Is Betterโ€‹

Not every if/else needs to become a strategy pattern:

// This is FINE โ€” it's stable, simple, and rarely changes
public String formatCurrency(double amount, String currency) {
return switch (currency) {
case "USD" -> "$" + amount;
case "EUR" -> "โ‚ฌ" + amount;
case "GBP" -> "ยฃ" + amount;
default -> currency + " " + amount;
};
}

Apply OCP when:

  • The branching logic grows frequently (new types added often)
  • Different teams own different branches
  • Each branch has complex, independent logic
  • You need to add new variants without redeploying existing code

Keep it simple when:

  • The set of options is small and stable (enum of 3โ€“5 values)
  • The logic per branch is a single line
  • The code rarely changes
  • You're building a prototype

Premature Abstractionโ€‹

The biggest risk of OCP is creating extension points where no extension is needed. This adds complexity for zero benefit:

// Over-engineered โ€” this enum has 3 values and hasn't changed in 2 years
public interface GenderFormatter {
String format(Gender gender);
}
public class MaleFormatter implements GenderFormatter { /* ... */ }
public class FemaleFormatter implements GenderFormatter { /* ... */ }
public class OtherFormatter implements GenderFormatter { /* ... */ }

// Just use a switch โ€” it's simpler and more readable

Rule of thumb: Wait until you see the second variation before abstracting. "Three strikes and you refactor."


๐Ÿงช Testing Implicationsโ€‹

Isolated Strategy Testingโ€‹

With OCP, each implementation is tested independently:

// Each strategy gets its own focused test class
class RegularDiscountTest {
@Test
void shouldApplyFivePercentDiscount() {
var discount = new RegularDiscount();
assertEquals(5.0, discount.calculate(100.0));
}
}

class VIPDiscountTest {
@Test
void shouldApplyTwentyPercentDiscount() {
var discount = new VIPDiscount();
assertEquals(20.0, discount.calculate(100.0));
}
}

// Adding StudentDiscount? Just add StudentDiscountTest.
// No existing tests touched!

Contract Tests for New Implementationsโ€‹

When new implementations are added, contract tests ensure they honor the interface:

// Shared contract test โ€” every DiscountStrategy must pass these
abstract class DiscountStrategyContractTest {

abstract DiscountStrategy createStrategy();

@Test
void shouldReturnNonNegativeDiscount() {
assertThat(createStrategy().calculate(100.0)).isGreaterThanOrEqualTo(0);
}

@Test
void shouldReturnZeroForZeroPrice() {
assertEquals(0, createStrategy().calculate(0));
}

@Test
void shouldNotExceedOriginalPrice() {
double price = 100.0;
assertThat(createStrategy().calculate(price)).isLessThanOrEqualTo(price);
}
}

// Each implementation extends the contract test
class RegularDiscountContractTest extends DiscountStrategyContractTest {
@Override
DiscountStrategy createStrategy() { return new RegularDiscount(); }
}

The Testing Pyramid with OCPโ€‹

/ E2E Tests \ โ† Few: test full workflows
/ Integration \ โ† Some: test strategy wiring
/ Unit Tests (each \ โ† Many: test each strategy independently
/ strategy in isolation) \

๐Ÿ”— Relationship to Other SOLID Principlesโ€‹

PrincipleHow It Connects to OCP
Single Responsibility (SRP)SRP creates focused classes that are easier to extend without modification โ€” each class has one clear extension point
Liskov Substitution (LSP)OCP relies on substitution: new implementations must honor the interface contract or OCP breaks at runtime
Interface Segregation (ISP)Narrow interfaces make OCP easier โ€” extending a focused interface is simpler than extending a fat one
Dependency Inversion (DIP)DIP is the mechanism that enables OCP โ€” by depending on abstractions, you can inject new implementations

OCP and DIP are a power couple. DIP gives you the interface to depend on; OCP gives you the confidence that adding new implementations won't break existing code. Together, they enable the Strategy pattern and most plugin architectures.


๐Ÿ“Œ Summaryโ€‹

BadGood
New featureModify existing classAdd a new class
RiskBreak existing behaviorIsolated, safe
Key toolif/else, switchInterfaces + polymorphism

Next up: Liskov Substitution Principle โ†’


Interview Questionsโ€‹

Q: How do you apply OCP in feature-flag-heavy systems?โ€‹

A: Keep stable orchestration fixed, and plug variant behavior behind strategy interfaces selected by flags/config.

Q: What is the difference between OCP and over-engineering?โ€‹

A: OCP targets expected change points. Over-engineering adds extension points where change is unlikely.

Q: How does OCP improve release safety?โ€‹

A: New behavior is introduced by adding isolated implementations, reducing risk of regressions in existing paths.

Q: In Spring, what is a practical OCP pattern for business rules?โ€‹

A: Register rule handlers as beans implementing a common interface, then route by key/context instead of adding new switch branches.

Q: What is an anti-pattern that pretends to follow OCP?โ€‹

A: A central dispatcher class that still requires modifying a switch map for each new behavior.

Q: How do you decide when a branch should be replaced by polymorphism?โ€‹

A: If branching grows with business variants and changes frequently, move to polymorphism. If it is stable and small, keep a simple branch.

Q: How does OCP interact with API versioning?โ€‹

A: New versions can be added as new handlers/adapters while preserving old behavior, minimizing risky edits in existing version paths.

Q: Give a production scenario where OCP paid off.โ€‹

A: Swapping a notification channel or pricing rule by adding a new implementation without touching order workflow logic.