注文確定、ファイル変換、外部SDKの初期化などは、複数のobjectと決められた手順から成ります。その手順を画面やAPI handlerが直接組み立てると、呼び出し順序、失敗時の後始末、重複実行の防止が各所へ散らばります。利用側は「購入を確定したい」だけなのに、在庫、決済、保存の内部事情を知る状態です。
Facade(ファサード)は、複雑なsubsystemへ用途に合った単純なinterfaceを提供する構造パターンです。本記事ではcheckoutを題材に、最小の入口から、失敗の型、補償処理、idempotency、テストまで発展させます。TanStack Queryの useQuery も公式資料に基づいてFacade的に読みますが、公式のパターン分類と教育的な類推は明確に分けます。
subsystemの複雑さが利用側へ漏れた例
API handlerがrepositoryと外部serviceを直接呼ぶ例です。
async function handleCheckoutBefore(cartId: string): Promise<Response> {
const cart = await cartRepository.findById(cartId);
const reservation = await inventoryService.reserve(cart.items);
const payment = await paymentService.charge(cart.totalYen);
const order = await orderRepository.save({ cart, reservation, payment });
await mailService.sendReceipt(order.id);
return Response.json({ orderId: order.id });
}
正常系だけなら読めますが、決済に失敗したら在庫予約を解放する、注文保存に失敗したら返金する、同じrequestが再送されたら二重課金しない、といった要件が抜けています。別のCLIや管理画面も同じ手順を必要とすればcopyされます。
また、HTTP handlerがpayment SDKのerror codeや在庫reservationの構造を知ると、infraの変更がpresentation層へ波及します。単に長いコードをhelper関数へ移すだけでなく、利用側が必要とする契約を設計する必要があります。
// 名前を変えただけでは、広いsubsystemがそのまま漏れている
async function checkoutHelper(
cartRepo: CartRepository,
inventory: InventorySdk,
payment: PaymentSdk,
orders: OrderRepository,
cartId: string,
) {
// 全手順を利用側が理解しないと呼べない
}
Facadeを正確に定義する
GoFのFacadeは、subsystem内の一群のinterfaceへ統一された上位interfaceを提供し、subsystemを使いやすくします。Facadeは複雑さを消すのではなく、扱う責任を境界へ集めます。Clientは用途に合った小さな操作を呼び、Facadeが必要なsubsystem objectを協調させます。
Facadeを置いても、低level APIを必ず非公開にする必要はありません。一般的な経路はFacadeへ寄せつつ、高度な用途には個別componentを直接使える設計も可能です。一方、重要な不変条件を守るために入口を強制する場合もあります。公開範囲は「便利さ」と「安全性」のどちらを保証するかで決めます。
Adapterは互換性のないinterfaceを別のinterfaceへ変換するのが中心です。Facadeは複数の操作やobjectを目的別の入口へまとめます。Application Serviceはuse caseを調整する層で、業務規則を含むことがあります。checkout FacadeがApplication Serviceでもあることは珍しくありません。名前より、責務と依存方向を明示することが重要です。
subsystemの契約を小さく定義する
例を実行可能な型として組み立てます。Facadeが具象SDKへ直接依存せず、必要な操作だけをinterfaceで受け取るとtestしやすくなります。
type Cart = Readonly<{
id: string;
items: readonly { sku: string; quantity: number }[];
totalYen: number;
}>;
type Reservation = Readonly<{ id: string }>;
type Payment = Readonly<{ id: string }>;
type Order = Readonly<{ id: string }>;
interface CartRepository {
findById(id: string): Promise<Cart | undefined>;
}
interface InventoryService {
reserve(items: Cart["items"]): Promise<Reservation>;
release(reservationId: string): Promise<void>;
}
interface PaymentService {
charge(input: { amountYen: number; idempotencyKey: string }): Promise<Payment>;
refund(paymentId: string): Promise<void>;
}
interface OrderRepository {
findByRequestId(requestId: string): Promise<Order | undefined>;
save(input: {
requestId: string;
cart: Cart;
reservation: Reservation;
payment: Payment;
}): Promise<Order>;
}
このinterfaceはsubsystem全機能の写しではありません。checkoutが必要とする操作へ絞っています。たとえばpayment SDKに顧客一覧やsubscription APIがあっても公開しません。Facadeの入力も内部objectではなく、利用者が持つ cartId とrequest識別子にします。
最小のCheckoutFacadeを作る
まず正常系と、見つからないcartを扱います。外部から来るrequest IDは二重実行を識別するために使います。
class CartNotFoundError extends Error {}
class CheckoutFacade {
constructor(
private readonly carts: CartRepository,
private readonly inventory: InventoryService,
private readonly payments: PaymentService,
private readonly orders: OrderRepository,
) {}
async checkout(cartId: string, requestId: string): Promise<Order> {
const existing = await this.orders.findByRequestId(requestId);
if (existing) return existing;
const cart = await this.carts.findById(cartId);
if (!cart) throw new CartNotFoundError(`cart not found: ${cartId}`);
const reservation = await this.inventory.reserve(cart.items);
const payment = await this.payments.charge({
amountYen: cart.totalYen,
idempotencyKey: requestId,
});
return this.orders.save({ requestId, cart, reservation, payment });
}
}
利用側の入口は小さくなります。HTTP固有のstatus変換はhandlerに残し、use caseの順序はFacadeへ置きます。
async function handleCheckout(request: Request): Promise<Response> {
const { cartId } = (await request.json()) as { cartId: string };
const requestId = request.headers.get("idempotency-key");
if (!requestId) return Response.json({ error: "missing key" }, { status: 400 });
try {
const order = await checkoutFacade.checkout(cartId, requestId);
return Response.json({ orderId: order.id }, { status: 201 });
} catch (error) {
if (error instanceof CartNotFoundError) {
return Response.json({ error: "cart not found" }, { status: 404 });
}
throw error;
}
}
実務ではrequest bodyもruntime schemaで検証します。Facadeへ Request objectを丸ごと渡すとHTTPへ結合するため、検証済みの値だけを渡します。逆にFacadeがHTTP statusを返すとCLIやjobで再利用しにくくなります。
失敗時の補償処理を設計する
複数serviceをまたぐ処理は、単一DB transactionのように一括rollbackできません。在庫予約後に決済が失敗したら予約を解放し、決済後に注文保存が失敗したら返金と解放を試みます。これはSagaの補償処理にも近い問題です。
class CheckoutFailedError extends Error {
constructor(
message: string,
options: { cause: unknown; compensationErrors: readonly unknown[] },
) {
super(message, { cause: options.cause });
this.compensationErrors = options.compensationErrors;
}
readonly compensationErrors: readonly unknown[];
}
async function compensate(
actions: readonly (() => Promise<void>)[],
): Promise<unknown[]> {
const errors: unknown[] = [];
for (const action of [...actions].reverse()) {
try {
await action();
} catch (error) {
errors.push(error);
}
}
return errors;
}
成功した手順ごとに補償actionを積み、失敗時は逆順に実行します。補償自体の失敗も元errorで上書きせず、運用から追跡できる形で保持します。
async function checkoutSafely(
deps: {
inventory: InventoryService;
payments: PaymentService;
orders: OrderRepository;
},
cart: Cart,
requestId: string,
): Promise<Order> {
const compensations: Array<() => Promise<void>> = [];
try {
const reservation = await deps.inventory.reserve(cart.items);
compensations.push(() => deps.inventory.release(reservation.id));
const payment = await deps.payments.charge({
amountYen: cart.totalYen,
idempotencyKey: requestId,
});
compensations.push(() => deps.payments.refund(payment.id));
return await deps.orders.save({ requestId, cart, reservation, payment });
} catch (cause) {
const compensationErrors = await compensate(compensations);
throw new CheckoutFailedError("checkout failed", {
cause,
compensationErrors,
});
}
}
この例は考え方を示す最小実装です。本番ではprocessが補償前に停止する可能性があるため、実行状態を永続化し、再開可能にする必要があります。Facadeへ try/catch を書いただけで分散transactionが完成するわけではありません。決済のidempotency keyや注文の一意制約もsubsystem側で保証します。
戻り値を利用者の判断に合わせる
Facadeが内部SDKのresponseをそのまま返すと、結局Clientが内部構造へ結合します。利用側が必要とする結果だけを返します。業務上予想される失敗を値、予期しない障害を例外に分ける設計もできます。
type CheckoutResult =
| { ok: true; orderId: string; duplicated: boolean }
| { ok: false; reason: "cart_not_found" | "out_of_stock" };
async function checkoutForClient(
cartId: string,
requestId: string,
): Promise<CheckoutResult> {
const existing = await orderRepository.findByRequestId(requestId);
if (existing) return { ok: true, orderId: existing.id, duplicated: true };
try {
const order = await checkoutFacade.checkout(cartId, requestId);
return { ok: true, orderId: order.id, duplicated: false };
} catch (error) {
if (error instanceof CartNotFoundError) {
return { ok: false, reason: "cart_not_found" };
}
throw error;
}
}
すべてのerrorを { ok: false } へ変換するとstackや原因を失うことがあります。利用者が分岐すべき期待内の結果だけ型へ含め、通信断、設定不備、programming errorなどは cause を保った例外として上位の共通処理へ渡します。
idempotencyは入口だけでは保証できない
先に findByRequestId しても、同じkeyのrequestが同時に到着すると、両方が「未処理」と判断できます。check-then-actだけでは競合を防げません。DBのunique constraint、atomic insert、payment providerのidempotency機能など、共有resource側の保証が必要です。
CREATE UNIQUE INDEX orders_request_id_unique
ON orders (request_id);
Facadeはrequest IDの受け渡し、重複時の既存結果返却、各subsystemのkey統一を調整します。しかし実際の排他制御までmemory上のMapだけで済ませると、processが複数ある環境で壊れます。Facadeの責務と、subsystemが提供すべきatomicityを分けます。
テストで手順と補償を確認する
Facadeのtestは、すべての具象SDKを起動するのではなく、小さなinterfaceへfakeを渡して観測します。正常系では呼び出し順序と、同じrequest IDが決済と保存へ渡ることを確認します。
import assert from "node:assert/strict";
import test from "node:test";
test("checkout coordinates the subsystem in order", async () => {
const calls: string[] = [];
const facade = new CheckoutFacade(
{
async findById() {
calls.push("cart");
return { id: "c1", items: [{ sku: "A", quantity: 1 }], totalYen: 1000 };
},
},
{
async reserve() { calls.push("reserve"); return { id: "r1" }; },
async release() { calls.push("release"); },
},
{
async charge() { calls.push("charge"); return { id: "p1" }; },
async refund() { calls.push("refund"); },
},
{
async findByRequestId() { return undefined; },
async save() { calls.push("save"); return { id: "o1" }; },
},
);
const order = await facade.checkout("c1", "req-1");
assert.equal(order.id, "o1");
assert.deepEqual(calls, ["cart", "reserve", "charge", "save"]);
});
補償testでは、注文保存を失敗させ、返金と在庫解放が逆順で実行されることを確認します。実時間やnetworkに依存しないため、失敗経路を安定して再現できます。
test("failed order persistence runs compensations in reverse", async () => {
const calls: string[] = [];
const deps = {
inventory: {
async reserve() { calls.push("reserve"); return { id: "r1" }; },
async release() { calls.push("release"); },
},
payments: {
async charge() { calls.push("charge"); return { id: "p1" }; },
async refund() { calls.push("refund"); },
},
orders: {
async findByRequestId() { return undefined; },
async save(): Promise<Order> { calls.push("save"); throw new Error("db down"); },
},
};
await assert.rejects(
checkoutSafely(
deps,
{ id: "c1", items: [], totalYen: 1000 },
"req-1",
),
CheckoutFailedError,
);
assert.deepEqual(calls, ["reserve", "charge", "save", "refund", "release"]);
});
mockが実装詳細へ密着しすぎると、内部整理だけでtestが壊れます。ただしFacadeの責務が手順調整なら、重要な順序と補償は公開契約の一部です。どのinteractionが業務保証で、どれが偶然かを区別します。
Facadeを巨大な万能serviceにしない
入口が便利だからと AppFacade へ全use caseを追加すると、依存が増え続けます。変更理由が異なる操作は、CheckoutFacade、AccountFacade のように目的で分けます。method名も execute や process ではなく、利用者の目的を表す名前にします。
Facade同士が循環参照すると、境界が崩れています。共通の低level componentを両方へ注入する、または上位のworkflowが複数Facadeを調整するなど、依存方向を見直します。Facadeが別Facadeの全APIを再公開するだけの層も、追跡距離を増やすだけです。
性能面では、Facadeが裏で行うI/O数が見えにくくなります。1回のmethodに見えてN+1 requestを発生させる場合、戻り値だけでなくcost、cache、timeout、キャンセルの契約も文書化します。単純な入口は、隠れた高コストを正当化しません。
TanStack QueryのuseQueryをFacade的に読む
TanStack Query公式では、queryを一意なquery keyとPromiseを返すquery functionに結び付いた宣言的なdata dependencyと説明しています。React componentは useQuery を通じてdata、status、error、refetchなどを受け取ります。
import { useQuery } from "@tanstack/react-query";
type UserView = Readonly<{ displayName: string }>;
async function fetchUser(userId: string): Promise<UserView> {
const response = await fetch(`/api/users/${encodeURIComponent(userId)}`);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return (await response.json()) as UserView;
}
function UserProfile({ userId }: { userId: string }) {
const query = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
staleTime: 60_000,
});
if (query.isPending) return <p>読み込み中</p>;
if (query.isError) return <p>{query.error.message}</p>;
return <p>{query.data.displayName}</p>;
}
背後ではQueryClient、QueryCache、QueryObserver、retry、stale判定、garbage collection、refetchなどが協調します。componentがそれらを個別に組み立てず、query keyとquery functionを中心とした目的別APIから利用する点はFacade的です。戻り値もdataだけでなく、UIが判断するための状態をまとめた入口になっています。
ただし、TanStack Query公式は useQuery をGoF Facadeの実装として分類していません。Hook、cache購読、React lifecycleという固有の責務を持ちます。本記事での対応付けは、複雑なsubsystemへ使いやすい上位interfaceを提供する構造を学ぶための教育的な類推です。
公式の重要な既定値では、query dataは既定でstaleと見なされ、mount、window focus、network reconnectなどでbackground refetchされ得ると説明されています。Facadeが簡単だからこそ、隠された既定動作を理解せず使うと「勝手に再取得された」と感じます。自作Facadeでも、内部方針を隠すだけでなく、利用者が判断すべきoptionと観測可能な状態を提供します。
Redux ToolkitのconfigureStoreにも入口を見る
Redux Toolkitの configureStore は、reducer、middleware、DevTools、preloaded stateなどstore構築に関わる複数の低level要素へ、推奨defaultを含む1つの入口を提供します。
import { configureStore } from "@reduxjs/toolkit";
const store = configureStore({
reducer: {
users: usersReducer,
cart: cartReducer,
},
});
これもsubsystem setupを簡潔にするFacade的なAPIとして読めますが、Redux Toolkit公式によるGoF分類ではありません。設定からstoreを生成するFactory的側面もあります。実在APIは複数のパターンの観点を持ち得るため、1つのラベルへ無理に固定せず、説明したい責務を限定します。
より単純な代替案とトレードオフ
低level操作が1つだけで、変換も方針もないなら薄いwrapperは不要です。
const getUser = (id: string) => userApi.getUser(id);
この関数に命名や依存方向を整える価値がなければ、移動先を増やすだけです。2、3手順が1か所だけにあり、失敗処理も単純なら通常のuse case関数で十分です。class Facadeにする必要もなく、依存を引数に取る関数として実装できます。
Facadeは利用側を簡潔にする一方、内部で何が起きるかを見えにくくします。optionを増やしすぎると、再びsubsystemの複雑さを表へ出します。逆に固定しすぎると例外的な用途で回避不能になります。一般的な安全経路を短くし、高度な経路は明示的に提供するバランスが必要です。
失敗しやすい設計
- Facadeが全featureを抱えるGod Objectになり、依存と変更理由が増え続ける。
- 内部SDKの型やerrorをそのまま返し、Clientがsubsystemへ再結合する。
- 例外をすべて握りつぶして
falseを返し、原因と補償失敗を失う。 - memory上の事前checkだけでidempotencyを保証したと思い込む。
- 補償処理を逆順に行わない、または補償の失敗を記録しない。
-
Request、React componentなど特定presentationの型をuse caseへ渡す。 - 便利な1methodの裏で大量I/Oを行い、timeoutやキャンセルを提供しない。
- 低level APIを不必要に禁止し、正当な高度利用まで困難にする。
通知メールの失敗をcheckout全体の失敗にするかも判断事項です。注文確定後のmailを同期手順へ含めると、mail障害でClientは失敗を受け取り、再送による重複を招きます。outboxへ記録して非同期配送するなど、業務上のtransaction境界をFacadeの手順へ反映します。
使うかどうかの判断基準
Facadeが向くのは、同じ複数手順を複数Clientが組み立てている、順序や補償を一元化したい、広い外部SDKを用途別の小さなinterfaceへ絞りたい、presentation層からinfra詳細を隠したい場合です。入口の名前だけで利用目的と結果が理解できることが目安です。
導入前には「隠したいsubsystemは何か」「Clientが本当に必要な入力と結果は何か」「期待内の失敗をどう表すか」「atomicityとidempotencyをどの層が保証するか」「例外的に低level APIへアクセスする道が必要か」を確認します。単なる別名関数しか作れないなら、まだFacadeの責務はありません。
まとめ
Facadeは、複雑なsubsystemの存在をなかったことにするのではなく、扱う場所を定め、利用目的に合った安全な入口を提供します。入力、戻り値、error、補償、idempotencyを含めて契約を設計し、内部SDKの詳細を漏らさないことが重要です。
TanStack Queryの useQuery やRedux Toolkitの configureStore にはFacade的な性質を観察できますが、公式がGoF Facadeとして分類しているわけではありません。実在APIの固有契約を尊重しつつ、どの複雑さを誰から隠し、誰が責任を持つかを考える道具としてパターンを使ってください。
参考資料
- Erich Gammaほか『Design Patterns: Elements of Reusable Object-Oriented Software』Facade章(Addison-Wesley, 1994)
- TanStack Query公式:Queries
- TanStack Query公式:useQuery
- TanStack Query公式:Important Defaults
- TanStack Query公式ソース:useQuery.ts
- Redux Toolkit公式:configureStore
- Redux Toolkit公式ソース:configureStore.ts