#10 デザインパターン入門

Adapter——互換性のないクラスをつなぐ

問題: 外部ライブラリのインターフェースが合わない

決済機能を実装するとき、最初はStripeだけだったのに、あとから「PayPalも使いたい」「GMO後払いも追加して」という要件が来ることがあります。それぞれのSDKはメソッド名も引数も異なります。

// Stripeの使い方
$stripe = new \Stripe\PaymentIntent();
$stripe->create(['amount' => 5800, 'currency' => 'jpy']);

// PayPalの使い方(まったく異なるAPI)
$paypal = new \PayPal\Api\Payment();
$paypal->setAmount(new \PayPal\Api\Amount(['total' => '58.00', 'currency' => 'JPY']));
$paypal->create($apiContext);

// GMO後払いの使い方(さらに異なる)
$gmo = new \GmoPg\PostPayClient();
$gmo->execTran(['jobCd' => 'AUTH', 'amount' => 5800]);

それぞれを直接呼び出すと、アプリケーションのコードが外部ライブラリに強く依存します。ライブラリを変更したとき、呼び出し箇所を全部修正する羽目になります。


パターン: Adapter

Adapterパターンは、互換性のないインターフェースを持つクラスを、既存のインターフェースに合わせてラップするパターンです。コンセントの変換アダプターと同じ発想です。

+-----------------------------+
|   PaymentGateway            |
|   <<interface>>             |
+-----------------------------+
| + charge(amount: int): bool |
| + refund(txId: string): bool|
+-----------------------------+

     _________|___________
    |          |          |
StripeAdapter PayPalAdapter GmoAdapter
(Stripeをラップ)(PayPalをラップ)(GMOをラップ)

PHP 8.x での実装(オブジェクトアダプター)

まず統一インターフェースを定義します。

<?php

// アプリケーションが依存するインターフェース
interface PaymentGateway
{
    /**
     * 決済を実行する
     * @throws \RuntimeException 決済失敗時
     */
    public function charge(int $amountJpy, string $orderId): string;

    /**
     * 返金する
     */
    public function refund(string $transactionId, int $amountJpy): bool;

    public function getName(): string;
}

各決済サービスをラップするアダプターを実装します。

<?php

// Stripeアダプター(オブジェクトアダプター)
class StripeAdapter implements PaymentGateway
{
    private \Stripe\StripeClient $stripe;

    public function __construct(string $apiKey)
    {
        $this->stripe = new \Stripe\StripeClient($apiKey);
    }

    public function charge(int $amountJpy, string $orderId): string
    {
        // Stripe固有のAPIを統一インターフェースに合わせる
        $intent = $this->stripe->paymentIntents->create([
            'amount'   => $amountJpy,
            'currency' => 'jpy',
            'metadata' => ['order_id' => $orderId],
        ]);

        if ($intent->status !== 'succeeded') {
            throw new \RuntimeException("Stripe決済失敗: {$intent->status}");
        }

        return $intent->id; // トランザクションID
    }

    public function refund(string $transactionId, int $amountJpy): bool
    {
        $refund = $this->stripe->refunds->create([
            'payment_intent' => $transactionId,
            'amount'         => $amountJpy,
        ]);

        return $refund->status === 'succeeded';
    }

    public function getName(): string { return 'Stripe'; }
}

// PayPalアダプター
class PayPalAdapter implements PaymentGateway
{
    private \PayPalCheckoutSdk\Core\PayPalHttpClient $client;

    public function __construct(string $clientId, string $clientSecret)
    {
        $environment   = new \PayPalCheckoutSdk\Core\SandboxEnvironment($clientId, $clientSecret);
        $this->client  = new \PayPalCheckoutSdk\Core\PayPalHttpClient($environment);
    }

    public function charge(int $amountJpy, string $orderId): string
    {
        // PayPal固有のAPIを統一インターフェースに変換
        $request = new \PayPalCheckoutSdk\Orders\OrdersCreateRequest();
        $request->body = [
            'intent'         => 'CAPTURE',
            'purchase_units' => [[
                'amount'      => ['currency_code' => 'JPY', 'value' => (string) $amountJpy],
                'reference_id' => $orderId,
            ]],
        ];

        $response = $this->client->execute($request);
        return $response->result->id;
    }

    public function refund(string $transactionId, int $amountJpy): bool
    {
        // PayPalの返金処理
        return true; // 簡略化
    }

    public function getName(): string { return 'PayPal'; }
}

使う側のコードはどの決済サービスを使っても同じです。

class CheckoutService
{
    public function __construct(
        private readonly PaymentGateway $gateway,
    ) {}

    public function checkout(int $orderId, int $amount): string
    {
        $transactionId = $this->gateway->charge($amount, (string) $orderId);
        echo "[{$this->gateway->getName()}] 決済完了: {$transactionId}\n";
        return $transactionId;
    }
}

// 使用例: ゲートウェイを切り替えるだけ
$service = new CheckoutService(new StripeAdapter(env('STRIPE_KEY')));
$service->checkout(101, 5800);

$service = new CheckoutService(new PayPalAdapter(env('PAYPAL_ID'), env('PAYPAL_SECRET')));
$service->checkout(102, 3200);

クラスアダプターとオブジェクトアダプター

Adapterには2つの実装パターンがあります。

オブジェクトアダプター(推奨)

アダプターがラップ対象のオブジェクトを内部に持つパターン。コンストラクターで受け取り、委譲します。

// オブジェクトアダプター(委譲)
class StripeAdapter implements PaymentGateway
{
    public function __construct(
        private readonly \Stripe\StripeClient $stripe, // 外部クラスを保持
    ) {}

    public function charge(int $amount, string $orderId): string
    {
        return $this->stripe->paymentIntents->create([...])->id; // 委譲
    }
}

クラスアダプター(継承)

アダプターがラップ対象を継承するパターン。PHPでは多重継承がないため使いにくいことが多いです。

// クラスアダプター(継承)
// PHPは単一継承なので、interfaceとの組み合わせが必要
class StripeAdapter extends \Stripe\StripeClient implements PaymentGateway
{
    public function charge(int $amount, string $orderId): string
    {
        // 親クラスのメソッドを直接呼べる
        return $this->paymentIntents->create([...])->id;
    }
}

PHPではオブジェクトアダプターの方が柔軟性が高く、一般的に推奨されます。


Laravelでの応用(ファイルストレージ)

LaravelのStorageファサードはAdapterパターンの典型例です。ローカル・S3・FTPなど異なるストレージを同一インターフェースで扱います。

// インターフェースは同じ、裏側のストレージが切り替わる
Storage::disk('local')->put('file.txt', '内容');
Storage::disk('s3')->put('file.txt', '内容');
Storage::disk('ftp')->put('file.txt', '内容');

// 環境変数でデフォルトのディスクを切り替え
// FILESYSTEM_DISK=s3 にするだけで全Storage呼び出しがS3に向く
Storage::put('file.txt', '内容');

独自のストレージアダプターを実装することもできます。

// config/filesystems.php
'disks' => [
    'custom' => [
        'driver' => 'custom_storage', // カスタムドライバー
        'root'   => storage_path('custom'),
    ],
],

// カスタムドライバーの登録
Storage::extend('custom_storage', function ($app, $config) {
    return new \League\Flysystem\Filesystem(new MyCustomAdapter($config));
});

サードパーティSDKのラッピング

外部SDKをアダプターで包む際の基本的なパターンです。

// メール配信サービスのアダプター
interface MailerInterface
{
    public function send(string $to, string $subject, string $body): void;
}

class MailgunAdapter implements MailerInterface
{
    public function __construct(
        private readonly \Mailgun\Mailgun $mailgun,
        private readonly string $domain,
    ) {}

    public function send(string $to, string $subject, string $body): void
    {
        $this->mailgun->messages()->send($this->domain, [
            'from'    => 'noreply@example.com',
            'to'      => [$to],
            'subject' => $subject,
            'html'    => $body,
        ]);
    }
}

class SesAdapter implements MailerInterface
{
    public function __construct(private readonly \Aws\Ses\SesClient $ses) {}

    public function send(string $to, string $subject, string $body): void
    {
        $this->ses->sendEmail([
            'Destination' => ['ToAddresses' => [$to]],
            'Message'     => [
                'Body'    => ['Html' => ['Data' => $body]],
                'Subject' => ['Data' => $subject],
            ],
            'Source' => 'noreply@example.com',
        ]);
    }
}

まとめ

  • Adapterパターンは互換性のないインターフェースを統一するラッパーを作る
  • 外部ライブラリの変更が内部コードに影響しなくなる(依存の逆転)
  • オブジェクトアダプター(委譲)がPHPでは主流
  • LaravelのStorageファサードはAdapterパターンの代表例

次回はTemplate Methodパターンで、アルゴリズムの骨格を抽象クラスで定義する方法を解説します。