デザインパターンは、繰り返し現れる設計上の問題に名前を付けた解決案です。完成コードや必須ルールではありません。
パターン名から実装を選ぶのではなく、変化する部分、依存を切りたい境界、増えている条件分岐を確認してから候補を選びます。

最初にパターンが必要か判断する
次の順で考えます。
- 今起きている変更や重複を、具体的な例で書く
- その変化を一つの関数やmoduleへ分けるだけで解決しないか試す
- 同じ種類の差し替えが二つ以上あり、今後も増えるか確認する
- パターンを最小範囲へ適用し、呼び出し側が簡単になったか比較する
一度しか使わない処理へinterfaceやclassを何層も足すと、追跡するファイルだけが増えます。パターンは「将来使うかもしれない」ではなく、現在見えている変更理由を分離するために使います。
Strategy:処理の選び方を差し替える
問題
同じ目的の計算方法が複数あり、ifやswitchが各画面へ散らばっている場合です。送料計算を例にします。
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 | アルゴリズム |
| 外部と内部の形が合わない | Adapter | interfaceの変換 |
| 生成手順や実装選択が散らばる | Factory | objectの作成 |
| 一つの出来事へ複数が反応する | Observer | 通知元と購読側 |
似て見えることもあります。Factoryで作ったAdapterをStrategyとして渡す構成も可能です。ただし、パターンを組み合わせること自体を目的にしません。
一つの機能で選ぶ例
複数の決済providerを使うcheckoutを考えます。
- provider SDKのrequestとerrorを内部契約へ変えるためAdapterを置く
- 国や通貨に応じたprovider選択が増えたらStrategyとして分ける
- 起動時に設定とSDK clientを組み立てるFactoryを一か所に置く
- 決済完了後の分析や通知は、成功条件から独立できる範囲だけ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を導入する時は、動作変更と構造変更を一度に広げません。
- 現在の振る舞いをtestまたは具体例で固定する
- 変化する処理を一つの関数へ抽出する
- 同じ契約の二つ目がある時に型を定義する
- 呼び出し側を一か所ずつ置き換える
- 旧分岐や旧wrapperを削除する
- file数、依存方向、testの読みやすさを比較する
Pattern導入PRと機能追加PRを分けられると、reviewerは振る舞いが変わっていないことを確認しやすくなります。大規模な一括置換より、元へ戻せる小さな差分にします。
導入理由は「Strategyを使いたかった」ではなく、「配送計算の分岐が三画面へ重複していたため、同じ型の関数へ集約した」のように記録します。削除条件も添えると、実装が一つだけになった後に不要な抽象化を外せます。
Pattern名をPR titleへ書く場合も、利用者に見える変更と設計上の問題を本文で説明します。名前だけでは、同じpatternでも対象範囲や同期・非同期など重要な前提が伝わりません。
適用後のレビュー
- 条件分岐や外部依存が一か所へ集まったか
- 呼び出し側が短く、目的を読みやすくなったか
- interfaceが実際の複数実装を表しているか
- テスト用だけの不自然な抽象化になっていないか
- 追加したclass・file・間接呼び出しに見合うか
問題が小さくなっていなければ、元の関数へ戻す判断も正解です。パターンは設計の語彙であり、採用数は品質指標ではありません。
まとめ
現代のWeb開発でも、Strategy、Adapter、Factory、Observerの考え方は使えます。class構造をコピーするのではなく、変わる理由と依存境界を見つけ、小さな関数やinterfaceから始めます。採用しない条件まで考えると、過剰設計を避けながら設計意図を共有できます。
参考リソース
- Design Patterns: Elements of Reusable Object-Oriented Software(Addison-Wesley)
- EventTarget(MDN)
- デザインパターン一覧