Skip to main content

What is Open-Closed Principle (OCP)?

Open-Closed Principle (OCP) - a SOLID rule that says software entities should be open for extension but closed for modification.

Theory

TL;DR

  • OCP is like a USB port: plug in new devices without rewiring the computer
  • Core idea: add new behavior by writing new code, not by editing old code
  • Abstract base class or interface = the stable contract; new types implement that contract
  • Decision rule: if adding a feature requires editing an existing file, the design breaks OCP
  • Tradeoff: polymorphic dispatch adds ~2-10ns per call, which matters in hot loops

Quick example

javascript
// BAD: every new shape requires editing this file class AreaCalculator { calculate(shape) { if (shape.type === 'circle') return Math.PI * shape.r ** 2; if (shape.type === 'square') return shape.side ** 2; // New rectangle? Edit here. Triangle? Edit here again. } } // GOOD: new shapes plug in without touching existing code class Shape { area() { throw new Error('Implement area()'); } } class Circle extends Shape { constructor(r) { super(); this.r = r; } area() { return Math.PI * this.r ** 2; } } class Rectangle extends Shape { constructor(w, h) { super(); this.w = w; this.h = h; } area() { return this.w * this.h; } } // new Rectangle(2, 3).area() → 6 // Shape and Circle: untouched.

Each new shape extends Shape. The calculator calls .area() on whatever it receives, no if/else needed.

Abstraction vs implementation

OCP separates two things: the stable contract (the abstract interface) and the extensible details (concrete implementations). Existing code stays untouched because new types plug into the abstract contract via inheritance. That prevents regression bugs from touching already-tested code.

One way to think about it: the AreaCalculator should never need to know what shapes exist. It only knows "this thing has an area() method." That contract is closed. New shapes are open.

When to use OCP

Use OCP when you can predict that behavior will grow in one direction:

  • Multiple output formats (loggers, printers, serializers): base class with log() or serialize(), extend for each format
  • Plugin systems: define a protocol or interface, third parties implement it without touching core
  • Payment processors: Strategy pattern with a shared interface, add Stripe or PayPal as a new class
  • Middleware stacks: Express app.use() is OCP in practice

Skip OCP for single-use code or small utilities where the abstraction overhead exceeds the maintenance cost. YAGNI applies here.

How V8 handles polymorphic calls

JavaScript engines use virtual method tables (vtables) for polymorphic dispatch. When shape.area() runs, V8 looks up the concrete class's vtable at runtime and dispatches to the right implementation. No static type knowledge required, which is exactly what makes OCP possible in JavaScript.

The cost is 1-2 indirect jumps per call, roughly 2-10ns. For 10,000 shapes per render cycle, that adds up. In performance-critical paths, batch operations or use concrete types directly.

Common mistakes

Mistake 1: using inheritance for state instead of behavior

javascript
// Wrong: Square IS-A Rectangle leads to broken setters class Rectangle { constructor(w, h) { this.w = w; this.h = h; } setWidth(w) { this.w = w; } } class Square extends Rectangle { setWidth(w) { this.w = this.h = w; } // modifies base behavior }

Square overriding setWidth breaks existing Rectangle users. This also violates Liskov Substitution, which kills OCP extensions down the line. Fix: use composition. class Square { constructor(s) { this.rect = new Rectangle(s, s); } }.

Mistake 2: abstract class with concrete default methods

javascript
// Wrong: default logic becomes a modification magnet class Shape { area() { return 0; } } // seems harmless

When the default logic changes, every subclass is at risk. Use pure abstract methods: throw an error, or in TypeScript declare the method without a body.

Mistake 3: premature abstraction

javascript
// Wrong: interface for every tiny thing // 50 implementations for what could be 3 functions interface ILogger { log(): void; }

Start concrete. Abstract only when you actually need to extend. Most codebases have more abstraction than real extension points.

Mistake 4: polymorphism in tight loops

javascript
// Risky in hot paths shapes.forEach(shape => total += shape.area()); // vtable lookup on each call

V8 can devirtualize this sometimes, but not always. If profiling shows this path as a bottleneck, use typed arrays or concrete types there.

Real-world usage

  • React: HOCs like withRouter in react-router extend components without touching source
  • Express: middleware via app.use() adds auth or logging without changing router logic
  • Redux: combineReducers adds features without touching existing reducer files
  • NestJS: @Injectable decorators enable module extension without base class changes
  • Stripe Node SDK: PaymentMethod classes extend a base via plugins, no core edits required

Follow-up questions

Q: How does OCP differ from the Stable Abstractions Principle?
A: OCP says "don't modify." The Stable Abstractions Principle (SAP) quantifies how abstract a package should be relative to how much other packages depend on it. Robert C. Martin describes SAP as a metric for OCP health via fan-out/in ratios. Junior answer: "they're the same." Senior answer: "SAP gives you a number to measure whether your OCP is actually working."

Q: Show an OCP violation and fix it in TypeScript using interfaces.
A: Violation: a function with a switch on string literal types. Fix: interface Shape { area(): number; }, one class per shape type. The function accepting Shape never changes.

Q: What are the tradeoffs of OCP in microservices vs a monolith?
A: In a monolith, inheritance and shared base classes are straightforward. In microservices, shared base classes become a coupling problem across service boundaries. There you prefer event contracts or OpenAPI schemas as the "closed" boundary. Extensions happen in separate services, not subclasses.

Q: In a legacy codebase with 100 if/else branches, what is your OCP migration plan?
A: Strangler pattern. Extract the interface first, wrap legacy code in an adapter, migrate one branch at a time. Tests before every step. No full rewrites at once.

Examples

Basic: shape area calculator

javascript
class Shape { area() { throw new Error('Subclass must implement area()'); } } class Circle extends Shape { constructor(radius) { super(); this.radius = radius; } area() { return Math.PI * this.radius ** 2; } } class Rectangle extends Shape { constructor(width, height) { super(); this.width = width; this.height = height; } area() { return this.width * this.height; } } class Triangle extends Shape { constructor(base, height) { super(); this.base = base; this.height = height; } area() { return (this.base * this.height) / 2; } } const shapes = [new Circle(5), new Rectangle(4, 6), new Triangle(3, 8)]; shapes.forEach(s => console.log(s.area())); // 78.54, 24, 12

Adding Triangle required zero changes to Shape, Circle, or Rectangle. New class, done. The existing code was never touched.

Intermediate: Express request logger

javascript
// Base logger - closed for modification class Logger { log(request) { console.log(`${request.method} ${request.url}`); } } // Extend for JSON format - no changes to Logger class JSONLogger extends Logger { log(request) { console.log(JSON.stringify({ method: request.method, url: request.url, timestamp: new Date().toISOString() })); } } // Extend for Sentry - still no changes to Logger class SentryLogger extends Logger { log(request) { Sentry.captureMessage(`Access: ${request.method} ${request.url}`); } } const logger = new JSONLogger(); app.use((req, res, next) => { logger.log(req); next(); }); // Output: {"method":"GET","url":"/api/users","timestamp":"2025-01-15T10:00:00Z"}

Adding DatadogLogger tomorrow? One new class. Logger, app.js, and JSONLogger stay exactly as they are.

Advanced: React HOC composition

javascript
// Closed HOC contract - provides featureEnabled prop function withFeature(WrappedComponent) { return class extends React.Component { render() { return <WrappedComponent {...this.props} featureEnabled={true} />; } }; } // Extension: adds analytics tracking - WrappedComponent is untouched function withAnalytics(WrappedComponent) { return class extends React.Component { componentDidMount() { analytics.track('ComponentMounted', this.props); } render() { return <WrappedComponent {...this.props} />; } }; } // Compose without modifying Button const TrackedButton = withAnalytics(withFeature(Button)); // Mounts Button, logs {page: 'home', featureEnabled: true}

HOC order matters. withAnalytics wraps withFeature(Button), so analytics fires after featureEnabled is already in props. Reverse the order and featureEnabled disappears from the analytics payload. That is the senior-level gotcha with OCP and HOC composition.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?