mybook

Adapter パターン — インターフェースを変換する

「決済サービスを2つに対応してほしい」

月曜日の朝、ケンタはコーヒーを片手に席に着いた。すると山田さんがやってきてホワイトボードに決済フロー図を書き始めた。

「ケンタくん、ちょっといいか?EC部門から要望が来ていてね。StripeとPayPalの両方に対応したい。でも社内の決済コードは全部Stripeのインターフェースに合わせて書かれているんだ。PayPalのAPIは呼び方が全然違う。」

「全部書き直すんですか?」ケンタは不安な顔をした。

「それが現実的じゃない。PaymentService を呼び出しているコントローラやテストコードも含めると100ファイル以上になる。書き直している間に別のバグを埋め込む確率が高い。」

ケンタはしばらく考えた。頭の中で「もし PayPal を追加するなら…」とシミュレーションすると、いたるところに if provider == "stripe" の分岐が増えていく未来が見えた。まるでタコ足配線だ。コンセントが足りないからといって延長コードを何本も重ねると、どこが何に繋がっているか誰も分からなくなってしまう。

「……PayPalのAPIを呼ぶときに、Stripeっぽいインターフェースで使えるようにする……?」

山田さんがニヤリと笑った。「それがAdapterパターンだ。」

INFO

Adapterパターンは「既存コードを変えずに新しいシステムと接続する」ための解決策。統合の規模が大きいほど、このパターンの威力が増す。

Adapter パターンとは

Adapter パターンは、互換性のないインターフェースを持つクラスを一緒に使えるようにするパターンだ。GoFデザインパターンの分類では「構造パターン」に属する。

日常の比喩は電源アダプタそのものだ。日本から海外旅行に行くとき、電源プラグの形状が国によって違う。でも電化製品(クライアント)を新しく買うわけでも、現地のコンセント(外部の仕様)を改造するわけでもない。アダプタ1つ持っていけば解決する。

別の比喩を使うなら通訳者だ。日本語しか話せない人(クライアント)と英語しか話せない人(外部システム)が会議をするとき、通訳者(Adapter)が間に入って変換する。どちらの人も相手の言語を覚える必要がない。

さらに言えばUSB変換アダプタも同じだ。USB-AのケーブルをUSB-Cポートに接続したいとき、ケーブルもパソコンも変えずにアダプタ1つで解決する。

Loading diagram...

登場人物は4つだ。

  • Client: Targetインターフェースを通じてサービスを使うコントローラやサービス
  • Target: Clientが期待する共通インターフェース
  • Adapter: Targetを実装し、実際のAPIを呼び出して変換する
  • Adaptee: 実際の外部サービス(Stripe API / PayPal API)

INFO

Adapterパターンの本質は「変換責務の局所化」。インターフェースの違いによる複雑さをAdapterクラスに閉じ込め、クライアントコードをシンプルに保つ。

問題のあるコード(Before)

Stripeに直接依存した既存コードを確認しよう。

# app/services/payment_service.rb
class PaymentService
  def charge(user:, amount:, card_token:)
    customer = Stripe::Customer.create(
      email: user.email,
      source: card_token
    )
 
    charge = Stripe::Charge.create(
      amount: amount,
      currency: "jpy",
      customer: customer.id
    )
 
    { success: true, transaction_id: charge.id, provider: "stripe" }
  end
 
  def refund(transaction_id:, amount:)
    Stripe::Refund.create(charge: transaction_id, amount: amount)
  end
end

PayPalのAPIを見ると、呼び方が根本的に違う。

# PayPalのAPI(Stripeと全く異なるインターフェース)
# 金額の単位が違う(StripeはSen単位、PayPalは円単位の浮動小数点)
# ネストしたハッシュ構造が異なり、リダイレクトURLの設定が必要
payment = PayPal::SDK::REST::Payment.new({
  intent: "sale",
  payer: { payment_method: "paypal", payer_info: { email: user.email } },
  transactions: [{
    amount: { total: (amount / 100.0).to_s, currency: "JPY" }
  }],
  redirect_urls: {
    return_url: "https://example.com/paypal/success",
    cancel_url:  "https://example.com/paypal/cancel"
  }
})
payment.create

if provider == "stripe" の分岐を各メソッドに入れますか?」とケンタが聞くと、山田さんは首を横に振った。「PayPalが増えて次にAmazon Payも対応となったら全メソッドを修正することになる。それはオープン・クローズド原則の違反だ。」

WARNING

if provider == "stripe" ... elsif provider == "paypal" という条件分岐を各メソッドに書き込むのはアンチパターン。新しいプロバイダが増えるたびに全メソッドの修正が必要になり、バグの混入リスクが高まる。

Adapterパターンで解決

ステップ1: 共通インターフェース(Target)を定義

まず「全ての決済プロバイダが守るべき約束」を定義する。

# app/gateways/payment_gateway.rb
module PaymentGateway
  def charge(user:, amount:, payment_token:)
    raise NotImplementedError, "#{self.class}#charge を実装してください"
  end
 
  def refund(transaction_id:, amount:)
    raise NotImplementedError, "#{self.class}#refund を実装してください"
  end
 
  def retrieve(transaction_id:)
    raise NotImplementedError, "#{self.class}#retrieve を実装してください"
  end
 
  # 戻り値の共通フォーマット(全Adapterがこの形式で返す)
  # charge  → { success: bool, transaction_id: str, amount: int, provider: str }
  # refund  → { success: bool, refund_id: str }
  # retrieve→ { amount: int, status: str }
end

ステップ2: Stripe Adapter

# app/gateways/stripe_gateway.rb
class StripeGateway
  include PaymentGateway
 
  def charge(user:, amount:, payment_token:)
    customer = Stripe::Customer.create(
      email: user.email,
      source: payment_token
    )
 
    charge = Stripe::Charge.create(
      amount: amount,
      currency: "jpy",
      customer: customer.id
    )
 
    # Stripe固有のレスポンスを共通フォーマットに変換
    {
      success: true,
      transaction_id: charge.id,
      amount: charge.amount,
      provider: "stripe",
      raw_response: charge
    }
  rescue Stripe::CardError => e
    { success: false, error: e.message, provider: "stripe" }
  end
 
  def refund(transaction_id:, amount:)
    refund = Stripe::Refund.create(charge: transaction_id, amount: amount)
    { success: true, refund_id: refund.id }
  rescue Stripe::StripeError => e
    { success: false, error: e.message }
  end
 
  def retrieve(transaction_id:)
    charge = Stripe::Charge.retrieve(transaction_id)
    { amount: charge.amount, status: charge.status }
  end
end

ステップ3: PayPal Adapter(異なるAPIを同じインターフェースに変換)

# app/gateways/paypal_gateway.rb
class PaypalGateway
  include PaymentGateway
 
  def charge(user:, amount:, payment_token:)
    # Adapterの仕事1: 単位変換(銭 → 円)
    amount_in_yens = (amount / 100.0).round(2)
 
    payment = PayPal::SDK::REST::Payment.new({
      intent: "sale",
      payer: {
        payment_method: "paypal",
        payer_info: { email: user.email }
      },
      transactions: [{
        amount: { total: amount_in_yens.to_s, currency: "JPY" }
      }],
      redirect_urls: {
        return_url: Rails.application.routes.url_helpers.paypal_success_url,
        cancel_url:  Rails.application.routes.url_helpers.paypal_cancel_url
      }
    })
 
    if payment.create
      # Adapterの仕事2: レスポンス形式の変換
      {
        success: true,
        transaction_id: payment.id,
        amount: amount,     # クライアントが期待する銭単位に戻す
        provider: "paypal",
        raw_response: payment
      }
    else
      { success: false, error: payment.error.message, provider: "paypal" }
    end
  end
 
  def refund(transaction_id:, amount:)
    sale = PayPal::SDK::REST::Sale.find(transaction_id)
    refund = sale.refund({
      amount: { total: (amount / 100.0).to_s, currency: "JPY" }
    })
    { success: refund.success?, refund_id: refund.id }
  end
 
  def retrieve(transaction_id:)
    payment = PayPal::SDK::REST::Payment.find(transaction_id)
    total_yen = payment.transactions.first.amount.total.to_f
    { amount: (total_yen * 100).to_i, status: payment.state }
  end
end

INFO

StripeGatewayPaypalGateway も、charge(user:, amount:, payment_token:) という同じシグネチャを持つ。単位変換や形式変換はAdapter内部で完結しており、利用側のコードは「どちらのゲートウェイか」を一切気にしない。

ステップ4: コントローラはインターフェースだけを使う

# app/services/payment_service.rb
class PaymentService
  def initialize(gateway: StripeGateway.new)
    @gateway = gateway
  end
 
  def charge(user:, amount:, payment_token:)
    result = @gateway.charge(
      user: user, amount: amount, payment_token: payment_token
    )
 
    if result[:success]
      Payment.create!(
        user: user, amount: amount,
        transaction_id: result[:transaction_id],
        provider: result[:provider]
      )
    end
 
    result
  end
end
 
# app/controllers/payments_controller.rb
class PaymentsController < ApplicationController
  def create
    gateway = build_gateway(params[:provider])
    service = PaymentService.new(gateway: gateway)
 
    result = service.charge(
      user: current_user,
      amount: params[:amount].to_i,
      payment_token: params[:token]
    )
 
    if result[:success]
      render json: { status: "ok", transaction_id: result[:transaction_id] }
    else
      render json: { status: "error", message: result[:error] },
             status: :unprocessable_entity
    end
  end
 
  private
 
  def build_gateway(provider)
    case provider
    when "stripe" then StripeGateway.new
    when "paypal" then PaypalGateway.new
    else raise ArgumentError, "不明なプロバイダ: #{provider}"
    end
  end
end

テストが劇的に書きやすくなった

Adapterパターンの隠れた恩恵はテスタビリティだ。コンストラクタインジェクションのおかげでモックを差し込める。

# spec/services/payment_service_spec.rb
RSpec.describe PaymentService do
  # 本物のStripe/PayPal APIを呼ばずに済む
  let(:mock_gateway) do
    instance_double(
      StripeGateway,
      charge: {
        success: true, transaction_id: "txn_test_123",
        amount: 1000, provider: "mock"
      },
      refund: { success: true, refund_id: "ref_test_456" }
    )
  end
 
  let(:service) { PaymentService.new(gateway: mock_gateway) }
 
  describe "#charge" do
    let(:user) { create(:user) }
 
    it "決済記録をDBに作成する" do
      expect {
        service.charge(user: user, amount: 1000, payment_token: "tok_test")
      }.to change(Payment, :count).by(1)
    end
 
    it "ゲートウェイが失敗したときはDBに保存しない" do
      allow(mock_gateway).to receive(:charge).and_return(
        { success: false, error: "カード残高不足" }
      )
      expect {
        service.charge(user: user, amount: 1000, payment_token: "tok_fail")
      }.not_to change(Payment, :count)
    end
  end
end

INFO

PaymentService のテストでは外部API呼び出しが一切不要。テスト速度が劇的に改善し、CI環境でのネットワーク依存もなくなる。各Adapterの単体テストは VCRカセット(HTTPリクエストの録画・再生)で外部APIとの契約だけをテストすればよい。

レガシーシステムとの統合(SOAP → REST風)

ケンタが入社する前から存在する在庫管理システムがあった。SOAP/XMLベースの古いAPIだ。新しいRailsアプリからはREST APIのように呼びたい。まるで古い蒸気機関車と新幹線を同じ線路で走らせるような問題だが、Adapterがそれを解決する。

Loading diagram...
# app/adapters/inventory_adapter.rb
class InventoryAdapter
  LEGACY_ENDPOINT = ENV["LEGACY_INVENTORY_SOAP_URL"]
 
  # 新しいRailsアプリが期待するインターフェース(RESTライク)
  def find_stock(sku:)
    soap_response = call_soap("GetInventory", { SKU: sku })
    parse_stock_response(soap_response)
  end
 
  def reserve(sku:, quantity:)
    soap_response = call_soap("ReserveItem", {
      SKU: sku,
      Quantity: quantity,
      ReservedAt: Time.current.iso8601
    })
    { success: soap_response[:Status] == "OK" }
  end
 
  private
 
  def call_soap(action, params)
    xml_body = build_soap_envelope(action, params)
    response = Faraday.post(LEGACY_ENDPOINT) do |req|
      req.headers["Content-Type"] = "text/xml; charset=utf-8"
      req.headers["SOAPAction"] = action
      req.body = xml_body
    end
    parse_xml(response.body)
  end
 
  def build_soap_envelope(action, params)
    param_xml = params.map { |k, v| "<#{k}>#{v}</#{k}>" }.join
    <<~XML
      <?xml version="1.0"?>
      <soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
        <soap:Body><#{action}>#{param_xml}</#{action}></soap:Body>
      </soap:Envelope>
    XML
  end
 
  def parse_xml(xml)
    Hash.from_xml(xml).dig("Envelope", "Body") || {}
  end
 
  def parse_stock_response(response)
    data = response["GetInventoryResponse"] || {}
    { sku: data["SKU"], quantity: data["AvailableQty"].to_i }
  end
end

使う側は find_stock(sku: "ABC-123") の1行で済む。SOAPのXMLが30行以上あっても、Adapterという箱に閉じ込めてしまえば外から見えない。

INFO

レガシーシステム統合こそAdapterパターンの最大の価値の一つ。SOAPからREST、XMLからJSON、旧APIから新APIへの移行期間中に「橋渡し役」として機能し、移行完了後にAdapterだけ廃棄すれば済む。

Adapterパターンの罠

「完璧なパターンに見えますが、注意することはありますか?」ケンタが尋ねた。山田さんは「もちろんある」と言って「過剰適用」と書いた。

WARNING

過剰適用の危険: 互換性の問題がない場面でもAdapterを追加すると、間接層が増えてコードが読みにくくなる。同じインターフェースを持つクラスが1つしかないなら、Adapterは不要。「将来2つになるかも」はAdapterを作る理由にならない(YAGNI原則)。

WARNING

Adapterの肥大化: Adapterに変換ロジック以外のビジネスロジックを書き始めると責務が混乱する。「月の購入上限チェック」や「ブラックリスト判定」はAdapterではなくServiceやDomainオブジェクトに置くこと。Adapterは「変換だけ」に徹する。

Rails標準のAdapterパターン

ActiveRecord のDBアダプタ

Railsで最も身近なAdapterは ActiveRecord のDBアダプタだ。

# config/database.yml
development:
  adapter: sqlite3      # 開発環境は軽量なSQLite
  database: db/development.sqlite3
 
production:
  adapter: postgresql   # 本番はPostgreSQL
  host: db.example.com
  database: myapp_production

adapter の値を変えるだけで、アプリコードは一切変わらない。内部では ActiveRecord::ConnectionAdapters::SQLite3AdapterActiveRecord::ConnectionAdapters::PostgreSQLAdapterActiveRecord::ConnectionAdapters::Mysql2Adapter がそれぞれのSQL方言を処理している。

# どのDBを使っていても全く同じRubyコードが動く
User.create!(name: "ケンタ", email: "kenta@example.com")
User.where("created_at > ?", 1.week.ago).order(:name)
User.joins(:orders).group(:id).having("COUNT(*) > 3")

ActiveStorage の Service Adapter

# config/storage.yml
local:
  service: Disk
  root: <%= Rails.root.join("storage") %>
 
amazon:
  service: S3
  bucket: my-bucket
  region: ap-northeast-1

ActiveStorage::Service::DiskServiceActiveStorage::Service::S3Service はどれも同じ ActiveStorage::Service インターフェースを実装したAdapterだ。

# アプリコードはどのストレージを使っているか気にしない
user.avatar.attach(io: file, filename: "avatar.png")
user.avatar.download  # ローカルでもS3でも同じ
user.avatar.url       # 公開URLを返す(バックエンドによって実装は違う)
user.avatar.purge     # 削除も同じインターフェース

Action Mailer の Delivery Adapter

# テスト環境ではメモリに蓄積
config.action_mailer.delivery_method = :test
 
# 本番ではSMTP or SendGrid
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: "smtp.sendgrid.net", port: 587,
  user_name: "apikey", password: ENV["SENDGRID_API_KEY"]
}

Mail::TestMailerMail::SMTPDelivery は全て同じインターフェース。メーラーのコードは変更不要だ。

INFO

RailsのActiveRecord(DB)、ActiveStorage(ファイルストレージ)、ActionMailer(メール配送)、ActiveJob(キュー)はいずれもAdapterパターンで設計されている。だから開発環境はSQLite+ローカルストレージ+テストメーラー+非同期なしで動き、本番はPostgreSQL+S3+SMTP+Sidekiqに切り替えられる。

AWSでのAdapterパターン

Loading diagram...

AWS SDK for Ruby は巨大なAdapterだ。各AWSサービスのREST APIは複雑な署名計算(SigV4)や認証ヘッダーを必要とするが、SDKがそれを全て隠蔽してくれる。

# SDKのおかげで、低レベルなHTTP APIを知らなくていい
s3 = Aws::S3::Client.new(region: "ap-northeast-1")
s3.put_object(bucket: "my-bucket", key: "file.txt", body: "Hello")
 
# SDKなしでは自分でSigV4署名を計算し、AMZ-Dateヘッダーを組み立て、
# HTTPリクエストを送信して200以外のステータスを処理する... 100行以上必要

AWS API Gateway はHTTPプロトコルを変換するAdapterとして機能する。クライアントは /orders という一つのエンドポイントを叩くだけで、内部でLambda / ECS / レガシーEC2のどれに振り分けられても気にしない。

AWS Transfer Family はSFTP/FTPプロトコルをS3にブリッジするAdapterだ。既存のSFTPクライアントはコードを変えずにS3にファイルを置ける。

INFO

AWSではSDK(低レベルHTTP隠蔽)、API Gateway(プロトコル変換)、Transfer Family(SFTP→S3)など、インフラレベルでもAdapterパターンが至る所で活用されている。ソフトウェア設計の原則はインフラ設計にも同じように機能する。

ケンタの最終的な気づき

1週間後、ケンタはPayPalのAdapterを本番にリリースした。コードレビューで山田さんが言った。「StripeGatewayとPaypalGatewayを除けば、既存のコードが1行も変わっていない。」

「Adapterって、変化を局所化するためのパターンなんですね。醜い詳細を箱に閉じ込めて、外側をきれいなままに保つ。通訳者が間に入ることで、両側がそれぞれの言語のままでいられる。」

「まさに」と山田さんはうなずいた。「外部ライブラリのバージョンアップ時にも同じだ。新しいバージョンでAPIが変わっても、Adapterだけ直せばいい。インターフェースの安定性が守られる限り、クライアントコードは永遠に変わらなくていい。」

ケンタはノートにメモした。

Adapterパターン = 互換性のないインターフェースを変換する。クライアントも外部ライブラリも変えずに、Adapterだけが変換を担う。醜い詳細を箱に閉じ込め、外側をきれいに保つ。テスト時にモックが書きやすく、ビジネスの変化(決済プロバイダの追加・切り替え)にも強い。


INFO

この章のまとめ

  • Adapterパターンは互換性のないインターフェースを統一し「変換責務」を一か所に閉じ込める
  • クライアントはTargetインターフェースだけを知り、StripeかPayPalかを気にしない
  • 外部サービスを追加・変更しても、クライアントコードは変わらない(Open-Closed原則)
  • テスト時にモックが差し込みやすく、外部API呼び出しなしでテストが書ける
  • レガシーシステムのSOAP/XMLをREST風に包む際にも威力を発揮する
  • RailsのActiveRecord(DBアダプタ)、ActiveStorage、ActionMailer、ActiveJobはAdapterパターンの実例
  • AWSのSDK(SigV4隠蔽)、API Gateway(プロトコル変換)、Transfer Family(SFTP→S3)も同じ思想
  • 過剰適用に注意。AdapterはビジネスロジックではなくAPI変換ロジックのみを持つ