Adapter Pattern

The Adapter pattern allows incompatible interfaces to work together by wrapping an object with an adapter that translates its interface. This pattern is essential in legacy modernization when integrating new systems with old ones, migrating to new libraries, or building anti-corruption layers between systems.

In slides deck: adapter-pattern

What is the Adapter Pattern?

The Adapter pattern acts as a bridge between two incompatible interfaces, similar to how a physical adapter allows you to plug a US device into a European outlet.

Core Concept

Target Interface: The interface your code expects Adaptee: The existing interface that needs adapting Adapter: Translates between Target and Adaptee

// Target interface (what we want)
interface PaymentProcessor {
  processPayment(amount: number, token: string): Promise<PaymentResult>;
}

// Adaptee (legacy system with different interface)
class LegacyPaymentGateway {
  submitTransaction(dollars: number, cents: number, cardToken: string): LegacyResponse {
    // Legacy implementation
  }
}

// Adapter (makes legacy system compatible)
class LegacyPaymentAdapter implements PaymentProcessor {
  constructor(private legacyGateway: LegacyPaymentGateway) {}

  async processPayment(amount: number, token: string): Promise<PaymentResult> {
    // Translate amount to dollars and cents
    const dollars = Math.floor(amount);
    const cents = Math.round((amount - dollars) * 100);

    // Call legacy system
    const legacyResponse = this.legacyGateway.submitTransaction(
      dollars,
      cents,
      token
    );

    // Translate response
    return {
      success: legacyResponse.status === 'OK',
      transactionId: legacyResponse.txnId,
      message: legacyResponse.statusMessage,
    };
  }
}

// Usage: modern code works with legacy system
const paymentProcessor: PaymentProcessor = new LegacyPaymentAdapter(
  new LegacyPaymentGateway()
);

await paymentProcessor.processPayment(99.99, 'token_123');

When to Use Adapters

Scenario 1: Integrating Third-Party Libraries

// You want to use this interface
interface Logger {
  info(message: string, meta?: object): void;
  error(message: string, error?: Error): void;
}

// But library provides this interface
class Winston {
  log(level: string, message: string, metadata: any): void {}
}

// Adapter bridges the gap
class WinstonAdapter implements Logger {
  constructor(private winston: Winston) {}

  info(message: string, meta?: object): void {
    this.winston.log('info', message, meta);
  }

  error(message: string, error?: Error): void {
    this.winston.log('error', message, { error });
  }
}

Scenario 2: Database Migration

// New interface
interface UserRepository {
  findById(id: string): Promise<User>;
  save(user: User): Promise<void>;
}

// Legacy database client
class LegacySQLClient {
  query(sql: string, params: any[]): Promise<any[]> {}
}

// Adapter
class SQLUserRepositoryAdapter implements UserRepository {
  constructor(private sql: LegacySQLClient) {}

  async findById(id: string): Promise<User> {
    const rows = await this.sql.query(
      'SELECT * FROM users WHERE id = ?',
      [id]
    );

    return this.mapRowToUser(rows[0]);
  }

  async save(user: User): Promise<void> {
    await this.sql.query(
      'INSERT INTO users (id, name, email) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), email = VALUES(email)',
      [user.id, user.name, user.email]
    );
  }

  private mapRowToUser(row: any): User {
    return {
      id: row.id,
      name: row.name,
      email: row.email,
    };
  }
}

Scenario 3: API Version Migration

// Modern API interface
interface OrderService {
  createOrder(order: CreateOrderDTO): Promise<Order>;
}

// Legacy SOAP API
class LegacySOAPOrderService {
  CreateOrder(xml: string): string {
    // Returns XML response
  }
}

// Adapter translates JSON to XML and back
class SOAPOrderServiceAdapter implements OrderService {
  constructor(private soapService: LegacySOAPOrderService) {}

  async createOrder(order: CreateOrderDTO): Promise<Order> {
    // Convert JSON to XML
    const xml = this.convertToXML(order);

    // Call SOAP service
    const responseXML = this.soapService.CreateOrder(xml);

    // Parse XML response to JSON
    return this.parseXMLResponse(responseXML);
  }

  private convertToXML(order: CreateOrderDTO): string {
    return `
      <CreateOrderRequest>
        <CustomerId>${order.customerId}</CustomerId>
        <Items>
          ${order.items.map(item => `
            <Item>
              <ProductId>${item.productId}</ProductId>
              <Quantity>${item.quantity}</Quantity>
            </Item>
          `).join('')}
        </Items>
      </CreateOrderRequest>
    `;
  }

  private parseXMLResponse(xml: string): Order {
    // Parse XML and return Order object
  }
}

Real-World Applications

Example 1: Cloud Provider Abstraction

// Unified interface
interface CloudStorage {
  upload(key: string, data: Buffer): Promise<string>;
  download(key: string): Promise<Buffer>;
  delete(key: string): Promise<void>;
}

// AWS S3 adapter
class S3StorageAdapter implements CloudStorage {
  constructor(private s3Client: S3) {}

  async upload(key: string, data: Buffer): Promise<string> {
    const result = await this.s3Client.putObject({
      Bucket: 'my-bucket',
      Key: key,
      Body: data,
    });

    return `https://my-bucket.s3.amazonaws.com/${key}`;
  }

  async download(key: string): Promise<Buffer> {
    const result = await this.s3Client.getObject({
      Bucket: 'my-bucket',
      Key: key,
    });

    return result.Body as Buffer;
  }

  async delete(key: string): Promise<void> {
    await this.s3Client.deleteObject({
      Bucket: 'my-bucket',
      Key: key,
    });
  }
}

// Google Cloud Storage adapter
class GCSStorageAdapter implements CloudStorage {
  constructor(private gcsClient: Storage) {}

  async upload(key: string, data: Buffer): Promise<string> {
    const bucket = this.gcsClient.bucket('my-bucket');
    const file = bucket.file(key);

    await file.save(data);

    return `https://storage.googleapis.com/my-bucket/${key}`;
  }

  async download(key: string): Promise<Buffer> {
    const bucket = this.gcsClient.bucket('my-bucket');
    const file = bucket.file(key);

    const [data] = await file.download();
    return data;
  }

  async delete(key: string): Promise<void> {
    const bucket = this.gcsClient.bucket('my-bucket');
    const file = bucket.file(key);

    await file.delete();
  }
}

// Application code doesn't know which provider
function createStorageAdapter(provider: string): CloudStorage {
  if (provider === 'aws') {
    return new S3StorageAdapter(new S3());
  }
  return new GCSStorageAdapter(new Storage());
}

const storage = createStorageAdapter(process.env.CLOUD_PROVIDER);
await storage.upload('file.txt', Buffer.from('data'));

Example 2: Email Service Abstraction

interface EmailService {
  send(email: Email): Promise<void>;
}

// SendGrid adapter
class SendGridAdapter implements EmailService {
  constructor(private sendgrid: SendGrid) {}

  async send(email: Email): Promise<void> {
    await this.sendgrid.send({
      to: email.to,
      from: email.from,
      subject: email.subject,
      html: email.body,
    });
  }
}

// Mailgun adapter
class MailgunAdapter implements EmailService {
  constructor(private mailgun: Mailgun) {}

  async send(email: Email): Promise<void> {
    await this.mailgun.messages.create('domain.com', {
      to: email.to,
      from: email.from,
      subject: email.subject,
      html: email.body,
    });
  }
}

// Easy to switch providers
const emailService: EmailService = new SendGridAdapter(sendgrid);
// const emailService: EmailService = new MailgunAdapter(mailgun);

Example 3: Anti-Corruption Layer

// Your domain model
interface Customer {
  id: string;
  fullName: string;
  emailAddress: string;
  memberSince: Date;
}

// Legacy system model (different structure)
interface LegacyCustomerRecord {
  customer_id: number;
  first_name: string;
  last_name: string;
  email: string;
  signup_date: string;
}

// Adapter prevents legacy model from corrupting your domain
class CustomerAdapter {
  static toDomain(legacy: LegacyCustomerRecord): Customer {
    return {
      id: String(legacy.customer_id),
      fullName: `${legacy.first_name} ${legacy.last_name}`,
      emailAddress: legacy.email,
      memberSince: new Date(legacy.signup_date),
    };
  }

  static toLegacy(domain: Customer): LegacyCustomerRecord {
    const [firstName, ...lastNameParts] = domain.fullName.split(' ');

    return {
      customer_id: Number(domain.id),
      first_name: firstName,
      last_name: lastNameParts.join(' '),
      email: domain.emailAddress,
      signup_date: domain.memberSince.toISOString(),
    };
  }
}

// Usage
const legacyData = await legacyAPI.getCustomer(123);
const customer = CustomerAdapter.toDomain(legacyData);

// Work with clean domain model
console.log(customer.fullName);

Benefits of Adapters

1. Isolation

Adapters isolate systems from each other's changes:

// If legacy API changes, only adapter needs updating
class LegacyAPIAdapter implements DataService {
  // If API changes from XML to JSON, update only here
}

2. Testability

Mock adapters for testing:

class MockDataAdapter implements DataService {
  async getData(id: string) {
    return { id, data: 'test' };
  }
}

// Easy to test
const service = new BusinessLogic(new MockDataAdapter());

3. Flexibility

Swap implementations easily:

// Development
const storage = new LocalFileStorageAdapter();

// Production
const storage = new S3StorageAdapter();

// Testing
const storage = new MockStorageAdapter();

Common Pitfalls

1. Too Many Adapters

Problem: Adapter for every minor difference.

Solution: Only adapt when interfaces are significantly different.

2. Leaky Abstraction

Problem: Adapter exposes implementation details.

// Bad: Leaks S3 specifics
interface Storage {
  upload(bucket: string, key: string, data: Buffer): Promise<void>;
}

Solution: Abstract interface should be implementation-agnostic.

// Good: Generic interface
interface Storage {
  upload(path: string, data: Buffer): Promise<void>;
}

3. Bidirectional Coupling

Problem: Adapter depends on both interfaces.

Solution: One-way dependency, adapter depends on adaptee.

Key Takeaways

  • Adapter pattern makes incompatible interfaces compatible
  • Essential for legacy integration and third-party library abstraction
  • Provides isolation between systems
  • Enables easy mocking and testing
  • Forms anti-corruption layers in domain-driven design
  • Keep adapters simple and focused
  • Don't overuse—only when interfaces are incompatible
  • One-way dependency from adapter to adaptee

Further Reading