Loading learning content...
Michael Feathers, in his seminal book Working Effectively with Legacy Code, introduced the concept of a seam—a place where you can alter behavior without editing the original code. Interfaces are the most powerful seams available in object-oriented programming. When you extract an interface, you're creating a point of flexibility where none existed before.
Interface extraction is often the first refactoring step toward better abstraction. It requires no behavioral changes to the existing code—you're simply declaring a contract that the existing implementation already satisfies. This makes it one of the safest refactorings you can perform, yet it opens enormous possibilities for extension, testing, and architectural evolution.
By the end of this page, you will master the mechanics of extracting interfaces from concrete classes, understand when interface extraction is the right choice versus other abstraction techniques, learn how to design stable, cohesive interfaces that last, and recognize the testing, decoupling, and extensibility benefits that interfaces provide.
Interface extraction follows a predictable, safe pattern. Let's walk through the process step by step, understanding not just what to do but why each step matters.
ReportGenerator interface might not include setDebugMode() even if the concrete class has it.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// BEFORE: Concrete class with no interface// All clients depend directly on this implementation class PdfReportGenerator { private templateEngine: TemplateEngine; private fontManager: FontManager; constructor(templateEngine: TemplateEngine, fontManager: FontManager) { this.templateEngine = templateEngine; this.fontManager = fontManager; } // Public method - part of the conceptual contract generate(data: ReportData): Buffer { const html = this.templateEngine.render('pdf-template', data); return this.convertToPdf(html); } // Public method - part of the conceptual contract generateWithWatermark(data: ReportData, watermark: string): Buffer { const html = this.templateEngine.render('pdf-template', data); const pdf = this.convertToPdf(html); return this.addWatermark(pdf, watermark); } // Public but NOT part of the abstraction - debugging concern setDebugMode(enabled: boolean): void { this.templateEngine.setDebug(enabled); } // Private - implementation detail private convertToPdf(html: string): Buffer { // PDF conversion logic using fontManager } // Private - implementation detail private addWatermark(pdf: Buffer, watermark: string): Buffer { // Watermark logic }} // Client code tightly coupled to PdfReportGeneratorclass ReportService { private generator: PdfReportGenerator; // Concrete dependency! constructor(generator: PdfReportGenerator) { this.generator = generator; } createMonthlyReport(data: SalesData): Buffer { return this.generator.generate(this.transformToReportData(data)); }}What changed:
PdfReportGenerator is nearly untouched—only one line addedReportGenerator interface declares the contractReportService depends on the interface, not the concrete classThis is the power of interface extraction: minimal code changes, maximum architectural flexibility.
The most critical decision in interface extraction is what to include. Include too little, and the interface won't capture the abstraction. Include too much, and the interface becomes a leaky mirror of the implementation.
The ISP Guidance:
The Interface Segregation Principle tells us that clients should not be forced to depend on methods they don't use. This principle directly informs interface design:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// EXAMPLE: Deciding interface contents for a notification service class EmailNotificationService { private smtpClient: SmtpClient; private rateLimiter: RateLimiter; private templateCache: Map<string, Template>; // ✅ INCLUDE: Core behavior that defines "notification" send(recipient: string, message: NotificationMessage): Promise<void>; // ✅ INCLUDE: Batch operations - core capability sendBulk(notifications: Notification[]): Promise<BulkResult>; // ❌ EXCLUDE: Email-specific configuration setSmtpSettings(settings: SmtpSettings): void; // ❌ EXCLUDE: Implementation-specific caching preloadTemplates(templateIds: string[]): Promise<void>; clearTemplateCache(): void; // ❓ CONSIDER: Might be interface-worthy if all notifiers support it getDeliveryStatus(notificationId: string): Promise<DeliveryStatus>; // ❌ EXCLUDE: Debug/admin concern, not client behavior getMetrics(): NotificationMetrics; enableDebugLogging(enabled: boolean): void;} // RESULT: Clean, focused interface interface NotificationService { send(recipient: string, message: NotificationMessage): Promise<void>; sendBulk(notifications: Notification[]): Promise<BulkResult>; getDeliveryStatus(notificationId: string): Promise<DeliveryStatus>;} // Now different channels can implement the same interfaceclass SmsNotificationService implements NotificationService { // Different implementation, same contract async send(recipient: string, message: NotificationMessage): Promise<void> { // SMS-specific logic } // SMS-specific methods NOT in interface setTwilioCredentials(credentials: TwilioConfig): void;} class PushNotificationService implements NotificationService { // Different implementation, same contract async send(recipient: string, message: NotificationMessage): Promise<void> { // Push notification logic } // Push-specific methods NOT in interface registerDevice(deviceToken: string, platform: Platform): void;}To determine what belongs in an interface, examine the code that USES the class. Which methods does the client code actually call? Include those. Which methods are only called during initialization, configuration, or debugging? Those likely don't belong in the core interface.
Interface naming is an art that directly impacts code readability and design clarity. A well-named interface communicates the abstraction's purpose; a poorly-named interface obscures it.
The fundamental rule: Interface names should describe what implementations do, not what they are.
| Pattern | Example | When to Use |
|---|---|---|
| Capability (-able) | Comparable, Serializable, Drawable | Single capability or trait the implementor provides |
| Role (-er) | Reader, Writer, Handler, Processor | Active participant in an operation |
| Service | PaymentService, NotificationService | Business capability with multiple operations |
| Repository | UserRepository, OrderRepository | Data access abstraction (DDD pattern) |
| Strategy | PricingStrategy, ValidationStrategy | Interchangeable algorithm (Strategy pattern) |
| Factory | ConnectionFactory, SessionFactory | Object creation abstraction |
| Simple Noun | Logger, Cache, Scheduler | When the concept is universally understood |
1234567891011121314151617181920212223242526272829303132333435
// ❌ POOR: Names describe what it IS, not what it DOESinterface IDatabase { } // "I" prefix is controversial and uninformativeinterface AbstractPaymentHandler { } // "Abstract" is redundant for interfacesinterface DataObject { } // Too vagueinterface UserInterface { } // Confusing with UI; describes structure, not behaviorinterface BaseCacheImpl { } // "Base" and "Impl" are implementation concepts // ✅ GOOD: Names describe capabilities and purposeinterface Cacheable { } // Something that can be cachedinterface PaymentProcessor { } // Processes payments (role-based)interface UserRepository { } // Data access for users (DDD pattern)interface ConnectionFactory { } // Creates connections (factory pattern)interface MessagePublisher { } // Publishes messages (role-based) // ✅ GOOD: Names match domain languageinterface DiscountPolicy { } // Domain term, clear purposeinterface ShippingCalculator { } // Calculates shipping (role-based)interface InventoryChecker { } // Checks inventory (role-based)interface OrderValidator { } // Validates orders (role-based) // CONTEXT MATTERS: Same concept, different names by context // In a web framework:interface RequestHandler { } // Handles HTTP requestsinterface MiddlewareFunction { } // Middleware concept from Express/Koa // In a message queue system:interface MessageHandler { } // Handles queue messages interface MessageConsumer { } // Consumes from a queue // In a logging library:interface Logger { } // Simple, universally understoodinterface LogSink { } // Where logs are written (output destination) // The key is matching the vocabulary of the domain/contextPdfReportGenerator, RedisCache, SmtpEmailSender.DiscountPolicy, not DiscountCalculationStrategy.Processor is too vague; PaymentProcessor is clear.Runnable is clear; Doable is not.When you extract an interface, the existing concrete class often has the 'good' name. Options: (1) Rename concrete to something descriptive like PostgresUserRepository and give interface the simple name UserRepository. (2) Keep concrete name and use a more abstract interface name. Choose based on which name clients will use most.
Interface extraction is valuable precisely because of the capabilities it unlocks. Understanding these benefits helps you recognize when interface extraction is the right refactoring and helps justify the investment to stakeholders.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// BENEFIT: Testability// Without interface - hard to test, requires real databaseclass OrderService_Hard { private db: PostgresDatabase; // Concrete dependency createOrder(items: Item[]): Order { // How do you test this without a real database? const order = new Order(items); this.db.insert('orders', order); // Real DB call in tests! return order; }} // With interface - easy to test with mockclass OrderService { constructor(private orderRepository: OrderRepository) {} // Interface createOrder(items: Item[]): Order { const order = new Order(items); this.orderRepository.save(order); return order; }} // Test with mockclass MockOrderRepository implements OrderRepository { savedOrders: Order[] = []; save(order: Order): void { this.savedOrders.push(order); }} test('createOrder saves to repository', () => { const mockRepo = new MockOrderRepository(); const service = new OrderService(mockRepo); service.createOrder([{ id: '1', price: 10 }]); expect(mockRepo.savedOrders).toHaveLength(1); // Easy assertion!}); // BENEFIT: Substitutability - feature flagsclass PaymentService { constructor( private paymentProcessor: PaymentProcessor, // Interface private featureFlags: FeatureFlags ) {} processPayment(payment: Payment): PaymentResult { // Same interface, different implementations return this.paymentProcessor.process(payment); }} // Production instantiation with feature flagconst paymentProcessor = featureFlags.isEnabled('new-stripe-integration') ? new StripePaymentProcessor() // New implementation : new LegacyPaymentProcessor(); // Old implementation const paymentService = new PaymentService(paymentProcessor, featureFlags); // BENEFIT: Parallel Development// Team A: Works on interface and consumersinterface ReportExporter { export(report: Report, destination: Destination): Promise<ExportResult>;} // Team B: Implements interface independentlyclass S3ReportExporter implements ReportExporter { async export(report: Report, destination: Destination): Promise<ExportResult> { // Implementation can proceed independently // as long as it fulfills the interface contract }}Interface extraction has upfront cost (creating the interface, updating dependencies) but pays ongoing dividends (easier testing, simpler changes, better extensibility). The payoff is proportional to: (1) how often the code changes, (2) how many clients depend on it, and (3) how likely alternative implementations are.
Interfaces are powerful, but extracting them everywhere is a recipe for over-engineering. Knowing when not to extract is as important as knowing when to extract.
The core question: Will this interface ever have more than one real implementation? If the answer is 'probably not,' the interface may be unnecessary indirection.
UserDto doesn't need an IUserDto interface.12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970
// ❌ UNNECESSARY: Interface for a utility class// There will never be another implementation of "Math utilities"interface IMathUtils { round(value: number, decimals: number): number; clamp(value: number, min: number, max: number): number;} class MathUtils implements IMathUtils { round(value: number, decimals: number): number { /* ... */ } clamp(value: number, min: number, max: number): number { /* ... */ }} // Just use the class directly - no interface neededclass MathUtils { static round(value: number, decimals: number): number { /* ... */ } static clamp(value: number, min: number, max: number): number { /* ... */ }} // ❌ UNNECESSARY: Interface for DTOsinterface IUserDto { id: string; name: string; email: string;} class UserDto implements IUserDto { constructor( public id: string, public name: string, public email: string ) {}} // Just use a simple type or class - no interface ceremonytype UserDto = { id: string; name: string; email: string;}; // ❌ UNNECESSARY: Interface just to have an interface// When there's zero chance of alternative implementationsinterface IApplicationConfig { getDbConnectionString(): string; getLogLevel(): string;} class ApplicationConfig implements IApplicationConfig { getDbConnectionString(): string { return process.env.DB_CONNECTION!; }} // There will never be a MockApplicationConfig or TestApplicationConfig// that provides different config values - just use the class // ✅ EXCEPTION: Testing IS a valid "second implementation"// If you need to mock config in tests, the interface becomes valuableinterface ConfigProvider { get(key: string): string;} class EnvironmentConfigProvider implements ConfigProvider { get(key: string): string { return process.env[key] || ''; }} class TestConfigProvider implements ConfigProvider { constructor(private values: Record<string, string>) {} get(key: string): string { return this.values[key] || ''; }}Testability is a legitimate reason for interface extraction even when you expect only one production implementation. If the class has dependencies that make testing difficult (databases, HTTP calls, file system), extracting an interface to enable mocking is valuable. The mock IS the second implementation.
Once an interface is extracted and clients depend on it, changing the interface becomes costly—every implementation and every client must be updated. This is why interface stability is a critical design goal.
The stability challenge: You want interfaces to be stable (don't change often) but also useful (capture the right abstraction). These goals can conflict.
process(Request): Response is more stable than process(HttpRequest): JsonBody.createUser(UserData): User is more stable than createUser(name, email, age, address): User. New fields are added to UserData without changing the interface.PaymentProcessorV2) rather than changing the existing interface.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263
// ❌ FRAGILE: Many parameters, each a change pointinterface UserService { createUser( name: string, email: string, age: number, address: string, phoneNumber?: string, preferences?: UserPreferences // Adding more params = interface change! ): User;} // ✅ STABLE: Whole object parameterinterface UserService { createUser(userData: CreateUserRequest): User;} // New fields added to CreateUserRequest, not the interfacetype CreateUserRequest = { name: string; email: string; age?: number; address?: string; phoneNumber?: string; // Adding more fields here is NOT an interface change loyaltyProgramId?: string; referralCode?: string;}; // ❌ FRAGILE: Concrete types in signaturesinterface DataExporter { exportToS3(data: PostgresQueryResult): S3Object; // Coupled to specific technologies!} // ✅ STABLE: Abstract types in signaturesinterface DataExporter { export(data: ExportableData): ExportResult; // Works with any data source, any destination} // ❌ FRAGILE: Too many specific methodsinterface ReportGenerator { generatePdfReport(data: ReportData): Buffer; generateHtmlReport(data: ReportData): Buffer; generateCsvReport(data: ReportData): Buffer; // Adding XML requires interface change!} // ✅ STABLE: Generic method with format parameter or separate interfacesinterface ReportGenerator { generate(data: ReportData, format: ReportFormat): Buffer;} // Or even better - format as a type parameter or separate implementationsinterface ReportOutput { render(data: ReportData): Buffer;} class PdfReportOutput implements ReportOutput { }class HtmlReportOutput implements ReportOutput { }// New formats = new classes, not interface changesVery stable interfaces may be less expressive or require more implementation effort. A generic export(data: ExportableData) is stable but puts more burden on implementations than exportToS3(data). Balance stability with usability based on how many implementations and clients you expect.
Let's consolidate the key considerations into a practical checklist you can use when extracting interfaces.
What comes next:
Interface extraction is often just the beginning. In many cases, you'll find that multiple implementations share behavior—not just contracts. When this happens, you'll want to introduce an abstract class that provides the shared implementation while leaving variation points for subclasses.
The next page covers introducing abstract classes as a complementary technique to interface extraction.
You now understand how to extract interfaces systematically—determining what to include, naming effectively, understanding the benefits unlocked, recognizing when NOT to extract, and designing for stability. The next page covers introducing abstract classes for situations where shared implementation is needed alongside shared contracts.