O Open Closed Principle OCP
7 min readO β 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
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.
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.
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();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.
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.
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.
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.
A: Custom hooks encapsulate behavior behind interfaces. Create usePayment(processor: PaymentProcessor) instead of useStripePayment(). New processors extend via new implementations, not code changes.
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.
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.
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.
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)
);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.