Loading learning content...
Imagine you're building a payment processing system. Today, you support credit cards and PayPal. Tomorrow, your business team wants to add Apple Pay. Next month, cryptocurrency. Each new payment method requires changes to your code—but where?
If adding Apple Pay means modifying your existing PaymentProcessor class, you're violating the Open/Closed Principle. Every modification risks breaking what already works. Every change requires retesting the entire system. Every new requirement introduces regression potential.
Interfaces are the solution. They define the contract that payment methods must fulfill, allowing you to add ApplePayProcessor, CryptoProcessor, or any future processor without touching a single line of existing, working code.
This page explores how interfaces serve as extension points—the architectural seams where new functionality can be added without surgery on existing code.
By the end of this page, you will understand how to identify opportunities for interface-based extension points, design interfaces that enable OCP-compliant systems, and recognize the patterns that make interfaces powerful tools for extensibility.
An extension point is a location in your architecture where new functionality can be added without modifying existing code. Think of it as a standardized socket in your system—any plug that fits the socket can provide new capabilities.
Extension points are not accidental. They emerge from deliberate design decisions about where variation is expected and how that variation should be accommodated. The key insight is that interfaces define the shape of the socket, while implementing classes are the plugs that provide specific functionality.
When designing a system, constantly ask: 'Where will requirements change? What new variations might be needed?' Those are your candidate extension points. An interface at each point allows the system to grow without modification.
Anatomy of an Extension Point:
A well-designed extension point consists of three elements:
The Interface Contract — A clear definition of what behavior implementations must provide. This contract must be stable; changing it would violate OCP for all existing implementations.
The Registration Mechanism — A way to introduce new implementations to the system. This might be constructor injection, a registry pattern, configuration, or plugin discovery.
The Consumer Code — The existing code that programs against the interface, not concrete implementations. This code remains unchanged when new implementations are added.
When all three elements work together, you achieve true extensibility: new capabilities through addition, not modification.
| Component | Purpose | Example |
|---|---|---|
| Interface Contract | Defines the behavior all implementations must provide | PaymentGateway with processPayment() and refund() methods |
| Registration Mechanism | Introduces new implementations to the system | Dependency injection, plugin loader, or factory configuration |
| Consumer Code | Uses the interface without knowing concrete implementations | OrderService that accepts any PaymentGateway |
An interface is more than a list of method signatures—it's a behavioral contract. When a class implements an interface, it promises that:
This contractual nature is what enables the Open/Closed Principle. Client code trusts the contract, not specific implementations. As long as new implementations honor the contract, they can be added freely.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// The interface defines the contractpublic interface NotificationChannel { /** * Sends a notification to the specified recipient. * * Contract guarantees: * - Returns true if notification was successfully queued/sent * - Returns false if recipient is unreachable (invalid address) * - Throws IllegalArgumentException if message is null or empty * - Never throws on recipient format issues (returns false instead) */ boolean send(String recipient, String message); /** * Returns whether this channel supports bulk notifications. */ boolean supportsBulkSend();} // Existing implementation - NEVER MODIFIED when adding new channelspublic class EmailNotificationChannel implements NotificationChannel { private final EmailService emailService; public EmailNotificationChannel(EmailService emailService) { this.emailService = emailService; } @Override public boolean send(String recipient, String message) { if (message == null || message.isEmpty()) { throw new IllegalArgumentException("Message cannot be empty"); } if (!isValidEmail(recipient)) { return false; // Contract: return false for unreachable recipient } return emailService.sendEmail(recipient, "Notification", message); } @Override public boolean supportsBulkSend() { return true; } private boolean isValidEmail(String email) { return email != null && email.contains("@"); }} // NEW implementation added later - EXTENDS the system without modificationpublic class SMSNotificationChannel implements NotificationChannel { private final SMSGateway smsGateway; public SMSNotificationChannel(SMSGateway smsGateway) { this.smsGateway = smsGateway; } @Override public boolean send(String recipient, String message) { if (message == null || message.isEmpty()) { throw new IllegalArgumentException("Message cannot be empty"); } if (!isValidPhoneNumber(recipient)) { return false; } return smsGateway.sendSMS(recipient, message); } @Override public boolean supportsBulkSend() { return false; // SMS rate limits prevent bulk sends } private boolean isValidPhoneNumber(String phone) { return phone != null && phone.matches("\\+?[0-9]{10,15}"); }}Observe the key OCP pattern:
NotificationChannel interface is closed for modification—it defines a stable contract that existing code relies upon.SMSNotificationChannel required zero changes to EmailNotificationChannel or any consumer code.PushNotificationChannel, SlackChannel, or WebhookChannel are needed next month, they follow the same pattern: implement the interface, register with the system, done.The interface acts as a stabilization layer between variation (multiple channel types) and consumers (notification services).
Method signatures define the explicit contract—what methods exist and their types. But behavioral expectations (like 'return false for invalid recipients instead of throwing') are the implicit contract. Documenting these expectations is crucial; violations break substitutability even when code compiles.
The effectiveness of an interface as an extension point depends entirely on its stability. An interface that changes frequently undermines the entire purpose of OCP—every interface change propagates to all implementations, forcing modifications.
Interface stability principles:
Capture the essential abstraction — An interface should represent a stable concept, not a specific implementation's details. NotificationChannel represents the concept of sending notifications—a stable abstraction. EmailWithSMTPSettings would tie the interface to email-specific details.
Favor general over specific — Methods should describe what is accomplished, not how. send(recipient, message) works for email, SMS, push, Slack, and channels not yet invented. sendWithRetry(recipient, message, maxRetries) might not apply to all channels.
Minimize surface area — Every method in an interface is a commitment. Start with the minimum viable contract and expand only when truly necessary. Unused methods become implementation burdens.
sendWithSMTPServer())EmailMessage instead of String)send(), validate())The stability test:
Before finalizing an interface, ask:
"If I need to add 5 more implementations of this interface over the next 2 years, will this interface still apply without modification?"
If the answer is uncertain, the interface may be too tightly coupled to current implementation assumptions. Consider:
Changing an interface is expensive. Every implementation must be updated. Every consumer must be reviewed. Every test must be verified. This is why interface design deserves careful thought upfront. Once published and implemented, interfaces become contracts that are costly to break.
Interfaces enable polymorphic dispatch—the ability for client code to invoke behavior without knowing which implementation provides it. This is the mechanism that makes OCP possible.
Consider a notification service that needs to send alerts through multiple channels:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
// This class NEVER changes when new notification channels are addedpublic class AlertService { private final List<NotificationChannel> channels; // Channels are injected - the service doesn't know or care about specifics public AlertService(List<NotificationChannel> channels) { this.channels = channels; } public void sendAlert(String recipient, String message) { for (NotificationChannel channel : channels) { try { boolean sent = channel.send(recipient, message); if (sent) { log.info("Alert sent via {}", channel.getClass().getSimpleName()); } } catch (Exception e) { log.warn("Channel failed: {}", channel.getClass().getSimpleName(), e); } } } public void sendBulkAlerts(List<String> recipients, String message) { // Use polymorphism to select appropriate channels List<NotificationChannel> bulkCapableChannels = channels.stream() .filter(NotificationChannel::supportsBulkSend) .collect(Collectors.toList()); for (String recipient : recipients) { for (NotificationChannel channel : bulkCapableChannels) { channel.send(recipient, message); } } }} // Configuration wires everything together - THIS is where new channels appear@Configurationpublic class NotificationConfig { @Bean public AlertService alertService( EmailService emailService, SMSGateway smsGateway, PushNotificationService pushService) { // Added later! List<NotificationChannel> channels = List.of( new EmailNotificationChannel(emailService), new SMSNotificationChannel(smsGateway), new PushNotificationChannel(pushService) // New channel - no AlertService changes! ); return new AlertService(channels); }}Analyzing the OCP achievement:
AlertService is closed for modification — When PushNotificationChannel was added, AlertService remained entirely unchanged. Its loop over NotificationChannel instances works identically regardless of how many or which types exist.
The system is open for extension — Adding new channels only requires:
NotificationChannelAlertService, no changes to existing channels.Polymorphic dispatch handles the variation — The channel.send() call dispatches to the correct implementation at runtime. AlertService neither knows nor cares whether it's sending to email, SMS, push, or a channel that doesn't exist yet.
This is the essence of interface-based extensibility: dispatch to the interface, implement the details elsewhere.
Not every variation warrants an interface. Over-engineering with unnecessary abstractions creates complexity without benefit. The skill lies in recognizing where interfaces provide genuine value.
Strong signals for interface-based extension points:
Weak signals (proceed with caution):
A useful heuristic: Extract an interface when you have three implementations or three distinct reasons for variation. One implementation is specific. Two might be coincidence. Three establishes a pattern. This prevents over-abstraction while ensuring you don't miss genuine extension points.
Several proven patterns leverage interfaces to achieve OCP. Understanding these patterns accelerates recognition of similar opportunities in your codebase.
Strategy Pattern encapsulates interchangeable algorithms behind an interface. The context class uses the strategy interface without knowing which algorithm is active.
Example: A compression library where CompressionStrategy might be ZipCompression, GzipCompression, or LzmaCompression. Adding new compression algorithms never modifies the compression service.
123456789101112131415161718192021
public interface CompressionStrategy { byte[] compress(byte[] data); byte[] decompress(byte[] compressedData);} public class FileArchiver { private final CompressionStrategy strategy; public FileArchiver(CompressionStrategy strategy) { this.strategy = strategy; } public byte[] archive(List<File> files) { byte[] combined = combineFiles(files); return strategy.compress(combined); // Polymorphic dispatch }} // New algorithms added without modifying FileArchiverpublic class Brotli implements CompressionStrategy { /* ... */ }public class Zstd implements CompressionStrategy { /* ... */ }What these patterns share:
These patterns are fundamental vocabulary for OCP-compliant design. When you recognize a problem fitting one of these patterns, you've identified an extension point.
We've explored how interfaces serve as the primary mechanism for achieving the Open/Closed Principle. Let's consolidate the key insights:
What's next:
While interfaces define pure contracts, sometimes extension points benefit from shared implementation. The next page explores Abstract Classes for Variation—how to provide default behavior while still enabling OCP-compliant extension.
You now understand how interfaces create extension points that enable systems to grow through addition rather than modification. This is the core enabler of the Open/Closed Principle and the foundation of flexible, maintainable software architecture.