Web開発で使うデザインパターン - 選び方と4つの例

15分 で読める | 2025.12.02

公式ドキュメント

デザインパターンは、繰り返し現れる設計上の問題に名前を付けた解決案です。完成コードや必須ルールではありません。

パターン名から実装を選ぶのではなく、変化する部分、依存を切りたい境界、増えている条件分岐を確認してから候補を選びます。

解きたい問題からパターン候補を選び、小さく適用して複雑性コストを評価した後、採用または元へ戻す流れ

最初にパターンが必要か判断する

次の順で考えます。

  1. 今起きている変更や重複を、具体的な例で書く
  2. その変化を一つの関数やmoduleへ分けるだけで解決しないか試す
  3. 同じ種類の差し替えが二つ以上あり、今後も増えるか確認する
  4. パターンを最小範囲へ適用し、呼び出し側が簡単になったか比較する

一度しか使わない処理へinterfaceやclassを何層も足すと、追跡するファイルだけが増えます。パターンは「将来使うかもしれない」ではなく、現在見えている変更理由を分離するために使います。

Strategy:処理の選び方を差し替える

問題

同じ目的の計算方法が複数あり、ifswitchが各画面へ散らばっている場合です。送料計算を例にします。

type ShippingStrategy = (subtotal: number) => number;

const standardShipping: ShippingStrategy = (subtotal) =>
  subtotal >= 5000 ? 0 : 500;

const expressShipping: ShippingStrategy = () => 1200;

function totalWithShipping(
  subtotal: number,
  shipping: ShippingStrategy,
): number {
  return subtotal + shipping(subtotal);
}

totalWithShipping(6000, standardShipping);
totalWithShipping(6000, expressShipping);

TypeScriptでは、必ずclassを作る必要はありません。同じ型の関数を渡すだけでも、計算方法を差し替えるStrategyとして働きます。

向く条件

  • 入力と出力が共通する複数の処理がある
  • 実行時またはテスト時に処理を選びたい
  • 条件分岐が複数の呼び出し側へ重複している

採用しない条件

二つの短い分岐が一か所だけにあり、増える理由もないなら、読みやすいifの方が明確です。Strategy名を知っていることより、分岐を移すことで重複が本当に減るかを優先します。

Strategyを育てる時の注意

すべてのstrategyが同じ前提を満たすよう、共通の入力・出力・error条件を決めます。あるstrategyだけDBへ保存し、別のstrategyは純粋計算という状態では、呼び出し側が違いを知らないまま副作用を起こします。

送料の例なら、負の小計、通貨、配送不能地域をどこで検証するかを統一します。「標準配送は例外を投げるが、速達は-1を返す」のような契約差を残しません。

テストは各strategyの計算だけでなく、選択規則も対象です。

function chooseShipping(kind: \"standard\" | \"express\"): ShippingStrategy {
  return kind === \"express\" ? expressShipping : standardShipping;
}

選択規則が増えて複雑になったら、その責任が画面、domain、設定のどこに属するかを見直します。Strategyは条件分岐を消すのではなく、計算と選択を別々に置く方法です。

Adapter:外部APIの違いを境界で吸収する

問題

外部メールサービスAとBで、送信メソッドや戻り値が異なるとします。業務コードが各SDKへ直接依存すると、変更が全体へ広がります。

type Mail = {
  to: string;
  subject: string;
  text: string;
};

interface MailSender {
  send(mail: Mail): Promise<{ messageId: string }>;
}

class ProviderAAdapter implements MailSender {
  constructor(private readonly client: ProviderAClient) {}

  async send(mail: Mail): Promise<{ messageId: string }> {
    const result = await this.client.messages.create({
      recipient: mail.to,
      title: mail.subject,
      body: mail.text,
    });

    return { messageId: result.id };
  }
}

ProviderAClientは外部SDKを表す仮の型です。Adapterは外部の名前や例外を、アプリ内部で使う契約へ変換します。

向く条件

  • 外部SDKや旧システムのinterfaceを直接広げたくない
  • 複数providerを同じ業務コードから扱いたい
  • 外部固有のエラーを内部のエラーへ変換したい

採用しない条件

外部APIを一か所から呼ぶだけで、交換予定もテスト上の問題もないなら、小さなwrapper関数で十分です。また、複数providerの能力が大きく違うのに、最低共通機能へ無理にそろえると重要な機能を隠します。

Adapterの境界で変換するもの

Adapterは名前だけでなく、外部と内部の意味を変換します。

  • SDK固有のrequest/response型
  • timeout、rate limit、認証errorなどの分類
  • providerが返すIDと内部ID
  • 日時、通貨、文字コードなどの表現
  • retry可能かどうか

外部errorをすべてError("送信失敗")へ潰すと、呼び出し側はretry判断も利用者への案内もできません。一方、SDKのerror classをdomain全体へ漏らすと、交換時の変更が広がります。内部で必要な分類だけを保つerror型へ変換します。

type MailSendError =
  | { kind: "temporary"; retryAfterMs?: number }
  | { kind: "invalid-recipient" }
  | { kind: "unauthorized" };

Adapterの契約テストでは、成功だけでなくtimeout、rate limit、不正入力を確認します。実serviceを使う統合テストと、内部契約だけを確認するtest doubleを分けます。

Factory:作り方と選択を一か所へ集める

問題

環境や設定に応じて生成する実装が変わり、newと設定読み取りが各所へ散らばる場合です。

type Storage = {
  save(key: string, value: string): Promise<void>;
};

function createStorage(kind: "memory" | "database"): Storage {
  if (kind === "memory") {
    return new MemoryStorage();
  }

  return new DatabaseStorage();
}

呼び出し側は生成手順を知らず、Storageの操作だけを使います。設定検証や依存の組み立ても、この境界へ集められます。

向く条件

  • 作成時に複数の依存や設定を組み立てる
  • 実装の選択規則を一か所にしたい
  • 呼び出し側を具体的なconstructorから離したい

採用しない条件

常に一つの単純なclassを作るだけなら、直接newする方が追いやすいです。Factoryが巨大なswitchになったら、登録表や依存性注入の構成rootなど、選択責任の置き場所を見直します。

生成時に検証する

Factoryが環境変数や設定を読む場合、不正な値を最初の利用まで隠さず、起動時に検証します。

function createStorageFromEnv(env: NodeJS.ProcessEnv): Storage {
  if (env.STORAGE_KIND === "memory") {
    return new MemoryStorage();
  }

  if (env.STORAGE_KIND === "database" && env.DATABASE_URL) {
    return new DatabaseStorage(env.DATABASE_URL);
  }

  throw new Error("Invalid storage configuration");
}

秘密値をerror messageへ含めません。また、connection poolのように一つを共有するobjectと、requestごとに作るobjectを区別します。Factoryが毎回新しいDB connectionを作ると、抽象化は正しく見えてもresourceを枯渇させます。

非同期初期化が必要なら、Factoryの戻り値をPromise<Storage>にするか、起動処理で初期化済みobjectを作って注入します。呼び出しのたびに「初期化済みか」を確認する責任を散らしません。

Observer:一つの出来事を複数の反応へ伝える

問題

注文完了後に、メール、監査ログ、分析など複数の処理を動かしたい場合です。注文処理がすべての詳細を直接呼ぶと依存が増えます。

ブラウザではEventTargetを使った小さな形もあります。

const events = new EventTarget();

events.addEventListener("order:completed", (event) => {
  console.log("analytics", event.detail.orderId);
});

events.dispatchEvent(
  new CustomEvent("order:completed", {
    detail: { orderId: "order_123" },
  }),
);

同期のin-process eventと、queueを使う非同期メッセージは同じではありません。非同期では配信失敗、重複、順序、再試行を設計します。

向く条件

  • 一つの出来事に独立した反応が複数ある
  • 発行側が購読側の詳細を知らなくてよい
  • 購読処理を追加しても中心処理を変えたくない

採用しない条件

重要な業務処理の順序や成功条件が暗黙になるなら、明示的な関数呼び出しの方が安全です。イベントを追わないと処理全体が理解できない状態や、購読解除漏れにも注意します。

eventの契約を決める

event名だけでなく、いつ発行され、失敗時に何が起きるかを決めます。

type OrderCompleted = {
  eventId: string;
  occurredAt: string;
  orderId: string;
  schemaVersion: 1;
};

注文transactionのcommit前にeventを外部queueへ送ると、DB更新がrollbackしても通知だけ残る場合があります。commit後に送ると、commit成功後のprocess停止でeventを失う場合があります。永続queueへ連携するならtransactional outboxなどを検討します。

subscriberは同じeventを複数回受け取る可能性を考え、eventIdで処理済みを記録するなど冪等にします。event schemaを変更する時は、古いconsumerが残っている期間の互換性も必要です。

単一process内のUI eventでは、これほどの永続化は不要です。Observerという名前が同じでも、同期通知と分散messageの信頼性要件を混同しません。

4パターンの選び分け

困っていること候補分けるもの
同じ目的の手順を切り替えたいStrategyアルゴリズム
外部と内部の形が合わないAdapterinterfaceの変換
生成手順や実装選択が散らばるFactoryobjectの作成
一つの出来事へ複数が反応するObserver通知元と購読側

似て見えることもあります。Factoryで作ったAdapterをStrategyとして渡す構成も可能です。ただし、パターンを組み合わせること自体を目的にしません。

一つの機能で選ぶ例

複数の決済providerを使うcheckoutを考えます。

  1. provider SDKのrequestとerrorを内部契約へ変えるためAdapterを置く
  2. 国や通貨に応じたprovider選択が増えたらStrategyとして分ける
  3. 起動時に設定とSDK clientを組み立てるFactoryを一か所に置く
  4. 決済完了後の分析や通知は、成功条件から独立できる範囲だけObserverで伝える

最初から四つすべてを作る必要はありません。一providerだけならAdapter関数から始め、選択規則やsubscriberが実際に増えた段階で分けます。

この構成で重要なのは、どのpatternを使ったかより責任の境界です。決済成功の確定は中心処理が担い、分析eventの失敗を決済失敗と混同しません。timeout後に結果がunknownになった場合は、同じproviderへ同じidempotency keyで再送して同じ処理結果を取得するか、発行済みの決済IDをstatus APIなどで照会し、注文・金額・provider responseを照合して成否を確定します。未確定のまま別providerへ送ると二重決済になり得るため、成否が確定するまではfallbackしません。

GoFパターンの短い索引

GoFは23パターンを、生成・構造・振る舞いに分類しました。すべてを一つの記事で実装するより、問題に遭遇した時の検索語として使います。

  • 生成:Builder、Abstract Factory、Prototype、Singleton
  • 構造:Bridge、Composite、Decorator、Facade、Flyweight、Proxy
  • 振る舞い:Chain of Responsibility、Command、Iterator、Mediator、Memento、State、Template Method、Visitorなど

たとえば状態によって許可する操作が変わるならState、既存objectへ責務を重ねたいならDecoratorを調べます。名前が似ているだけで採用せず、解決したい問題と導入コストを比較します。

Repository、Unit of Work、依存性注入、CQRSなどはGoFの23パターンとは別の文脈です。データアクセスやアプリ構成の記事で個別に扱います。

小さくrefactorする順番

既存コードへpatternを導入する時は、動作変更と構造変更を一度に広げません。

  1. 現在の振る舞いをtestまたは具体例で固定する
  2. 変化する処理を一つの関数へ抽出する
  3. 同じ契約の二つ目がある時に型を定義する
  4. 呼び出し側を一か所ずつ置き換える
  5. 旧分岐や旧wrapperを削除する
  6. file数、依存方向、testの読みやすさを比較する

Pattern導入PRと機能追加PRを分けられると、reviewerは振る舞いが変わっていないことを確認しやすくなります。大規模な一括置換より、元へ戻せる小さな差分にします。

導入理由は「Strategyを使いたかった」ではなく、「配送計算の分岐が三画面へ重複していたため、同じ型の関数へ集約した」のように記録します。削除条件も添えると、実装が一つだけになった後に不要な抽象化を外せます。

Pattern名をPR titleへ書く場合も、利用者に見える変更と設計上の問題を本文で説明します。名前だけでは、同じpatternでも対象範囲や同期・非同期など重要な前提が伝わりません。

適用後のレビュー

  • 条件分岐や外部依存が一か所へ集まったか
  • 呼び出し側が短く、目的を読みやすくなったか
  • interfaceが実際の複数実装を表しているか
  • テスト用だけの不自然な抽象化になっていないか
  • 追加したclass・file・間接呼び出しに見合うか

問題が小さくなっていなければ、元の関数へ戻す判断も正解です。パターンは設計の語彙であり、採用数は品質指標ではありません。

まとめ

現代のWeb開発でも、Strategy、Adapter、Factory、Observerの考え方は使えます。class構造をコピーするのではなく、変わる理由と依存境界を見つけ、小さな関数やinterfaceから始めます。採用しない条件まで考えると、過剰設計を避けながら設計意図を共有できます。

参考リソース

次に読む記事

← 一覧に戻る
PR
PR
PR
PR