0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

newArticle011

0
Posted at

注文確定、ファイル変換、外部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を追加すると、依存が増え続けます。変更理由が異なる操作は、CheckoutFacadeAccountFacade のように目的で分けます。method名も executeprocess ではなく、利用者の目的を表す名前にします。

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の固有契約を尊重しつつ、どの複雑さを誰から隠し、誰が責任を持つかを考える道具としてパターンを使ってください。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?