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
endWARNING
Fat Controller(太ったコントローラ)の問題点
- テストが書きにくい — HTTP リクエストなしに注文処理だけをテストできない
- 再利用できない — バッチ処理や管理画面から同じ処理を呼べない
- 変更が怖い — 決済ロジックを変えたいだけなのに在庫処理を壊すかもしれない
- 読みにくい — 200行の
createアクションを頭の中で全部追わないといけない
「コントローラは交通整理係だ」と山田さんは言った。「交通整理係が料理を作ったり、会計をしたりしてたらおかしいだろう?コントローラは『リクエストを受けて、適切な担当者(サービス)に渡して、レスポンスを返す』だけでいい。」
INFO
コントローラの正しい責務
- パラメータを受け取る / 2. 認証・認可を確認する / 3. サービス(Facade)を呼ぶ / 4. 結果をレスポンスとして返す
ビジネスロジックはコントローラが知る必要はない。
Facade パターンとは
Facade パターンは、複雑なサブシステムに対してシンプルなインターフェース(窓口)を提供するパターンだ。
日常の比喩はホテルのフロントデスクだ。「ルームサービスを頼みたい」「タクシーを呼んでほしい」——フロントデスク(Facade)がそれを全部引き受ける。お客さんはキッチンスタッフ、タクシー会社、観光局に個別に電話しなくていい。
もう一つの比喩は家電のリモコンだ。テレビをつけるとき、画面のバックライト回路、映像デコーダ、音声アンプを個別に操作したりしない。電源ボタン一つで全部が協調して動く。Facade パターンはこの「電源ボタン」を作るパターンだ。
サービスオブジェクトとしての 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
endBefore/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
end200行が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
endWARNING
例外(Exception)を結果の伝達に使うのは避けよう。在庫不足や決済失敗は「想定内のビジネスルール違反」なので、Result オブジェクトや dry-monads の Failure で表現する方が意図が明確になる。例外は「想定外のシステムエラー」のために予約しておく。
別のFacade例:UserRegistrationService
会員登録フローも同じ「複数のサブシステムを協調させる」パターンだ。
# 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
endINFO
「複数の処理を順番に実行し、途中の失敗でロールバックが必要なフロー」は 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
endINFO
テストの利点まとめ
- 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
endWARNING
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 もある。代表的なものが interactor と dry-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
endINFO
gem を使うかどうかはチームの好みとプロジェクトの規模次第だ。小規模なプロジェクトは Struct ベースの Result パターンで十分。大規模なプロジェクトでは gem が提供する一貫した構造と、ロールバック(rollback メソッド)の仕組みが助かる場合もある。
AWS でのFacade的発想
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)で構造化した実装も選択肢の一つ