O Open Closed Principle OCP

7 min read
Rapid overview

O β€” Open/Closed Principle (OCP)

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

You should add new behavior without editing existing code.

βœ… Example: Payment processors (React/TypeScript)

// paymentProcessors.ts - Define the abstraction
export interface PaymentProcessor {
  processPayment(amount: number, details: PaymentDetails): Promise<PaymentResult>;
  getName(): string;
}

// Existing implementation
export class StripeProcessor implements PaymentProcessor {
  async processPayment(amount: number, details: PaymentDetails): Promise<PaymentResult> {
    // Stripe API logic
    return { success: true, transactionId: 'stripe_123' };
  }

  getName(): string {
    return 'Stripe';
  }
}

export class PayPalProcessor implements PaymentProcessor {
  async processPayment(amount: number, details: PaymentDetails): Promise<PaymentResult> {
    // PayPal API logic
    return { success: true, transactionId: 'paypal_456' };
  }

  getName(): string {
    return 'PayPal';
  }
}

// Add new processor WITHOUT modifying existing code
export class CryptoProcessor implements PaymentProcessor {
  async processPayment(amount: number, details: PaymentDetails): Promise<PaymentResult> {
    // Crypto wallet logic
    return { success: true, transactionId: 'crypto_789' };
  }

  getName(): string {
    return 'Cryptocurrency';
  }
}

// React component using the abstraction
function CheckoutForm({ processor }: { processor: PaymentProcessor }) {
  const handleSubmit = async (amount: number, details: PaymentDetails) => {
    const result = await processor.processPayment(amount, details);
    if (result.success) {
      console.log(`Payment processed via ${processor.getName()}`);
    }
  };

  return <form onSubmit={...}>...</form>;
}

βœ… Adding a new payment processor (like Apple Pay or Google Pay) just means creating another PaymentProcessor implementation β€” no code modification, only extension.

βœ… Example: Form validators (React)

// Bad - violates OCP (requires modification for new validation rules)
function validateForm(data: FormData): ValidationErrors {
  const errors: ValidationErrors = {};

  if (!data.email.includes('@')) {
    errors.email = 'Invalid email';
  }

  if (data.password.length < 8) {
    errors.password = 'Password too short';
  }

  // Adding new validation requires editing this function!

  return errors;
}

// Good - follows OCP (extend without modification)
export interface Validator<T> {
  validate(value: T): ValidationError | null;
}

export class EmailValidator implements Validator<string> {
  validate(value: string): ValidationError | null {
    return value.includes('@') ? null : { message: 'Invalid email' };
  }
}

export class PasswordLengthValidator implements Validator<string> {
  constructor(private minLength: number) {}

  validate(value: string): ValidationError | null {
    return value.length >= this.minLength
      ? null
      : { message: `Password must be at least ${this.minLength} characters` };
  }
}

// Add new validators without modifying existing code
export class PasswordStrengthValidator implements Validator<string> {
  validate(value: string): ValidationError | null {
    const hasUpper = /[A-Z]/.test(value);
    const hasNumber = /\d/.test(value);
    return hasUpper && hasNumber
      ? null
      : { message: 'Password must contain uppercase and numbers' };
  }
}

// Compose validators
export class FormValidator {
  private validators: Map<string, Validator<any>[]> = new Map();

  addValidator(field: string, validator: Validator<any>): this {
    if (!this.validators.has(field)) {
      this.validators.set(field, []);
    }
    this.validators.get(field)!.push(validator);
    return this;
  }

  validate(data: Record<string, any>): ValidationErrors {
    const errors: ValidationErrors = {};

    for (const [field, validators] of this.validators) {
      for (const validator of validators) {
        const error = validator.validate(data[field]);
        if (error) {
          errors[field] = error.message;
          break;
        }
      }
    }

    return errors;
  }
}

// Usage - configure without modifying code
const formValidator = new FormValidator()
  .addValidator('email', new EmailValidator())
  .addValidator('password', new PasswordLengthValidator(8))
  .addValidator('password', new PasswordStrengthValidator());

βœ… Example: Chart renderers (Angular)

// chart.types.ts - Abstraction
export interface ChartRenderer {
  render(data: ChartData, container: HTMLElement): void;
  destroy(): void;
}

// Existing implementations
@Injectable()
export class BarChartRenderer implements ChartRenderer {
  render(data: ChartData, container: HTMLElement): void {
    // D3 bar chart rendering
  }

  destroy(): void {
    // Cleanup
  }
}

@Injectable()
export class LineChartRenderer implements ChartRenderer {
  render(data: ChartData, container: HTMLElement): void {
    // D3 line chart rendering
  }

  destroy(): void {
    // Cleanup
  }
}

// Extend without modification - add new chart type
@Injectable()
export class PieChartRenderer implements ChartRenderer {
  render(data: ChartData, container: HTMLElement): void {
    // D3 pie chart rendering
  }

  destroy(): void {
    // Cleanup
  }
}

// Component uses the abstraction
@Component({
  selector: 'app-chart',
  template: '<div #chartContainer></div>'
})
export class ChartComponent implements OnInit, OnDestroy {
  @ViewChild('chartContainer', { static: true }) container!: ElementRef;
  @Input() data!: ChartData;
  @Input() renderer!: ChartRenderer; // Injected by parent

  ngOnInit() {
    this.renderer.render(this.data, this.container.nativeElement);
  }

  ngOnDestroy() {
    this.renderer.destroy();
  }
}

// Usage - switch chart types without modifying ChartComponent
<app-chart [data]="salesData" [renderer]="barRenderer"></app-chart>
<app-chart [data]="salesData" [renderer]="lineRenderer"></app-chart>
<app-chart [data]="salesData" [renderer]="pieRenderer"></app-chart>

βœ… Example: Middleware/Plugin architecture

// middleware.ts - Open for extension
export interface Middleware {
  process(context: RequestContext, next: () => Promise<void>): Promise<void>;
}

export class LoggingMiddleware implements Middleware {
  async process(context: RequestContext, next: () => Promise<void>): Promise<void> {
    console.log(`Request: ${context.url}`);
    await next();
    console.log(`Response: ${context.status}`);
  }
}

export class AuthMiddleware implements Middleware {
  async process(context: RequestContext, next: () => Promise<void>): Promise<void> {
    if (!context.headers.authorization) {
      context.status = 401;
      return;
    }
    await next();
  }
}

// Add new middleware without modifying existing code
export class RateLimitMiddleware implements Middleware {
  private requests = new Map<string, number>();

  async process(context: RequestContext, next: () => Promise<void>): Promise<void> {
    const count = this.requests.get(context.ip) || 0;
    if (count > 100) {
      context.status = 429;
      return;
    }
    this.requests.set(context.ip, count + 1);
    await next();
  }
}

// Pipeline composes middleware without modification
export class MiddlewarePipeline {
  private middlewares: Middleware[] = [];

  use(middleware: Middleware): this {
    this.middlewares.push(middleware);
    return this;
  }

  async execute(context: RequestContext): Promise<void> {
    let index = 0;

    const next = async (): Promise<void> => {
      if (index < this.middlewares.length) {
        const middleware = this.middlewares[index++];
        await middleware.process(context, next);
      }
    };

    await next();
  }
}

// Configure pipeline by adding new middleware
const pipeline = new MiddlewarePipeline()
  .use(new LoggingMiddleware())
  .use(new AuthMiddleware())
  .use(new RateLimitMiddleware());

πŸ’‘ In frontend applications:

  • Use interfaces/abstract classes to define contracts (payment processors, validators, renderers, middleware).
  • Implement strategy pattern for swappable behaviors (sorting strategies, filtering strategies).
  • Use plugin/middleware architectures for extensible pipelines.
  • React: Higher-order components (HOCs) and render props enable extension without modification.
  • Angular: Dependency injection and providers make it easy to swap implementations.
  • Component libraries: Export component interfaces/props that consumers can extend via composition.

Questions & Answers

Q: How do you ensure new features don't require modifying existing code in React?

A: Use composition, custom hooks with dependency injection, and strategy patterns. Pass dependencies as props or via context so components depend on abstractions, not concrete implementations.

Q: What signals an OCP violation in frontend code?

A: Large switch statements or if-else chains that grow with every new feature. For example, a form validator with a giant switch for field types, or a component that checks if (type === 'stripe') vs if (type === 'paypal') instead of using polymorphism.

Q: How do feature flags interact with OCP?

A: Feature flags can select between implementations without modifying code. Use dependency injection to register both implementations and toggle via configuration:

const processor = useFeatureFlag('crypto-payments')
  ? new CryptoProcessor()
  : new StripeProcessor();
Q: How do you balance OCP with readability in frontend?

A: Don't over-abstract. Only introduce interfaces when you have 2+ implementations or anticipate variation. A single validator doesn't need an interface until you add a second one.

Q: How does OCP apply to component libraries?

A: Export component composition APIs that allow extension. For example, a <Table> component that accepts custom <Column> renderers allows users to add new column types without modifying the library.

Q: How does OCP apply to APIs consumed by frontend?

A: Version APIs instead of changing contracts. Add new endpoints (/api/v2/users) or optional fields while keeping existing behavior untouched to avoid breaking frontend clients.

Q: What tooling helps enforce OCP in frontend?

A: TypeScript interfaces enforce contracts, ESLint rules can warn on large switch statements, and architectural tests can verify dependencies. Code reviews should catch growing conditionals.

Q: How do React hooks aid OCP?

A: Custom hooks encapsulate behavior behind interfaces. Create usePayment(processor: PaymentProcessor) instead of useStripePayment(). New processors extend via new implementations, not code changes.

Q: How does OCP help with component theming?

A: Define theme interfaces and inject implementations:

interface Theme {
  colors: ColorPalette;
  spacing: SpacingScale;
}

function Button({ theme }: { theme: Theme }) {
  return <button style={{ color: theme.colors.primary }}>Click</button>;
}

Add new themes by implementing the interface without touching Button.

Q: Can configuration count as "extension" in frontend?

A: Yes, if behavior is data-driven. For example, a form builder that reads field configurations from JSONβ€”adding new field types is configuration, not code modification. Ensure validation guards config changes.

Q: How does OCP help with plugin architectures in frontend?

A: Plugins implement known interfaces (e.g., Monaco editor extensions, VS Code extensions, WordPress blocks). The host application never changes; you register new plugins via a plugin API.

Q: How do you use OCP with state management (Redux)?

A: Use middleware for cross-cutting concerns (logging, analytics, error tracking). Each middleware is independent and can be added without modifying existing middleware or reducers:

const store = createStore(
  reducer,
  applyMiddleware(logger, analytics, errorTracker, newMiddleware)
);
Q: How does OCP apply to React component composition?

A: Use render props, children as function, or slots pattern:

<DataTable
  columns={columns}
  renderRow={(row) => <CustomRow data={row} />} // Extend rendering
/>

Users extend behavior by providing custom renderers without modifying DataTable.