mybook

Facade パターン — 複雑さを隠す

「コントローラが200行になってしまった」

2週間後、ケンタのプルリクに山田さんからコメントが届いた。Slackの通知を見た瞬間、ケンタの心臓がドキッとした。

OrdersController#create が200行超えてる。何をやっているのか把握するだけで疲れる。このコントローラはいったい何の責務を持っているんだ?」

ケンタはデスクで固まった。「200行……確かに長いけど、注文処理って複雑なんだからしょうがないじゃないか」と思いながら、自分のコードをスクロールした。在庫確認、注文作成、決済処理、在庫更新、メール送信、ポイント付与、ダッシュボード更新——確かに全部コントローラに書いてある。

「コントローラに何でも詰め込む罠」にはまっていた。

アンチパターン:Fat Controller の実態

ケンタが書いたコードはこうだった:

class OrdersController < ApplicationController
  def create
    # 在庫確認 -- この時点で既に Product を知りすぎている
    params[:items].each do |item|
      product = Product.find(item[:product_id])
      raise InsufficientStockError unless product.stock >= item[:quantity]
    end
 
    # 注文作成 -- ドメインロジックがコントローラに漏れている
    order = Order.new(user: current_user, status: :pending)
    params[:items].each do |item|
      product = Product.find(item[:product_id])
      order.order_items.build(product: product, quantity: item[:quantity],
                               unit_price: product.price)
    end
    order.calculate_totals
    order.save!
 
    # 決済処理 -- Stripe の詳細をコントローラが知っている
    gateway = StripeGateway.new
    result = gateway.charge(user: current_user, amount: order.total_price,
                             payment_token: params[:payment_token])
    raise PaymentFailedError unless result[:success]
    order.update!(payment_id: result[:transaction_id], status: :paid)
 
    # 在庫更新
    order.order_items.each do |item|
      item.product.decrement!(:stock, item.quantity)
    end
 
    # メール送信 / ポイント付与(100円=1ポイント)/ ダッシュボード更新
    OrderMailer.confirmation(order).deliver_later
    current_user.increment!(:points, (order.total_price / 100).floor)
    Dashboard.refresh_sales_data
 
    render json: { order_id: order.id, status: "success" }
  rescue InsufficientStockError
    render json: { error: "在庫不足です" }, status: :unprocessable_entity
  rescue PaymentFailedError
    render json: { error: "決済に失敗しました" }, status: :unprocessable_entity
  end
end

WARNING

Fat Controller(太ったコントローラ)の問題点

  1. テストが書きにくい — HTTP リクエストなしに注文処理だけをテストできない
  2. 再利用できない — バッチ処理や管理画面から同じ処理を呼べない
  3. 変更が怖い — 決済ロジックを変えたいだけなのに在庫処理を壊すかもしれない
  4. 読みにくい — 200行の create アクションを頭の中で全部追わないといけない

「コントローラは交通整理係だ」と山田さんは言った。「交通整理係が料理を作ったり、会計をしたりしてたらおかしいだろう?コントローラは『リクエストを受けて、適切な担当者(サービス)に渡して、レスポンスを返す』だけでいい。」

INFO

コントローラの正しい責務

  1. パラメータを受け取る / 2. 認証・認可を確認する / 3. サービス(Facade)を呼ぶ / 4. 結果をレスポンスとして返す

ビジネスロジックはコントローラが知る必要はない。


Facade パターンとは

Facade パターンは、複雑なサブシステムに対してシンプルなインターフェース(窓口)を提供するパターンだ。

日常の比喩はホテルのフロントデスクだ。「ルームサービスを頼みたい」「タクシーを呼んでほしい」——フロントデスク(Facade)がそれを全部引き受ける。お客さんはキッチンスタッフ、タクシー会社、観光局に個別に電話しなくていい。

もう一つの比喩は家電のリモコンだ。テレビをつけるとき、画面のバックライト回路、映像デコーダ、音声アンプを個別に操作したりしない。電源ボタン一つで全部が協調して動く。Facade パターンはこの「電源ボタン」を作るパターンだ。

Loading diagram...

サービスオブジェクトとしての Facade

RailsではサービスオブジェクトがFacadeパターンの実用的な実装だ。慣習として app/services/ ディレクトリに配置する。

# app/services/order_creation_service.rb
class OrderCreationService
  Result = Struct.new(:success?, :order, :error, keyword_init: true)
 
  def initialize(user:, items:, payment_token:)
    @user = user
    @items = items
    @payment_token = payment_token
  end
 
  def call
    order = nil
 
    ActiveRecord::Base.transaction do
      validate_stock!
      order = create_order
      charge_payment(order)
      update_stock(order)
    end
 
    # トランザクション外(失敗してもロールバック不要な副作用)
    send_confirmation_email(order)
    grant_points(order)
    refresh_dashboard
 
    Result.new(success?: true, order: order)
  rescue InsufficientStockError => e
    Result.new(success?: false, error: e.message)
  rescue PaymentFailedError => e
    Result.new(success?: false, error: e.message)
  end
 
  private
 
  def validate_stock!
    @items.each do |item|
      product = Product.find(item[:product_id])
      unless product.stock >= item[:quantity]
        raise InsufficientStockError, "#{product.name}の在庫が不足しています"
      end
    end
  end
 
  def create_order
    order = Order.new(user: @user, status: :pending)
    @items.each do |item|
      product = Product.find(item[:product_id])
      order.order_items.build(product: product, quantity: item[:quantity],
                               unit_price: product.price)
    end
    order.calculate_totals
    order.save!
    order
  end
 
  def charge_payment(order)
    result = StripeGateway.new.charge(
      user: @user, amount: order.total_price, payment_token: @payment_token
    )
    raise PaymentFailedError, "決済に失敗しました" unless result[:success]
    order.update!(payment_id: result[:transaction_id], status: :paid)
  end
 
  def update_stock(order)
    order.order_items.each { |item| item.product.decrement!(:stock, item.quantity) }
  end
 
  def send_confirmation_email(order) = OrderMailer.confirmation(order).deliver_later
  def grant_points(order) = @user.increment!(:points, (order.total_price / 100).floor)
  def refresh_dashboard = Dashboard.refresh_sales_data
 
  class InsufficientStockError < StandardError; end
  class PaymentFailedError < StandardError; end
end

Before/After: コントローラのスリム化

# After: app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    result = OrderCreationService.new(
      user: current_user,
      items: order_params[:items],
      payment_token: order_params[:payment_token]
    ).call
 
    if result.success?
      render json: { order_id: result.order.id, status: "success" }
    else
      render json: { error: result.error }, status: :unprocessable_entity
    end
  end
 
  private
 
  def order_params
    params.require(:order).permit(:payment_token, items: [:product_id, :quantity])
  end
end

200行が10行になった。コントローラの責務は「リクエストを受けてサービスに渡し、レスポンスを返すこと」に絞られた。

INFO

サービスオブジェクトはRailsの「規約」ではない(公式フォルダ名services/も非公式)。しかし多くのRailsプロジェクトで事実上の標準になっている。重要なのは「複雑さをコントローラから引き剥がす」という考え方だ。


Result オブジェクトパターン

サービスの結果を返す方法はいくつかある。Struct を使った Result パターンが最もシンプルだ。

Result = Struct.new(:success?, :data, :error, keyword_init: true)
 
Result.new(success?: true, data: { order_id: 123 })   # 成功
Result.new(success?: false, error: "在庫不足です")      # 失敗

dry-monads gem を使う場合、関数型スタイルのパターンマッチングが使える:

# Gemfile: gem "dry-monads"
class OrderCreationService
  include Dry::Monads[:result]
 
  def call
    validate_stock!
    order = create_order
    charge_payment(order)
    Success(order)
  rescue InsufficientStockError => e
    Failure(e.message)
  rescue PaymentFailedError => e
    Failure(e.message)
  end
end
 
# コントローラでパターンマッチングを使う
case OrderCreationService.new(...).call
in Success(order)
  render json: { order_id: order.id }
in Failure(error)
  render json: { error: error }, status: :unprocessable_entity
end

WARNING

例外(Exception)を結果の伝達に使うのは避けよう。在庫不足や決済失敗は「想定内のビジネスルール違反」なので、Result オブジェクトや dry-monads の Failure で表現する方が意図が明確になる。例外は「想定外のシステムエラー」のために予約しておく。


別のFacade例:UserRegistrationService

会員登録フローも同じ「複数のサブシステムを協調させる」パターンだ。

Loading diagram...
# app/services/user_registration_service.rb
class UserRegistrationService
  Result = Struct.new(:success?, :user, :error, keyword_init: true)
 
  def initialize(email:, password:, name:)
    @email = email
    @password = password
    @name = name
  end
 
  def call
    user = nil
    ActiveRecord::Base.transaction do
      user = User.create!(email: @email, password: @password, name: @name, role: :member)
      UserPreference.create!(user: user, newsletter: true, notification_email: true)
    end
    UserMailer.welcome(user).deliver_later
    SlackNotifier.post(channel: "#new-members", message: "新規会員: #{user.name}")
    Result.new(success?: true, user: user)
  rescue ActiveRecord::RecordInvalid => e
    Result.new(success?: false, error: e.record.errors.full_messages.join(", "))
  end
end

INFO

「複数の処理を順番に実行し、途中の失敗でロールバックが必要なフロー」は Facade(サービスオブジェクト)のよいユースケースだ。注文作成・会員登録・パスワードリセットなど、多くのユーザー操作がこのパターンに当てはまる。


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

Facade をサービスオブジェクトとして分離することの最大のメリットの一つがテストのしやすさだ。「本全体をテストする」より「各章をテストする」方が細かく確認できるようなものだ。

# spec/services/order_creation_service_spec.rb
RSpec.describe OrderCreationService do
  let(:user) { create(:user, points: 0) }
  let(:product) { create(:product, price: 1000, stock: 5) }
  let(:items) { [{ product_id: product.id, quantity: 2 }] }
 
  subject(:service) do
    described_class.new(user: user, items: items, payment_token: "tok_visa")
  end
 
  before do
    allow_any_instance_of(StripeGateway).to receive(:charge).and_return(
      { success: true, transaction_id: "ch_123" }
    )
  end
 
  context "在庫が十分にある場合" do
    it "注文を作成して成功を返す" do
      result = service.call
      expect(result.success?).to be true
      expect(result.order).to be_persisted
    end
 
    it "在庫を減らす" do
      expect { service.call }.to change { product.reload.stock }.by(-2)
    end
 
    it "ポイントを付与する(100円=1ポイント)" do
      expect { service.call }.to change { user.reload.points }.by(20)
    end
  end
 
  context "在庫が不足している場合" do
    let(:items) { [{ product_id: product.id, quantity: 10 }] }
 
    it "失敗を返す" do
      result = service.call
      expect(result.success?).to be false
      expect(result.error).to include("在庫が不足")
    end
 
    it "注文が作成されない" do
      expect { service.call }.not_to change(Order, :count)
    end
  end
 
  context "決済に失敗した場合" do
    before do
      allow_any_instance_of(StripeGateway).to receive(:charge).and_return({ success: false })
    end
 
    it "失敗を返し注文がロールバックされる" do
      result = service.call
      expect(result.success?).to be false
      expect(Order.count).to eq(0)
    end
  end
end

INFO

テストの利点まとめ

  • HTTP リクエストなしに純粋なユニットテストが書ける
  • 各シナリオ(在庫不足、決済失敗)を独立してテストできる
  • モックの範囲が明確(外部 API の Stripe だけモックすれば十分)
  • テストが高速(コントローラのリクエストスペックより速い)

WARNING

サービスオブジェクトをテストするとき、内部のサブサービスを全部モックしすぎるとテストの意味が薄れる。外部 API(Stripe、SendGrid 等)だけモックし、内部の DB 操作は実際に動かすのがバランスがよい。「実際の処理の組み合わせが正しいか」を検証するのが Facade テストの本質だ。


Facade のネスト — 複数の Facade を組み合わせる

大きなFacadeは小さなFacadeに分割できる。ロシアのマトリョーシカ人形のような構造だ。

class OrderCreationService
  def call
    stock_service.validate!(@items)
    order = order_builder.build(@user, @items)
    payment_service.charge(order, @payment_token)
    notification_service.notify_completion(order, @user)
    Result.new(success?: true, order: order)
  rescue => e
    Result.new(success?: false, error: e.message)
  end
 
  private
 
  def stock_service       = StockValidationService.new
  def order_builder       = OrderBuilder.new
  def payment_service     = PaymentProcessingService.new
  def notification_service = OrderNotificationService.new
end
 
# 通知のFacade(メール + ポイント + ダッシュボード)
class OrderNotificationService
  def notify_completion(order, user)
    OrderMailer.confirmation(order).deliver_later
    user.increment!(:points, (order.total_price / 100).floor)
    Dashboard.refresh_sales_data
  end
end

WARNING

Facade のネストは「分割の理由がある場合」だけにしよう。分割の基準は変更の理由が独立しているかだ。通知ロジックが変わっても決済ロジックを変えたくないなら、分割する意味がある。ただし、ネストが深すぎると「どこに何が書いてあるかわからない」という別の問題が生まれる。


God Object(神クラス)への警告

「じゃあサービスに全部書けばいいんだ!」とケンタは思ったが、山田さんが釘を刺した。「Facade が Fat になる問題がある。最初は100行のサービスが、機能追加のたびに300行、500行に膨れ上がる。気づいたら Facade が神クラス(God Object)になっている。」

# NG: 何でも知っていて何でもできる GodService
class OrderService
  def create_order(...)      ... end  # 注文作成
  def update_order(...)      ... end  # 注文更新
  def cancel_order(...)      ... end  # キャンセル
  def refund_order(...)      ... end  # 返金処理
  def export_to_csv(...)     ... end  # CSV出力
  def generate_report(...)   ... end  # レポート生成
  # ...500行続く
end
 
# OK: ユースケース単位に分割
class OrderCreationService     # 注文作成フロー
class OrderCancellationService # キャンセルフロー
class OrderRefundService       # 返金フロー
class OrderReportService       # レポート生成

INFO

サービスオブジェクトはユースケース(ユーザーが行う操作)単位で作るのが目安だ。「注文を作る」「注文をキャンセルする」「返金する」はそれぞれ独立したユースケースなので、独立したサービスにする。プライベートメソッドを直接テストしたくなったら、そのメソッドは独立したクラスに昇格させるサインだ。

WARNING

Anemic Domain Model(貧血ドメインモデル)にも注意 「とにかくサービスに書く」と、モデルが単なるデータコンテナになってしまう。order.total_price の計算や order.paid? の判定は Order モデルが担うべきロジックだ。単一モデルのロジックはモデルに書く。サービスに入れるのは「複数のサブシステムを協調させる」ロジックだけだ。


Interactor gem の活用

コミュニティでは Facade パターンを実装するための gem もある。代表的なものが interactordry-transaction だ。

# Gemfile: gem "interactor"
class ValidateStock
  include Interactor
 
  def call
    context.items.each do |item|
      product = Product.find(item[:product_id])
      context.fail!(error: "#{product.name}の在庫が不足") unless product.stock >= item[:quantity]
    end
  end
end
 
class CreateOrder
  include Interactor
 
  def call
    order = Order.create!(user: context.user, status: :pending)
    context.order = order
  end
end
 
# Organizer で複数のInteractorをチェーン
class PlaceOrder
  include Interactor::Organizer
  organize ValidateStock, CreateOrder, ChargePayment, SendNotification
end
 
# 呼び出し方は手製サービスと同じ感覚
result = PlaceOrder.call(user: current_user, items: items, payment_token: token)
if result.success?
  render json: { order_id: result.order.id }
else
  render json: { error: result.error }, status: :unprocessable_entity
end

INFO

gem を使うかどうかはチームの好みプロジェクトの規模次第だ。小規模なプロジェクトは Struct ベースの Result パターンで十分。大規模なプロジェクトでは gem が提供する一貫した構造と、ロールバック(rollback メソッド)の仕組みが助かる場合もある。


AWS でのFacade的発想

Loading diagram...

API Gateway はFacadeパターンそのものだ。クライアントはAPI Gatewayの1つのエンドポイントを叩くだけ。後ろに複数のLambda関数、ECSコンテナ、DynamoDBが隠れていても関係ない。レート制限、認証(Cognito連携)、ログ記録もAPI Gatewayが一手に引き受ける。

AWS Step Functions はFacadeの「順序と条件分岐」を担う。複数のLambda関数を協調させて「在庫確認 → 注文作成 → 決済 → 失敗時に返金 → 通知」という業務フローを表現できる。呼び出し側は StartExecution を1回呼ぶだけ。エラー時のリトライと補償トランザクションは Step Functions が管理する。

ECS TaskDefinition も Facade に似た概念だ。「このコンテナを起動するときに必要な全設定(CPU、メモリ、環境変数、ボリューム、ネットワーク)」をひとまとめにした設計書で、ECS サービスは TaskDefinition を参照するだけでコンテナを正しく起動できる。

INFO

AWSでのFacadeまとめ

  • API Gateway: マイクロサービス群へのシングルエントリーポイント
  • Step Functions: 複数のLambdaを協調させる業務フローのFacade
  • ECS TaskDefinition: コンテナ起動に必要な全設定をまとめた設計書

インフラでもアプリケーションでも「複雑さを隠して単純な窓口を作る」という考え方は同じだ。


ケンタの成長 — 次のメンバーに説明できるか?

3ヶ月後。新しいインターンの田中さんがジョインしてきた。山田さんはケンタに言った。「田中さんにサービスオブジェクトの使い方を説明してみて。」

ケンタは少し緊張しながら、ホワイトボードに向かった。

「まず、コントローラに全部書いてはダメだという話から始めます。コントローラは『受け取って、渡して、返す』だけの仕事で十分なんです。ホテルのフロントみたいに。注文処理の場合、在庫確認・決済・メール・ポイント付与……これ全部コントローラに書いたら、200行になってしまいました。それをサービスオブジェクトという『フロントデスク係』に委任するのが Facade パターンです。」

田中さんがうなずいた。「呼び出し方は?」

OrderCreationService.new(user:, items:, payment_token:).call で呼んで、Result オブジェクトで成否を受け取ります。result.success? が true なら成功、false なら result.error にメッセージが入っています。テストも HTTP リクエストなしでサービス単体で書けるので、シナリオが増えても管理しやすいです。」

山田さんが横でニヤリとした。説明できるようになったということは、本当に理解したということだ。

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

Facade パターン = 複雑なサブシステムに単純な窓口を作る。Railsではサービスオブジェクトがその役割。コントローラは薄く保つ。サービスが大きくなりすぎたらユースケース単位で分割する。God Object にしない。


INFO

この章のまとめ

  • Facade パターンは複雑なサブシステムへのシンプルな窓口を提供する
  • Fat Controller の問題:テスト困難・再利用不可・変更が怖い
  • Railsではサービスオブジェクト(app/services/)が典型的なFacade実装
  • Result オブジェクト(または dry-monads)で成功/失敗を明示的に返す
  • テストはサービス単位で書けるのでシナリオ管理が楽になる
  • Facade のネストは「変更の理由が独立しているか」で判断
  • God Object 化に注意 — ユースケース単位でサービスを分割する
  • Anemic Domain Model にも注意 — 単一モデルのロジックはモデルに書く
  • AWS の API Gateway・Step Functions・ECS TaskDefinition も同じ「Facade」の発想
  • interactor gem(dry-transaction)で構造化した実装も選択肢の一つ