Loading content...
The Open/Closed Principle makes a bold claim: systems can be extended without modification. This sounds theoretically elegant, but does it work in practice? Can we truly add significant new functionality—an entire new payment method, notification channel, or export format—without touching a single line of existing code?
This page answers with a definitive yes by walking through complete examples. We'll trace every file that changes (or rather, doesn't change) when adding new strategies. We'll prove OCP isn't just possible—it's practical, verifiable, and transformative for how we evolve software.
By seeing extension happen in concrete detail, you'll internalize not just that OCP works but why it works. This understanding lets you design systems that truly deliver on the OCP promise.
By the end of this page, you will trace through complete extension examples, verify that existing code remains untouched, understand the conditions that enable pure extension, and recognize when modifications signal OCP violations.
Let's trace a complete extension scenario. We have a checkout system that currently supports Credit Card and PayPal payments. The business wants to add Apple Pay. How do we achieve this with zero modification to existing code?
The Existing System:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// === FILE: payment-strategy.interface.ts (EXISTING - NO CHANGES) ===export interface PaymentStrategy { processPayment(amount: Money, context: PaymentContext): Promise<PaymentResult>; validatePaymentDetails(details: PaymentDetails): ValidationResult; getProviderName(): string; getSupportedCards?(): CardType[];} // === FILE: credit-card.strategy.ts (EXISTING - NO CHANGES) ===@Injectable()export class CreditCardStrategy implements PaymentStrategy { constructor( private readonly cardProcessor: CardProcessorGateway, private readonly fraudDetection: FraudDetectionService ) {} async processPayment(amount: Money, context: PaymentContext): Promise<PaymentResult> { const fraudCheck = await this.fraudDetection.checkTransaction(context); if (fraudCheck.isSuspicious) { return PaymentResult.declined('Fraud check failed'); } return this.cardProcessor.chargeCard( context.paymentDetails.cardToken, amount ); } validatePaymentDetails(details: PaymentDetails): ValidationResult { if (!details.cardToken) { return ValidationResult.invalid('Card token required'); } return this.cardProcessor.validateToken(details.cardToken); } getProviderName(): string { return 'Credit Card'; } getSupportedCards(): CardType[] { return ['visa', 'mastercard', 'amex', 'discover']; }} // === FILE: paypal.strategy.ts (EXISTING - NO CHANGES) ===@Injectable()export class PayPalStrategy implements PaymentStrategy { constructor(private readonly paypalClient: PayPalApiClient) {} async processPayment(amount: Money, context: PaymentContext): Promise<PaymentResult> { const order = await this.paypalClient.createOrder({ intent: 'CAPTURE', purchase_units: [{ amount: { currency_code: 'USD', value: amount.toString() } }] }); return this.paypalClient.capturePayment(order.id); } validatePaymentDetails(details: PaymentDetails): ValidationResult { if (!details.paypalOrderId) { return ValidationResult.invalid('PayPal order ID required'); } return ValidationResult.valid(); } getProviderName(): string { return 'PayPal'; }} // === FILE: checkout.service.ts (EXISTING - NO CHANGES) ===@Injectable()export class CheckoutService { constructor( private readonly orderRepository: OrderRepository, private readonly inventoryService: InventoryService, private readonly emailService: EmailService, private readonly logger: Logger ) {} async processCheckout( cart: ShoppingCart, paymentStrategy: PaymentStrategy, // Strategy injected per-request context: PaymentContext ): Promise<CheckoutResult> { this.logger.info(`Processing checkout with ${paymentStrategy.getProviderName()}`); // Validate payment details const validation = paymentStrategy.validatePaymentDetails(context.paymentDetails); if (!validation.isValid) { return CheckoutResult.failed(validation.errors); } // Reserve inventory const reservation = await this.inventoryService.reserve(cart.items); if (!reservation.success) { return CheckoutResult.failed(['Items no longer available']); } try { // Process payment using injected strategy const paymentResult = await paymentStrategy.processPayment( cart.total, context ); if (!paymentResult.success) { await this.inventoryService.releaseReservation(reservation.id); return CheckoutResult.failed([paymentResult.errorMessage]); } // Create order const order = await this.orderRepository.create({ items: cart.items, total: cart.total, paymentId: paymentResult.transactionId, paymentMethod: paymentStrategy.getProviderName() }); // Send confirmation await this.emailService.sendOrderConfirmation(order, context.customer); return CheckoutResult.success(order); } catch (error) { await this.inventoryService.releaseReservation(reservation.id); throw error; } }}Now Let's Add Apple Pay:
The business requirement is to support Apple Pay. Here's what we add—and critically, what we don't touch:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394
// === FILE: apple-pay.strategy.ts (NEW FILE - PURE ADDITION) ===// This is the ONLY code file that needs to be written @Injectable()export class ApplePayStrategy implements PaymentStrategy { constructor( private readonly applePayService: ApplePayMerchantService, private readonly tokenService: ApplePayTokenService, private readonly logger: Logger ) {} async processPayment(amount: Money, context: PaymentContext): Promise<PaymentResult> { this.logger.info('Processing Apple Pay transaction'); // Validate the Apple Pay token const tokenValidation = await this.tokenService.validateToken( context.paymentDetails.applePayToken ); if (!tokenValidation.isValid) { return PaymentResult.declined('Invalid Apple Pay token'); } // Decrypt and process the payment const decryptedPayment = await this.tokenService.decryptPaymentData( context.paymentDetails.applePayToken ); // Process through Apple's payment network const result = await this.applePayService.processPayment({ paymentData: decryptedPayment, amount: amount.toCents(), currencyCode: amount.currency, merchantId: this.applePayService.getMerchantId() }); if (result.success) { return PaymentResult.success({ transactionId: result.transactionId, authorizationCode: result.authCode, network: result.cardNetwork }); } return PaymentResult.declined(result.errorMessage); } validatePaymentDetails(details: PaymentDetails): ValidationResult { if (!details.applePayToken) { return ValidationResult.invalid('Apple Pay token required'); } if (!details.applePayToken.paymentData) { return ValidationResult.invalid('Payment data missing from token'); } return ValidationResult.valid(); } getProviderName(): string { return 'Apple Pay'; }} // === FILE: payment.module.ts (CONFIGURATION ONLY - Adding registration) ===// This is configuration, not core logic @Module({ providers: [ // Existing strategies (unchanged) CreditCardStrategy, PayPalStrategy, // NEW: Apple Pay strategy registration { provide: ApplePayStrategy, useFactory: (applePayService, tokenService, logger) => new ApplePayStrategy(applePayService, tokenService, logger), inject: [ApplePayMerchantService, ApplePayTokenService, Logger] }, // Update strategy registry (adding, not modifying) { provide: 'PAYMENT_STRATEGIES', useFactory: (creditCard, paypal, applePay) => ({ 'credit_card': creditCard, 'paypal': paypal, 'apple_pay': applePay // NEW entry }), inject: [CreditCardStrategy, PayPalStrategy, ApplePayStrategy] } ]})export class PaymentModule {}Count the modifications: CheckoutService — UNCHANGED. CreditCardStrategy — UNCHANGED. PayPalStrategy — UNCHANGED. PaymentStrategy interface — UNCHANGED. We added one new class file and a configuration entry. That's pure extension.
Let's systematically analyze the impact of adding Apple Pay:
Files That Remain Completely Untouched:
| File | Type | Modified? | Reason |
|---|---|---|---|
| payment-strategy.interface.ts | Interface | No ✓ | Stable abstraction; Apple Pay fits existing contract |
| credit-card.strategy.ts | Strategy | No ✓ | Isolated implementation; unaware of other strategies |
| paypal.strategy.ts | Strategy | No ✓ | Isolated implementation; unaware of other strategies |
| checkout.service.ts | Context | No ✓ | Programs to interface; accepts any PaymentStrategy |
| order.repository.ts | Repository | No ✓ | Stores paymentMethod string; no strategy knowledge |
| email.service.ts | Service | No ✓ | Uses paymentMethod string for display; no coupling |
| apple-pay.strategy.ts | Strategy | N/A (New) | Pure addition to codebase |
| payment.module.ts | Config | Yes (config) | Adding registration; configuration not logic |
Why This Works:
Several design decisions enable this pure extension:
Stable Interface — PaymentStrategy defines all operations needed. Apple Pay, despite being different technology, can express itself through the same operations (process, validate, name).
Context Ignorance — CheckoutService doesn't know concrete strategy types. It receives a PaymentStrategy and uses it. Whether that's credit card, PayPal, or Apple Pay is irrelevant to its logic.
String-Based Identity — The system uses getProviderName() for display and storage, not type checking. This allows new payment types without database schema changes.
Dependency Injection — Strategy instantiation happens in configuration, not in consuming code. Adding a new strategy is a wiring change, not a code change.
OCP distinguishes between modifying behavioral code and updating configuration/registration. Adding an entry to a DI module or plugin registry is configuration change, not code modification. The core logic remains closed while the system remains open for extension.
Let's examine a more complex scenario: adding a complete new notification channel that requires multiple related strategies working together.
Scenario: The system supports Email and SMS notifications. We need to add Slack notifications with rich formatting, thread support, and interactive buttons.
Existing System:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
// === EXISTING INTERFACES (NO CHANGES) === interface NotificationStrategy { send(notification: Notification): Promise<DeliveryResult>; formatMessage(content: NotificationContent): FormattedMessage; supportsRichContent(): boolean; supportsInteraction(): boolean; getChannelName(): string;} interface NotificationTemplateStrategy { render(template: Template, data: TemplateData): RenderedContent; getSupportedFormats(): ContentFormat[];} // === EXISTING IMPLEMENTATIONS (NO CHANGES) === class EmailNotificationStrategy implements NotificationStrategy { constructor(private readonly emailClient: SmtpClient) {} async send(notification: Notification): Promise<DeliveryResult> { const result = await this.emailClient.send({ to: notification.recipient, subject: notification.title, body: notification.content, html: notification.htmlContent }); return DeliveryResult.from(result); } formatMessage(content: NotificationContent): FormattedMessage { return { plain: content.text, html: `<html><body>${content.html}</body></html>` }; } supportsRichContent(): boolean { return true; } supportsInteraction(): boolean { return false; } getChannelName(): string { return 'email'; }} class SmsNotificationStrategy implements NotificationStrategy { constructor(private readonly smsGateway: SmsGateway) {} async send(notification: Notification): Promise<DeliveryResult> { // SMS is limited to 160 chars for single segment const message = notification.content.substring(0, 160); return this.smsGateway.sendSms(notification.recipient, message); } formatMessage(content: NotificationContent): FormattedMessage { return { plain: content.text.substring(0, 160) }; } supportsRichContent(): boolean { return false; } supportsInteraction(): boolean { return false; } getChannelName(): string { return 'sms'; }} // === EXISTING CONTEXT (NO CHANGES) === class NotificationService { constructor( private readonly strategies: Map<string, NotificationStrategy>, private readonly templateEngine: TemplateEngine, private readonly logger: Logger ) {} async notify( userId: string, channel: string, templateId: string, data: NotificationData ): Promise<NotificationResult> { const strategy = this.strategies.get(channel); if (!strategy) { throw new UnsupportedChannelError(channel); } // Get user's channel-specific address const recipient = await this.resolveRecipient(userId, channel); // Render template const template = await this.templateEngine.load(templateId); const content = this.templateEngine.render(template, data); // Format for channel capabilities const formatted = strategy.formatMessage(content); // Build notification const notification: Notification = { recipient, title: data.title, content: formatted.plain, htmlContent: formatted.html, metadata: data.metadata }; // Send through strategy const result = await strategy.send(notification); this.logger.info(`Notification sent via ${strategy.getChannelName()}`, { success: result.success, messageId: result.messageId }); return result; }}Adding Slack Notifications:
Slack has unique features: Block Kit formatting, threads, interactive buttons. Our design must accommodate these while maintaining OCP:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
// === NEW FILE: slack-notification.strategy.ts ===// Pure addition - no existing files modified @Injectable()export class SlackNotificationStrategy implements NotificationStrategy { constructor( private readonly slackClient: SlackWebClient, private readonly blockBuilder: SlackBlockBuilder, private readonly logger: Logger ) {} async send(notification: Notification): Promise<DeliveryResult> { const blocks = this.buildBlocks(notification); try { const result = await this.slackClient.chat.postMessage({ channel: notification.recipient, text: notification.content, // Fallback for notifications blocks: blocks, thread_ts: notification.metadata?.threadId, unfurl_links: false }); return DeliveryResult.success({ messageId: result.ts, channel: result.channel, threadId: result.ts }); } catch (error) { this.logger.error('Slack notification failed', { error }); return DeliveryResult.failed(error.message); } } formatMessage(content: NotificationContent): FormattedMessage { // Slack uses mrkdwn format return { plain: content.text, html: null, // Slack doesn't use HTML mrkdwn: this.convertToSlackMarkdown(content.text) }; } supportsRichContent(): boolean { return true; } // Slack uniquely supports interactive elements supportsInteraction(): boolean { return true; } getChannelName(): string { return 'slack'; } private buildBlocks(notification: Notification): SlackBlock[] { const blocks: SlackBlock[] = [ this.blockBuilder.header(notification.title), this.blockBuilder.section(notification.content) ]; // Add interactive buttons if actions specified if (notification.metadata?.actions) { blocks.push( this.blockBuilder.actions( notification.metadata.actions.map(action => this.blockBuilder.button({ text: action.label, actionId: action.id, value: action.value, style: action.primary ? 'primary' : undefined }) ) ) ); } return blocks; } private convertToSlackMarkdown(text: string): string { return text .replace(/**(.+?)**/g, '*$1*') // Bold .replace(/__(.+?)__/g, '_$1_') // Italic .replace(/`(.+?)`/g, '`$1`') // Code .replace(/[(.+?)]((.+?))/g, '<$2|$1>'); // Links }} // === NEW FILE: slack-block.builder.ts ===// Supporting class for rich block formatting @Injectable()export class SlackBlockBuilder { header(text: string): SlackBlock { return { type: 'header', text: { type: 'plain_text', text, emoji: true } }; } section(text: string): SlackBlock { return { type: 'section', text: { type: 'mrkdwn', text } }; } actions(elements: SlackElement[]): SlackBlock { return { type: 'actions', elements }; } button(config: ButtonConfig): SlackElement { return { type: 'button', text: { type: 'plain_text', text: config.text }, action_id: config.actionId, value: config.value, style: config.style }; } divider(): SlackBlock { return { type: 'divider' }; } context(elements: SlackElement[]): SlackBlock { return { type: 'context', elements }; }} // === NEW FILE: slack-interaction.handler.ts ===// Handle interactive button clicks from Slack @Injectable()export class SlackInteractionHandler { constructor( private readonly slackClient: SlackWebClient, private readonly actionProcessor: ActionProcessor, private readonly logger: Logger ) {} async handleInteraction(payload: SlackInteractionPayload): Promise<void> { this.logger.info('Received Slack interaction', { type: payload.type, actionId: payload.actions?.[0]?.action_id }); for (const action of payload.actions || []) { await this.actionProcessor.process({ actionId: action.action_id, value: action.value, userId: payload.user.id, channelId: payload.channel.id, messageTs: payload.message.ts }); } // Acknowledge the interaction if (payload.response_url) { await this.slackClient.respondToInteraction(payload.response_url, { text: 'Action received!', replace_original: false }); } }} // === UPDATE: notification.module.ts (Configuration Only) === @Module({ providers: [ // Existing (unchanged) EmailNotificationStrategy, SmsNotificationStrategy, // NEW registrations SlackNotificationStrategy, SlackBlockBuilder, SlackInteractionHandler, // Updated strategy map { provide: 'NOTIFICATION_STRATEGIES', useFactory: (email, sms, slack) => new Map([ ['email', email], ['sms', sms], ['slack', slack] // NEW ]), inject: [ EmailNotificationStrategy, SmsNotificationStrategy, SlackNotificationStrategy ] } ]})export class NotificationModule {}Notice how Slack's rich features (blocks, threads, interactivity) are implemented entirely within the strategy. The interface's supportsInteraction() method existed for this purpose. The context can query capabilities, but the specifics are encapsulated in the strategy.
Pure extension—adding functionality without modification—isn't magic. It requires specific conditions that must be established during initial design. Understanding these conditions helps you design systems that truly achieve OCP.
Condition 1: Complete Abstraction
The strategy interface must capture all operations the context needs. If adding Apple Pay requires operations the interface doesn't define, you'll need to modify the interface—breaking OCP.
Design Implication: Think carefully about what operations are genuinely universal versus strategy-specific. The interface should define WHAT is done; strategies decide HOW.
Condition 2: Context Ignorance
The context must genuinely not know about concrete strategy types. Any reference to specific strategies creates coupling that breaks extension capability.
Anti-Pattern to Avoid:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546
// ❌ VIOLATION: Context knows about concrete strategiesclass CheckoutService { processOrder(cart: Cart, strategy: PaymentStrategy) { // This check couples context to specific strategy if (strategy instanceof CreditCardStrategy) { // Special handling for credit cards this.handleFraudCheck(strategy); } // Adding ApplePayStrategy requires modifying this method strategy.processPayment(cart.total); }} // ❌ VIOLATION: Type discrimination in contextclass NotificationService { send(notification: Notification, strategy: NotificationStrategy) { switch (strategy.getChannelName()) { case 'email': // Email-specific logic in context this.addEmailHeaders(notification); break; case 'sms': // SMS-specific logic in context notification.content = notification.content.substring(0, 160); break; // Adding 'slack' requires new case here } return strategy.send(notification); }} // ✓ CORRECT: Context is strategy-agnosticclass NotificationService { send(notification: Notification, strategy: NotificationStrategy) { // Let strategy handle its own formatting const formatted = strategy.formatMessage(notification.content); // Context remains unchanged for any strategy return strategy.send({ ...notification, content: formatted }); }}Condition 3: Capability Queries Over Type Checks
Instead of checking types, contexts should query capabilities through the interface:
// ❌ Type check
if (strategy instanceof RichContentStrategy) {
useHtmlFormatting();
}
// ✓ Capability query
if (strategy.supportsRichContent()) {
useHtmlFormatting();
}
Capability queries let new strategies declare their features without context modification.
Condition 4: External Configuration
Strategy instantiation must be external to core logic. If core code instantiates strategies with new ConcreteStrategy(), adding new strategies requires modification.
Condition 5: Stable Data Contracts
Data objects passed to strategies (PaymentContext, Notification, etc.) must be extensible without breaking existing strategies. Use optional fields for new capabilities:
interface PaymentDetails {
// Original fields
cardToken?: string;
paypalOrderId?: string;
// Extended for Apple Pay - existing strategies ignore this
applePayToken?: string;
}
Despite best efforts, some extensions genuinely require modification. Recognizing these situations helps you avoid blaming OCP for problems that are actually design limitations.
Situation 1: Interface Expansion
When a new strategy needs operations that don't exist in the interface, the interface must change. This is a design limitation, not an OCP failure:
// Original interface
interface SearchStrategy {
search(query: string): SearchResult[];
}
// New requirement: faceted search
// The interface didn't anticipate this capability
class ElasticsearchStrategy implements SearchStrategy {
search(query: string): SearchResult[] { ... }
// This method can't be called through the interface!
searchWithFacets(query: string, facets: Facet[]): FacetedResult {
...
}
}
Resolution: Extend the interface (breaking change) or create a separate faceted search interface that some strategies implement.
Situation 2: Global Behavioral Changes
When the fundamental operation changes for ALL strategies, modification is appropriate:
// Original: Synchronous processing
interface PaymentStrategy {
process(amount: Money): PaymentResult;
}
// New requirement: All payments must be async with webhook confirmation
// This isn't adding a strategy - it's changing how payment works
interface PaymentStrategy {
initiatePayment(amount: Money): Promise<PendingPayment>;
handleWebhook(payload: WebhookPayload): PaymentResult;
}
This is a fundamental design change, not extension. All strategies and contexts must adapt.
OCP enables extending behavior within a chosen abstraction. Changing the abstraction itself is a different kind of evolution. Trying to force-fit fundamental changes into 'new strategies' leads to awkward, confusing designs. Accept that some changes require modification, plan for them, and execute cleanly.
Situation 3: Performance-Critical Path Changes
Sometimes a new strategy reveals that the context's structure is suboptimal:
// Context assumes synchronous strategies
class BatchProcessor {
process(items: Item[], strategy: ProcessingStrategy) {
for (const item of items) {
strategy.process(item); // Sequential
}
}
}
// New strategy could parallelize, but context prevents it
class ParallelizableStrategy implements ProcessingStrategy {
// Can't express parallel capability through existing interface
}
Situation 4: Cross-Cutting Concerns
When new strategies require cross-cutting behavior (authentication, logging, transactions), the context might need middleware or aspect-oriented changes:
// Adding authenticated payment strategies might require:
class CheckoutService {
async process(strategy: PaymentStrategy) {
// NEW: Auth check needed for some strategies
if (strategy.requiresAuthentication()) {
await this.authService.ensureAuthenticated();
}
return strategy.process(amount);
}
}
This walks the line—using capability queries keeps it OCP-friendly, but adds context complexity.
For maximum extensibility, mature systems evolve toward plugin architectures. Plugins are extreme OCP: new functionality is added without modifying, recompiling, or even redeploying the core system.
Plugin Architecture Characteristics:
Strategy Patterns Become Plugin Patterns:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
// PLUGIN ARCHITECTURE for Maximum Extensibility // Plugin interface - versioned for compatibilityinterface ExportPlugin { readonly pluginVersion: string; readonly supportedCoreVersions: string[]; readonly name: string; readonly description: string; readonly author: string; // Strategy methods export(data: ExportableData): Promise<ExportResult>; getFileExtension(): string; getMimeType(): string; getConfigSchema(): PluginConfigSchema;} // Plugin loader - discovers and validates pluginsclass PluginLoader { private readonly pluginDir: string; private readonly loadedPlugins = new Map<string, ExportPlugin>(); constructor(pluginDir: string, private readonly coreVersion: string) { this.pluginDir = pluginDir; } async loadPlugins(): Promise<PluginLoadResult> { const results: PluginLoadResult = { loaded: [], failed: [] }; const pluginFiles = await this.discoverPlugins(); for (const file of pluginFiles) { try { const plugin = await this.loadPlugin(file); // Validate version compatibility if (!this.isCompatible(plugin)) { results.failed.push({ file, reason: `Incompatible with core v${this.coreVersion}` }); continue; } this.loadedPlugins.set(plugin.name, plugin); results.loaded.push(plugin.name); } catch (error) { results.failed.push({ file, reason: error.message }); } } return results; } private async discoverPlugins(): Promise<string[]> { const files = await fs.readdir(this.pluginDir); return files.filter(f => f.endsWith('.plugin.js')); } private async loadPlugin(file: string): Promise<ExportPlugin> { const module = await import(path.join(this.pluginDir, file)); const plugin = module.default as ExportPlugin; // Validate required interface methods this.validatePluginInterface(plugin); return plugin; } private isCompatible(plugin: ExportPlugin): boolean { return plugin.supportedCoreVersions.some(v => semver.satisfies(this.coreVersion, v) ); } getPlugin(name: string): ExportPlugin | undefined { return this.loadedPlugins.get(name); } getAvailablePlugins(): PluginInfo[] { return Array.from(this.loadedPlugins.values()).map(p => ({ name: p.name, description: p.description, author: p.author, version: p.pluginVersion })); }} // Plugin-aware export serviceclass PluginExportService { constructor( private readonly pluginLoader: PluginLoader, private readonly storage: StorageService ) {} async exportWithPlugin( data: ExportableData, pluginName: string ): Promise<ExportResult> { const plugin = this.pluginLoader.getPlugin(pluginName); if (!plugin) { throw new PluginNotFoundError( `Plugin '${pluginName}' not found. Available: ${ this.pluginLoader.getAvailablePlugins() .map(p => p.name) .join(', ') }` ); } // Export using plugin const result = await plugin.export(data); // Store with correct extension const filename = `export_${Date.now()}.${plugin.getFileExtension()}`; await this.storage.save(filename, result.content, { contentType: plugin.getMimeType() }); return result; }} // Example plugin implementation (in separate file)// File: exports/pdf-export.plugin.jsconst PdfExportPlugin: ExportPlugin = { pluginVersion: '1.0.0', supportedCoreVersions: ['>=2.0.0 <3.0.0'], name: 'pdf-export', description: 'Export data to beautifully formatted PDF documents', author: 'Export Team', async export(data: ExportableData): Promise<ExportResult> { const pdf = new PDFDocument(); // ... PDF generation logic return { content: await pdf.toBuffer() }; }, getFileExtension(): string { return 'pdf'; }, getMimeType(): string { return 'application/pdf'; }, getConfigSchema(): PluginConfigSchema { return { pageSize: { type: 'enum', values: ['A4', 'Letter'] }, includeHeaders: { type: 'boolean', default: true } }; }}; export default PdfExportPlugin;Plugin architecture extends OCP beyond compile time. New export formats can be added by dropping a .plugin.js file in a directory—no code changes, no redeployment. This is OCP taken to its logical extreme.
We've proven that OCP works in practice by tracing complete extension scenarios. Adding Apple Pay, Slack notifications, or export plugins requires no modification to existing, tested code.
Let's consolidate the key insights:
What's Next:
We've seen OCP through strategy injection and pure extension. Next, we'll explore how OCP is achieved through composition patterns—how objects are assembled rather than how algorithms are varied.
You can now trace what changes (and doesn't) when extending Strategy-based systems. This skill lets you verify OCP compliance, identify violations, and design systems that truly deliver on the extension promise. Next, we'll explore OCP through composition.