Loading content...
Splitting fat interfaces creates a new challenge: how does a single class implement multiple focused interfaces cleanly? If we're not careful, we trade fat interfaces for fat implementations—tangled classes that cram multiple roles into chaotic, hard-to-maintain code.
The solution lies in understanding that multiple interface implementation is a design skill, not just a syntax feature. This page teaches you the patterns, strategies, and organizational techniques that make multi-interface implementations as clean and maintainable as the interfaces themselves.
By the end of this page, you will master: (1) How classes can implement multiple interfaces while remaining cohesive, (2) Composition vs. inheritance strategies for role fulfillment, (3) Delegation patterns that distribute complexity, (4) Organizational techniques that keep implementations readable, and (5) Interface intersection types that express precise dependencies.
Most modern languages support multiple interface implementation. The syntax varies, but the concept is universal: a single class can fulfill multiple behavioral contracts.
The key insight: When a class implements multiple interfaces, it's not combining unrelated responsibilities—it's expressing that a single cohesive entity can play multiple roles depending on context.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374
// Individual focused interfaces (ISP-compliant)interface Readable { read(buffer: Buffer, offset: number, length: number): number;} interface Writable { write(buffer: Buffer, offset: number, length: number): number;} interface Seekable { seek(position: number): void; tell(): number;} interface Closeable { close(): void;} // Single class implementing multiple interfaces// This is NOT a violation of cohesion—FileHandle IS all of these thingsclass FileHandle implements Readable, Writable, Seekable, Closeable { private fd: number; private position: number = 0; constructor(path: string, mode: OpenMode) { this.fd = nativeOpen(path, mode); } // Readable implementation read(buffer: Buffer, offset: number, length: number): number { const bytesRead = nativeRead(this.fd, buffer, offset, length, this.position); this.position += bytesRead; return bytesRead; } // Writable implementation write(buffer: Buffer, offset: number, length: number): number { const bytesWritten = nativeWrite(this.fd, buffer, offset, length, this.position); this.position += bytesWritten; return bytesWritten; } // Seekable implementation seek(position: number): void { this.position = position; } tell(): number { return this.position; } // Closeable implementation close(): void { nativeClose(this.fd); }} // The power: clients declare EXACTLY what they needfunction copyData(source: Readable, destination: Writable): void { const buffer = Buffer.alloc(4096); let bytesRead: number; while ((bytesRead = source.read(buffer, 0, buffer.length)) > 0) { destination.write(buffer, 0, bytesRead); } // source could be FileHandle, NetworkStream, MemoryBuffer—anything Readable // destination could be FileHandle, S3Uploader, Console—anything Writable} function processWithReset(stream: Readable & Seekable): Result { // Intersection type: must be BOTH Readable AND Seekable const result = parseStream(stream); stream.seek(0); // Reset for another pass return result;}When FileHandle implements Readable, Writable, Seekable, and Closeable, it's not 'combining four responsibilities'—it's expressing that a file handle naturally plays all four roles. These aren't arbitrary groupings; they're intrinsic to what a file handle IS. The class is cohesive because all its capabilities serve the unified purpose of file I/O.
One of the most powerful features enabled by multiple interface implementation is intersection types—the ability to express that a client needs an object that implements multiple specific interfaces.
Intersection types are the demand side of ISP. While split interfaces offer minimal contracts, intersection types let clients combine exactly the roles they need—no more, no less.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104
// Individual role interfacesinterface Identifiable { readonly id: string;} interface Timestamped { readonly createdAt: Date; readonly updatedAt: Date;} interface Versionable { readonly version: number; incrementVersion(): void;} interface SoftDeletable { readonly isDeleted: boolean; softDelete(): void; restore(): void;} interface Auditable { readonly createdBy: string; readonly modifiedBy: string; recordModification(userId: string): void;} // INTERSECTION TYPES: Combine exactly what's needed // A repository that works with identifiable, timestamped entitiesinterface Repository<T extends Identifiable & Timestamped> { findById(id: string): T | null; findByCreatedAfter(date: Date): T[]; findByUpdatedAfter(date: Date): T[]; save(entity: T): void;} // An audit service that needs auditable + identifiableclass AuditService { recordChange<T extends Identifiable & Auditable>( entity: T, userId: string ): void { entity.recordModification(userId); this.auditLog.append({ entityId: entity.id, modifiedBy: userId, timestamp: new Date() }); }} // A soft-delete handler that needs identifiable + soft-deletableclass SoftDeleteHandler<T extends Identifiable & SoftDeletable> { delete(entity: T): void { entity.softDelete(); this.events.emit('entitySoftDeleted', { id: entity.id }); } restore(entity: T): void { entity.restore(); this.events.emit('entityRestored', { id: entity.id }); }} // An optimistic locker that needs identifiable + versionableclass OptimisticLocker<T extends Identifiable & Versionable> { checkAndIncrement(entity: T, expectedVersion: number): boolean { if (entity.version !== expectedVersion) { throw new OptimisticLockError( `Entity ${entity.id} was modified. Expected v${expectedVersion}, found v${entity.version}` ); } entity.incrementVersion(); return true; }} // THE POWER: Different entities implement different combinationsclass User implements Identifiable, Timestamped, Auditable { readonly id: string; readonly createdAt: Date; updatedAt: Date; createdBy: string; modifiedBy: string; recordModification(userId: string): void { this.modifiedBy = userId; this.updatedAt = new Date(); }} class Document implements Identifiable, Timestamped, Versionable, SoftDeletable, Auditable { // Full implementation of all five interfaces} class LogEntry implements Identifiable, Timestamped { // Only identifiable and timestamped—immutable, not auditable}The Combinatorial Power:
With 5 orthogonal role interfaces, we can express:
| Interfaces Combined | Possible Combinations |
|---|---|
| 1 | 5 options |
| 2 | 10 options |
| 3 | 10 options |
| 4 | 5 options |
| 5 | 1 option |
| Total | 31 combinations |
This is ISP's multiplicative power: every new role interface doubles the expressiveness of your type vocabulary.
For frequently-used combinations, create named type aliases: type AuditableEntity = Identifiable & Timestamped & Auditable. This improves readability and documents common patterns in your domain. The alias costs nothing at runtime—it's purely a documentation and convenience feature.
When a class implements multiple interfaces, you have two primary strategies for organizing the implementation:
The choice depends on cohesion, reusability, and complexity factors.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
// ============================================// STRATEGY 1: INLINE IMPLEMENTATION// All methods use shared internal state// ============================================ interface Persistable { save(): void; load(id: string): void; readonly isDirty: boolean;} interface Validatable { validate(): ValidationResult; readonly isValid: boolean;} interface Serializable { toJSON(): object; fromJSON(data: object): void;} // INLINE: User has simple, interconnected implementationsclass User implements Persistable, Validatable, Serializable { private _id: string; private _name: string; private _email: string; private _isDirty: boolean = false; // All methods share access to the same simple state // Persistable - uses internal fields directly save(): void { const data = this.toJSON(); // Reuses Serializable database.save('users', this._id, data); this._isDirty = false; } load(id: string): void { const data = database.load('users', id); this.fromJSON(data); // Reuses Serializable this._isDirty = false; } get isDirty(): boolean { return this._isDirty; } // Validatable - simple validation of internal fields validate(): ValidationResult { const errors: string[] = []; if (!this._name) errors.push('Name required'); if (!isValidEmail(this._email)) errors.push('Invalid email'); return { valid: errors.length === 0, errors }; } get isValid(): boolean { return this.validate().valid; } // Serializable - direct field access toJSON(): object { return { id: this._id, name: this._name, email: this._email }; } fromJSON(data: object): void { this._id = data['id']; this._name = data['name']; this._email = data['email']; }} // ============================================// STRATEGY 2: DELEGATION// Complex logic delegated to specialized objects// ============================================ interface Searchable { index(): void; updateIndex(): void; removeFromIndex(): void;} interface Cacheable { cache(): void; invalidate(): void; isCached(): boolean;} interface Notifiable { notifyCreated(): void; notifyUpdated(): void; notifyDeleted(): void;} // Delegates - specialized implementations that can be reusedclass ElasticsearchIndexer implements Searchable { constructor(private entity: Document, private esClient: ESClient) {} index(): void { this.esClient.index({ index: 'documents', id: this.entity.id, body: this.buildIndexDocument() }); } updateIndex(): void { this.esClient.update({ index: 'documents', id: this.entity.id, body: { doc: this.buildIndexDocument() } }); } removeFromIndex(): void { this.esClient.delete({ index: 'documents', id: this.entity.id }); } private buildIndexDocument(): object { return { title: this.entity.title, content: this.entity.content, tags: this.entity.tags, createdAt: this.entity.createdAt }; }} class RedisCache implements Cacheable { constructor(private entity: Document, private redis: RedisClient) {} cache(): void { this.redis.setex( `doc:${this.entity.id}`, 3600, // 1 hour TTL JSON.stringify(this.entity) ); } invalidate(): void { this.redis.del(`doc:${this.entity.id}`); } isCached(): boolean { return this.redis.exists(`doc:${this.entity.id}`); }} class WebhookNotifier implements Notifiable { constructor( private entity: Document, private webhooks: WebhookService ) {} notifyCreated(): void { this.webhooks.trigger('document.created', { document: this.entity }); } notifyUpdated(): void { this.webhooks.trigger('document.updated', { document: this.entity }); } notifyDeleted(): void { this.webhooks.trigger('document.deleted', { documentId: this.entity.id }); }} // DELEGATION: Document delegates complex responsibilitiesclass Document implements Searchable, Cacheable, Notifiable { readonly id: string; title: string; content: string; tags: string[]; createdAt: Date; // Delegates handle complex logic private readonly indexer: ElasticsearchIndexer; private readonly cache: RedisCache; private readonly notifier: WebhookNotifier; constructor( data: DocumentData, esClient: ESClient, redis: RedisClient, webhooks: WebhookService ) { this.id = data.id; this.title = data.title; // ... initialize fields // Create delegates this.indexer = new ElasticsearchIndexer(this, esClient); this.cache = new RedisCache(this, redis); this.notifier = new WebhookNotifier(this, webhooks); } // Searchable - delegate to indexer index(): void { this.indexer.index(); } updateIndex(): void { this.indexer.updateIndex(); } removeFromIndex(): void { this.indexer.removeFromIndex(); } // Cacheable - delegate to cache cache(): void { this.cache.cache(); } invalidate(): void { this.cache.invalidate(); } isCached(): boolean { return this.cache.isCached(); } // Notifiable - delegate to notifier notifyCreated(): void { this.notifier.notifyCreated(); } notifyUpdated(): void { this.notifier.notifyUpdated(); } notifyDeleted(): void { this.notifier.notifyDeleted(); }}Notice how ElasticsearchIndexer, RedisCache, and WebhookNotifier are all reusable. Any entity that needs search indexing can use ElasticsearchIndexer. Any entity that needs caching can use RedisCache. Delegation transforms interface implementation into composable building blocks.
An advanced pattern for multiple interface implementation is Role Objects—instead of one class implementing all interfaces, we compose separate objects that each implement one interface, united by a central coordinator.
This pattern is powerful when:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
// Role interfacesinterface Printable { print(): void; getPreview(): PrintPreview;} interface Exportable { exportTo(format: ExportFormat): Buffer; getSupportedFormats(): ExportFormat[];} interface Shareable { share(recipients: Recipient[]): ShareResult; getShareableLink(): URL; revokeSharing(): void;} interface Versionable { createVersion(comment: string): Version; getVersionHistory(): Version[]; revertTo(version: Version): void;} // Role implementations as separate classesclass DocumentPrinter implements Printable { constructor(private doc: DocumentCore) {} print(): void { const printData = this.formatForPrint(); printerService.print(printData); } getPreview(): PrintPreview { return new PrintPreview(this.formatForPrint()); } private formatForPrint(): PrintData { return { title: this.doc.title, content: this.doc.content, pageSettings: this.doc.pageSettings }; }} class DocumentExporter implements Exportable { constructor(private doc: DocumentCore) {} exportTo(format: ExportFormat): Buffer { switch (format) { case 'pdf': return this.toPDF(); case 'docx': return this.toWord(); case 'html': return this.toHTML(); default: throw new UnsupportedFormatError(format); } } getSupportedFormats(): ExportFormat[] { return ['pdf', 'docx', 'html', 'txt']; } private toPDF(): Buffer { /* conversion logic */ } private toWord(): Buffer { /* conversion logic */ } private toHTML(): Buffer { /* conversion logic */ }} class DocumentSharer implements Shareable { constructor( private doc: DocumentCore, private sharingService: SharingService ) {} share(recipients: Recipient[]): ShareResult { return this.sharingService.share(this.doc.id, recipients); } getShareableLink(): URL { return this.sharingService.createLink(this.doc.id); } revokeSharing(): void { this.sharingService.revokeAll(this.doc.id); }} class DocumentVersioner implements Versionable { constructor( private doc: DocumentCore, private versionStore: VersionStore ) {} createVersion(comment: string): Version { return this.versionStore.create(this.doc.snapshot(), comment); } getVersionHistory(): Version[] { return this.versionStore.getHistory(this.doc.id); } revertTo(version: Version): void { this.doc.restore(version.snapshot); this.createVersion(`Reverted to v${version.number}`); }} // Core document class - minimal, focused on contentclass DocumentCore { readonly id: string; title: string; content: string; pageSettings: PageSettings; snapshot(): DocumentSnapshot { /* ... */ } restore(snapshot: DocumentSnapshot): void { /* ... */ }} // ROLE OBJECT COORDINATOR// Composes roles on-demand, fulfills interfaces through delegationclass Document implements Printable, Exportable, Shareable, Versionable { private core: DocumentCore; // Lazily created role objects private _printer?: DocumentPrinter; private _exporter?: DocumentExporter; private _sharer?: DocumentSharer; private _versioner?: DocumentVersioner; constructor( data: DocumentData, private sharingService: SharingService, private versionStore: VersionStore ) { this.core = new DocumentCore(data); } // Role accessors - lazy initialization private get printer(): DocumentPrinter { return this._printer ??= new DocumentPrinter(this.core); } private get exporter(): DocumentExporter { return this._exporter ??= new DocumentExporter(this.core); } private get sharer(): DocumentSharer { return this._sharer ??= new DocumentSharer(this.core, this.sharingService); } private get versioner(): DocumentVersioner { return this._versioner ??= new DocumentVersioner(this.core, this.versionStore); } // Forward interface methods to role objects // Printable print(): void { this.printer.print(); } getPreview(): PrintPreview { return this.printer.getPreview(); } // Exportable exportTo(format: ExportFormat): Buffer { return this.exporter.exportTo(format); } getSupportedFormats(): ExportFormat[] { return this.exporter.getSupportedFormats(); } // Shareable share(recipients: Recipient[]): ShareResult { return this.sharer.share(recipients); } getShareableLink(): URL { return this.sharer.getShareableLink(); } revokeSharing(): void { this.sharer.revokeSharing(); } // Versionable createVersion(comment: string): Version { return this.versioner.createVersion(comment); } getVersionHistory(): Version[] { return this.versioner.getVersionHistory(); } revertTo(version: Version): void { this.versioner.revertTo(version); }}Benefits of the Role Object Pattern:
Role Objects add architectural complexity. Use them when roles are genuinely independent, reusable, or need runtime polymorphism. For simpler cases where all interfaces are intrinsic to the class's identity, inline implementation is cleaner.
Even when using inline implementation, classes implementing many interfaces can become unwieldy. Here are organizational techniques to keep such classes readable and maintainable:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
// ============================================// TECHNIQUE 1: Region-Based Organization// Group methods by interface with clear comments// ============================================ class OrderService implements OrderRepository, OrderValidator, OrderProcessor, OrderNotifier, OrderReporter{ // ───────────────────────────────────────────── // FIELDS & CONSTRUCTOR // ───────────────────────────────────────────── private readonly db: Database; private readonly eventBus: EventBus; private readonly emailService: EmailService; constructor(/* dependencies */) { /* ... */ } // ───────────────────────────────────────────── // OrderRepository Implementation // ───────────────────────────────────────────── findById(id: string): Order | null { /* ... */ } findByCustomer(customerId: string): Order[] { /* ... */ } save(order: Order): void { /* ... */ } delete(id: string): void { /* ... */ } // ───────────────────────────────────────────── // OrderValidator Implementation // ───────────────────────────────────────────── validate(order: Order): ValidationResult { /* ... */ } validateLineItems(items: LineItem[]): ValidationResult { /* ... */ } validateShippingAddress(address: Address): ValidationResult { /* ... */ } // ───────────────────────────────────────────── // OrderProcessor Implementation // ───────────────────────────────────────────── process(order: Order): ProcessingResult { /* ... */ } processPayment(order: Order): PaymentResult { /* ... */ } fulfill(order: Order): FulfillmentResult { /* ... */ } cancel(order: Order, reason: string): void { /* ... */ } // ───────────────────────────────────────────── // OrderNotifier Implementation // ───────────────────────────────────────────── notifyOrderReceived(order: Order): void { /* ... */ } notifyOrderShipped(order: Order): void { /* ... */ } notifyOrderDelivered(order: Order): void { /* ... */ } // ───────────────────────────────────────────── // OrderReporter Implementation // ───────────────────────────────────────────── getSalesReport(period: DateRange): SalesReport { /* ... */ } getOrderByStatusReport(): StatusReport { /* ... */ } getCustomerOrderHistory(customerId: string): OrderHistory { /* ... */ } // ───────────────────────────────────────────── // Private Helpers (shared across interfaces) // ───────────────────────────────────────────── private mapToOrder(row: DatabaseRow): Order { /* ... */ } private calculateTotal(items: LineItem[]): Money { /* ... */ }} // ============================================// TECHNIQUE 2: Mixin-Based Organization (TypeScript)// Factor implementation into reusable mixins// ============================================ // Base classclass OrderServiceBase { protected readonly db: Database; protected readonly eventBus: EventBus; protected readonly emailService: EmailService; constructor(deps: OrderServiceDeps) { /* ... */ }} // Mixin typestype Constructor<T = {}> = new (...args: any[]) => T; // Repository mixinfunction RepositoryMixin<TBase extends Constructor<OrderServiceBase>>(Base: TBase) { return class extends Base implements OrderRepository { findById(id: string): Order | null { return this.db.orders.findById(id); } findByCustomer(customerId: string): Order[] { return this.db.orders.findByCustomer(customerId); } save(order: Order): void { this.db.orders.save(order); } delete(id: string): void { this.db.orders.delete(id); } };} // Validator mixinfunction ValidatorMixin<TBase extends Constructor<OrderServiceBase>>(Base: TBase) { return class extends Base implements OrderValidator { validate(order: Order): ValidationResult { const errors: string[] = []; // validation logic return { valid: errors.length === 0, errors }; } validateLineItems(items: LineItem[]): ValidationResult { /* ... */ } validateShippingAddress(address: Address): ValidationResult { /* ... */ } };} // Notifier mixinfunction NotifierMixin<TBase extends Constructor<OrderServiceBase>>(Base: TBase) { return class extends Base implements OrderNotifier { notifyOrderReceived(order: Order): void { this.emailService.send(/* ... */); this.eventBus.publish('order.received', order); } notifyOrderShipped(order: Order): void { /* ... */ } notifyOrderDelivered(order: Order): void { /* ... */ } };} // Compose mixins into final classconst OrderServiceWithMixins = NotifierMixin( ValidatorMixin( RepositoryMixin(OrderServiceBase) )); class OrderService extends OrderServiceWithMixins implements OrderRepository, OrderValidator, OrderNotifier{ // Additional methods or overrides if needed}| Technique | Best For | Trade-offs |
|---|---|---|
| Region-Based Comments | Moderate complexity, clear structure | Requires discipline; no compile-time enforcement |
| Mixins / Traits | Reusable implementations across classes | Added complexity; language-specific support |
| Role Objects | Complex roles, runtime flexibility | More classes; forwarding boilerplate |
| Partial Classes (C#) | Very large classes | C# only; still one logical class |
If your class implementing multiple interfaces exceeds ~300-400 lines, consider whether it's truly cohesive. Multiple interfaces is fine; multiple responsibilities masquerading as interfaces is not. A class should implement multiple ROLES, not multiple CONCERNS.
When implementing multiple interfaces, you may encounter method signature conflicts—two interfaces defining methods with the same name but different semantics or signatures. Different languages handle this differently:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// SCENARIO: Two interfaces with same method name, different return types interface Readable { read(): string; // Returns string content} interface BinaryReadable { read(): Buffer; // Returns binary data} // ❌ PROBLEM: TypeScript cannot satisfy both// class FileReader implements Readable, BinaryReadable {// read(): string | Buffer { } // Type conflict!// } // ✓ SOLUTION 1: Rename methods in your interfacesinterface TextReader { readText(): string;} interface BinaryReader { readBinary(): Buffer;} class FileReader implements TextReader, BinaryReader { readText(): string { /* ... */ } readBinary(): Buffer { /* ... */ }} // ✓ SOLUTION 2: Generic interface with type parameterinterface Reader<T> { read(): T;} class TextFileReader implements Reader<string> { read(): string { /* ... */ }} class BinaryFileReader implements Reader<Buffer> { read(): Buffer { /* ... */ }} // ✓ SOLUTION 3: Adapter pattern when you don't control interfacesinterface ExternalReadable { read(): string;} interface ExternalBinaryReadable { read(): Buffer;} // Adapter that implements one interface and wraps the otherclass BinaryReadableAdapter implements ExternalReadable { constructor(private binary: ExternalBinaryReadable) {} read(): string { return this.binary.read().toString('utf-8'); }}Method conflicts often signal that the interfaces weren't designed with ISP in mind, or that they're from different domains being incorrectly combined. When you control the interface design, use descriptive method names that include context (readText vs readBinary, closeFile vs closeConnection) to avoid future conflicts.
Testing classes that implement multiple interfaces requires a structured approach. The key insight: test each interface's contract independently, then test interactions.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
// Class under testclass DocumentService implements DocumentRepository, DocumentValidator, DocumentExporter{ // ... full implementation} // ============================================// TESTING STRATEGY: Interface-Specific Test Suites// ============================================ describe('DocumentService', () => { let service: DocumentService; beforeEach(() => { service = new DocumentService(/* dependencies */); }); // ───────────────────────────────────────────── // Test Suite: DocumentRepository Contract // ───────────────────────────────────────────── describe('as DocumentRepository', () => { // Type assertion: treat service as just the interface let repository: DocumentRepository; beforeEach(() => { repository = service; // Narrowed to interface type }); test('findById returns document when exists', () => { const doc = repository.findById('existing-id'); expect(doc).toBeDefined(); expect(doc?.id).toBe('existing-id'); }); test('findById returns null when not exists', () => { const doc = repository.findById('non-existent-id'); expect(doc).toBeNull(); }); test('save persists document', () => { const doc = createTestDocument(); repository.save(doc); expect(repository.findById(doc.id)).toEqual(doc); }); test('delete removes document', () => { const doc = createTestDocument(); repository.save(doc); repository.delete(doc.id); expect(repository.findById(doc.id)).toBeNull(); }); }); // ───────────────────────────────────────────── // Test Suite: DocumentValidator Contract // ───────────────────────────────────────────── describe('as DocumentValidator', () => { let validator: DocumentValidator; beforeEach(() => { validator = service; }); test('validate returns valid for correct document', () => { const doc = createValidDocument(); const result = validator.validate(doc); expect(result.valid).toBe(true); expect(result.errors).toHaveLength(0); }); test('validate returns invalid for missing title', () => { const doc = createDocumentWithoutTitle(); const result = validator.validate(doc); expect(result.valid).toBe(false); expect(result.errors).toContain('Title is required'); }); test('validate returns invalid for content too long', () => { const doc = createDocumentWithLongContent(); const result = validator.validate(doc); expect(result.valid).toBe(false); expect(result.errors).toContain('Content exceeds maximum length'); }); }); // ───────────────────────────────────────────── // Test Suite: DocumentExporter Contract // ───────────────────────────────────────────── describe('as DocumentExporter', () => { let exporter: DocumentExporter; beforeEach(() => { exporter = service; }); test('exportToPDF returns valid PDF buffer', () => { const doc = createTestDocument(); const pdf = exporter.exportToPDF(doc); expect(pdf.slice(0, 4).toString()).toBe('%PDF'); // PDF magic bytes }); test('exportToHTML returns valid HTML', () => { const doc = createTestDocument(); const html = exporter.exportToHTML(doc); expect(html).toContain('<!DOCTYPE html>'); expect(html).toContain(doc.title); }); }); // ───────────────────────────────────────────── // Test Suite: Cross-Interface Interactions // ───────────────────────────────────────────── describe('interface interactions', () => { test('exported document matches saved document', () => { const doc = createTestDocument(); service.save(doc); // Repository interface const retrieved = service.findById(doc.id)!; // Repository interface const exported = service.exportToHTML(retrieved); // Exporter interface expect(exported).toContain(doc.title); expect(exported).toContain(doc.content); }); test('invalid documents cannot be saved', () => { const invalidDoc = createDocumentWithoutTitle(); const result = service.validate(invalidDoc); // Validator interface expect(result.valid).toBe(false); // Business rule: don't save invalid documents expect(() => service.save(invalidDoc)).toThrow(); // Repository interface }); });});We've covered the practical patterns for implementing multiple interfaces cleanly and maintainably. Let's consolidate the key lessons:
Next Steps:
Knowing how to implement multiple interfaces is essential, but real-world systems face a harder challenge: migrating from fat interfaces to split ones without breaking existing code. The next page covers migration strategies—how to evolve your architecture incrementally while maintaining backward compatibility.
You now have a comprehensive toolkit for implementing multiple interfaces cleanly. Whether you choose inline implementation, delegation, or role objects, you can keep your code organized, testable, and maintainable. These patterns enable you to fully leverage the flexibility that ISP provides.