LESSON 40分

ストーリー

高橋アーキテクト
外部ライブラリのインターフェースが、自分たちのシステムと合わないことがある。そんなとき、ライブラリを書き換えるわけにはいかない
高橋アーキテクト
Adapterは”変換プラグ”のようなもの。そしてDecoratorは、既存の機能に”ラッピング”して機能を追加する。どちらも既存のコードを変更せずに、新しい機能を実現するパターンだ

Adapter パターン

目的

互換性のないインターフェースを持つクラスを、既存のインターフェースに適合させる。海外旅行で使う電源アダプターのようなものです。

ユースケース:外部ログライブラリの統合

// 自分たちのシステムで使うロガーインターフェース
interface Logger {
  info(message: string): void;
  warn(message: string): void;
  error(message: string, error?: Error): void;
}

// 外部ライブラリのロガー(変更できない)
class ThirdPartyLogger {
  writeLog(level: number, msg: string, metadata?: Record<string, unknown>): void {
    console.log(`[LEVEL:${level}] ${msg}`, metadata ?? '');
  }
}

// Adapter:外部ライブラリを自分たちのインターフェースに適合
class ThirdPartyLoggerAdapter implements Logger {
  private static readonly LEVELS = { INFO: 1, WARN: 2, ERROR: 3 };

  constructor(private adaptee: ThirdPartyLogger) {}

  info(message: string): void {
    this.adaptee.writeLog(ThirdPartyLoggerAdapter.LEVELS.INFO, message);
  }

  warn(message: string): void {
    this.adaptee.writeLog(ThirdPartyLoggerAdapter.LEVELS.WARN, message);
  }

  error(message: string, error?: Error): void {
    this.adaptee.writeLog(
      ThirdPartyLoggerAdapter.LEVELS.ERROR,
      message,
      error ? { stack: error.stack } : undefined
    );
  }
}

// 使い方
const externalLogger = new ThirdPartyLogger();
const logger: Logger = new ThirdPartyLoggerAdapter(externalLogger);

logger.info('Application started');
logger.error('Something failed', new Error('Connection timeout'));

Adapter のもう1つの例:データソースの統合

// 社内API
interface UserDataSource {
  fetchUsers(): Promise<User[]>;
}

// 外部CRM API(インターフェースが異なる)
class ExternalCrmApi {
  async getContacts(apiKey: string, page: number): Promise<CrmContact[]> {
    // 外部APIを呼び出す
    return [];
  }
}

// Adapter
class CrmApiAdapter implements UserDataSource {
  constructor(
    private crmApi: ExternalCrmApi,
    private apiKey: string
  ) {}

  async fetchUsers(): Promise<User[]> {
    const contacts = await this.crmApi.getContacts(this.apiKey, 1);
    return contacts.map(contact => ({
      id: contact.contactId,
      name: `${contact.firstName} ${contact.lastName}`,
      email: contact.emailAddress,
    }));
  }
}

Decorator パターン

目的

オブジェクトに動的に新しい機能を追加する。継承を使わずに、ラッピングによって責任を追加する。

ユースケース:HTTPクライアントの機能拡張

// 基本インターフェース
interface HttpClient {
  request(url: string, options?: RequestOptions): Promise<Response>;
}

// 基本実装
class BasicHttpClient implements HttpClient {
  async request(url: string, options?: RequestOptions): Promise<Response> {
    return fetch(url, options);
  }
}

// Decorator 1: ログ追加
class LoggingHttpClient implements HttpClient {
  constructor(private wrapped: HttpClient) {}

  async request(url: string, options?: RequestOptions): Promise<Response> {
    console.log(`[HTTP] ${options?.method ?? 'GET'} ${url}`);
    const start = Date.now();
    const response = await this.wrapped.request(url, options);
    console.log(`[HTTP] ${response.status} (${Date.now() - start}ms)`);
    return response;
  }
}

// Decorator 2: リトライ追加
class RetryHttpClient implements HttpClient {
  constructor(
    private wrapped: HttpClient,
    private maxRetries: number = 3
  ) {}

  async request(url: string, options?: RequestOptions): Promise<Response> {
    let lastError: Error | undefined;
    for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
      try {
        return await this.wrapped.request(url, options);
      } catch (error) {
        lastError = error as Error;
        console.log(`Retry ${attempt}/${this.maxRetries}...`);
        await this.delay(attempt * 1000);
      }
    }
    throw lastError;
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

// Decorator 3: キャッシュ追加
class CachingHttpClient implements HttpClient {
  private cache: Map<string, { response: Response; timestamp: number }> = new Map();

  constructor(
    private wrapped: HttpClient,
    private ttlMs: number = 60000
  ) {}

  async request(url: string, options?: RequestOptions): Promise<Response> {
    const method = options?.method ?? 'GET';
    if (method !== 'GET') {
      return this.wrapped.request(url, options);
    }

    const cached = this.cache.get(url);
    if (cached && Date.now() - cached.timestamp < this.ttlMs) {
      return cached.response.clone();
    }

    const response = await this.wrapped.request(url, options);
    this.cache.set(url, { response: response.clone(), timestamp: Date.now() });
    return response;
  }
}

// Decoratorの組み合わせ:自由に積み重ねられる
const client: HttpClient = new CachingHttpClient(
  new RetryHttpClient(
    new LoggingHttpClient(
      new BasicHttpClient()
    ),
    3
  ),
  60000
);

// リクエストはキャッシュ → リトライ → ログ → 基本 の順で処理される
await client.request('https://api.example.com/users');

Adapter vs Decorator

観点AdapterDecorator
目的インターフェースの変換機能の追加
元のインターフェース変換前後で異なる同じインターフェースを維持
使用場面外部ライブラリの統合機能の動的な追加
関連原則DIP, ISPOCP, SRP

高橋アーキテクトのアドバイス:

「Adapter は”違うものを同じに見せる”、Decorator は”同じものに新しい力を加える”。目的を混同しないことが大切だ」


まとめ

ポイント内容
Adapter互換性のないインターフェースを接続する変換器
Decorator既存の機能にラッピングで責任を追加する
Adapter の効果外部ライブラリを安全に統合できる
Decorator の効果継承なしで柔軟に機能を組み合わせられる

チェックリスト

  • Adapter パターンを使って外部ライブラリを統合できる
  • Decorator パターンで機能を動的に追加できる
  • 2つのパターンの違いを説明できる

次のステップへ

次は Facade と Composite パターンを学びます。複雑なサブシステムをシンプルにし、木構造を統一的に扱う方法です。


推定読了時間: 40分