Skip to content
aipatterns.dev

Refactoring techniqueDealing with Generalization

Extract Interface

Declare the subset of a class's methods that clients actually use as an interface, so clients depend on the contract instead of the class.

Problem

Several clients use only part of a class, or several classes offer the same operations and clients should not care which one they get. Clients still name the concrete class, so they cannot be given a fake in tests or a different implementation in production.

Solution

Declare an interface with exactly the methods the clients use, make the class implement it, and change the clients to depend on the interface.

Before

TypeScript
class S3Storage {
  constructor(private client: S3Client, private bucket: string) {}
  async put(key: string, body: Buffer): Promise<void> { /* ... */ }
  async get(key: string): Promise<Buffer> { /* ... */ }
  async presign(key: string, ttlSeconds: number): Promise<string> { /* ... */ }
  async listVersions(key: string): Promise<string[]> { /* ... */ }
}

class InvoiceArchive {
  constructor(private storage: S3Storage) {}
  async store(invoice: Invoice, pdf: Buffer): Promise<string> {
    const key = `invoices/${invoice.tenantId}/${invoice.number}.pdf`;
    await this.storage.put(key, pdf);
    return this.storage.presign(key, 3600);
  }
}

After

TypeScript
interface BlobStore {
  put(key: string, body: Buffer): Promise<void>;
  presign(key: string, ttlSeconds: number): Promise<string>;
}

class S3Storage implements BlobStore {
  constructor(private client: S3Client, private bucket: string) {}
  async put(key: string, body: Buffer): Promise<void> { /* ... */ }
  async get(key: string): Promise<Buffer> { /* ... */ }
  async presign(key: string, ttlSeconds: number): Promise<string> { /* ... */ }
  async listVersions(key: string): Promise<string[]> { /* ... */ }
}

class InvoiceArchive {
  constructor(private storage: BlobStore) {}
  async store(invoice: Invoice, pdf: Buffer): Promise<string> {
    const key = `invoices/${invoice.tenantId}/${invoice.number}.pdf`;
    await this.storage.put(key, pdf);
    return this.storage.presign(key, 3600);
  }
}

Why

  • InvoiceArchive can be tested with an in-memory BlobStore and no AWS SDK.
  • Swapping S3 for another store touches only the composition root.
  • The interface documents which capabilities the client relies on.

Steps

  1. List the methods the client actually calls on the class.
  2. Declare an interface containing only those methods, with identical signatures. Run the compiler.
  3. Mark the class as implementing the interface. Run the compiler and tests.
  4. Change the client's field, parameter or variable type from the class to the interface. Run the tests.
  5. Repeat for other clients. Create separate small interfaces if clients use different subsets.

Watch out

  • Do not extract an interface with a single implementation and no test double or second implementation in sight. It adds a file and an indirection. In TypeScript, structural typing lets tests pass any object of the right shape without a declared interface.
  • Do not copy the whole public surface into the interface. A wide interface forces fakes and new implementations to stub methods no one uses.
  • Interfaces carry no code. If implementations share behavior, combine with Extract Superclass or composition.
  • There is no named inverse. Inline the interface (replace it with the class) when it has one implementation and no test value.

Agent checklist

  • The interface contains only methods a client actually calls.
  • There is a second implementation, a test double, or a module boundary that justifies it.
  • Clients now reference the interface type, not the class.
  • Construction of the concrete class happens in one place.

Treats these smells

See also

Original writing and examples. Topic coverage follows Refactoring.Guru, which explains this topic with its own illustrations.