7つの必須ソフトウェアデザインパターン

ソフトウェアデザインパターンとは、ソフトウェア開発における「よくある問題に対する再利用可能な解決策」のことで、過去の経験豊富な開発者が発見した設計ノウハウを体系化し、カタログ化したものです。これらを活用することで、効率的で柔軟なコードを書き、チーム間でのコミュニケーションを円滑にし、ソフトウェアの品質と保守性を向上させることができます

1994年、いわゆる Gang of Four(Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides)が名著「Design Patterns」で23の主要パターンを体系化しました。これらは今もなお有効で、次の3つのカテゴリに分類されます。

  • 生成に関するパターン: オブジェクトの生成方法を扱う
  • 構造に関するパターン: オブジェクトの構成や関係性を扱う
  • 振る舞いに関するパターン: オブジェクト間のやり取りや責務分担を扱う

この記事では、すぐに役立つ7つのパターンを取り上げます。各パターンの使いどころ、利点、トレードオフ、コード例を載せています。

生成に関するパターン

シングルトン(Singleton)

アプリ全体でインスタンスを1つに制限し、どこからでも同じインスタンスへアクセスできるようにするデザインパターン。高コストな共有リソースやレジストリを一元管理して重複生成を防ぐ目的で用いられます。

使いどころ

  • 重い共有リソースの集中管理
    • ログ出力、メトリクス送信、トレーシング、イベントバス
    • 接続プール、スレッドプール、キャッシュ、ID生成器
  • アプリ全体で単一であるべきレジストリ
    • プラグイン/ハンドラ登録、フォーマット/ロケール設定レジストリ
  • プラットフォームの共有コンテキスト
    • モバイルのApplicationコンテキスト、設定ローダ(主に読み取り専用)
  • DIコンテナ自身(多くはフレームワークが管理)

利点

  • 一意性保証:重複生成や競合状態を防ぎ、単一の真実の源泉を提供
  • パフォーマンス:高コストな初期化を一度で済ませ、共有して再利用
  • 使い勝手:どこからでも簡単にアクセスできるため実装が素早い
  • ライフサイクル集約:初期化と破棄の場所を明確にできる

トレードオフ

  • グローバル状態化による結合度上昇
    • 依存が暗黙化してテストが難しくなる(モック差し替え/リセットが面倒)
    • 順序依存や初期化タイミングのバグを誘発
  • スレッドセーフティの複雑さ
    • 遅延初期化やダブルチェックロッキングの実装ミスで競合・可視性問題
  • ライフサイクルとクリーンアップ
    • 終了処理が抜けるとリーク、長寿命化でキャッシュ肥大や状態の腐敗が起きやすい
  • スケーリング/分散環境での誤解
    • 「プロセス内で1つ」でしかなく、複数プロセスやコンテナ間では複数存在
    • 状態共有が必要なら外部ストア(DBやRedis等)に寄せるべき
  • 設計の可読性/保守性低下
    • どこからでも呼べるため「神オブジェクト化」しやすく、責務が肥大
    • Service Locator的な使い方になるとアンチパターン化

コード例

javascript
// シンプルなロガーのシングルトン例(IIFE + クロージャ)
const Logger = (() => {
  let instance;

  function create() {
    const logs = [];
    return {
      log(message) {
        const entry = `${new Date().toISOString()} ${message}`;
        logs.push(entry);
        console.log(entry);
      },
      count() {
        return logs.length;
      },
    };
  }

  return {
    getInstance() {
      if (!instance) {
        instance = create();
      }
      return instance;
    },
  };
})();

// 使い方:常に同じインスタンスが返る
const a = Logger.getInstance();
const b = Logger.getInstance();

a.log('アプリ起動');
b.log('ユーザーがログイン');

console.log(a === b); // true(同一インスタンス)
console.log(a.count()); // 2

ビルダー(Builder)

複雑なオブジェクトの生成手順を専用のビルダーに切り出し、段階的に設定して最後に完成品を生成するパターン。多数の任意パラメータや順序依存の初期化を、読みやすい流暢なAPIで安全に表現するために用いられます。

使いどころ

  • 引数やオプションが多いコンストラクタ・設定オブジェクトの生成
  • 必須/任意が混在し、バリデーションや派生値計算が必要なとき
  • 不変(immutable)な完成品を作りたいが組み立ては段階的にしたいとき
  • ツリー構造・クエリ・HTTPリクエスト等の複雑な構造の組み立て
  • テストデータビルダーで読みやすく柔軟にデータを作りたいとき

利点

  • テレスコーピングコンストラクタを回避し、可読性を高められる
  • build() で一括バリデーションでき、整合性を確保しやすい
  • デフォルト値や段階的な構築を明示できる
  • 完成品を不変にして副作用を抑えられる
  • 流暢な(チェーン可能な)APIで使い間違いを減らせる

トレードオフ

  • 追加のクラス/関数が増え、ボイラープレートが多くなりがち
  • 単純なオブジェクトには過剰設計になりうる
  • build() 呼び忘れや未設定項目の見落としによる実行時エラー
  • 状態を持つビルダーを使い回すと副作用の原因(再利用時は注意)
  • わずかなパフォーマンスオーバーヘッド
  • JS/TSではオブジェクトリテラルやデフォルト引数で十分な場面もある

コード例

javascript
// ユーザーオブジェクトを段階的に組み立てるビルダーパターンの例
class User {
  constructor({ name, email, roles, active }) {
    this.name = name;
    this.email = email;
    this.roles = roles;
    this.active = active;
    Object.freeze(this); // 完成品は不変
  }
}

class UserBuilder {
  constructor() {
    this._name = null;
    this._email = null;
    this._roles = [];
    this._active = true;
  }

  name(v) {
    this._name = v;
    return this;
  }

  email(v) {
    this._email = v;
    return this;
  }

  addRole(role) {
    this._roles.push(role);
    return this;
  }

  active(v) {
    this._active = Boolean(v);
    return this;
  }

  build() {
    if (!this._name) throw new Error("name is required");
    // email は任意(必要ならチェックを追加)
    const roles = Array.from(new Set(this._roles));
    return new User({
      name: this._name,
      email: this._email ?? null,
      roles,
      active: this._active,
    });
  }
}

// 使い方:メソッドチェーンで段階的に設定し、最後に build()。
const user = new UserBuilder()
  .name("山田太郎")
  .email("taro@example.com")
  .addRole("admin")
  .addRole("viewer")
  .build();

console.log(user); // 完成した不変の User
console.log(Object.isFrozen(user)); // true

ファクトリ(Factory)

オブジェクトの生成を直接newせず、専用の工場(ファクトリ)に委譲して「どの具象型を、どう初期化するか」を隠蔽するパターン。Factory MethodやAbstract Factoryなどの派生があり、実装交換や構成の切替を容易にします。

使いどころ

  • 実行環境・設定・入力に応じて生成する具象クラスを切り替えたいとき
  • 生成手順に前処理・検証・デフォルト補完・依存注入が必要なとき
  • UI/DB/ネットワークなど製品ファミリーを一貫性を保って作りたい(Abstract Factory)
  • プラグインや戦略(Strategy)の差し替えポイントを用意したいとき
  • キャッシュ/プール/計測など、生成の横断的関心事を集約したいとき
  • テストでモック/スタブを注入できる生成窓口が欲しいとき

利点

  • 呼び出し側を具象型から疎結合にでき、依存の逆転が進む
  • 生成ロジックを一箇所に集約でき、切替や拡張が容易
  • 一括バリデーションやデフォルト設定で不正な生成を防げる
  • テスト容易性(ファクトリ差し替え・スタブ化)が高い
  • 製品ファミリー間の整合性(互換性)を保ちやすい
  • 生成時の計測・ロギング・キャッシュなどを組み込みやすい

トレードオフ

  • 単純な場面ではnewよりもボイラープレートと間接化が増える
  • 巨大なif/switch/マップで「神ファクトリ」化しやすい
  • 過度の抽象化で具体型固有の機能にアクセスしにくくなる
  • 実装追加時の登録漏れやキーのタイプミス(JSでは実行時エラー)に注意
  • パターン乱用でコントロールフローが追いにくく、デバッグ性が低下

コード例

javascript
// 通知手段を選んで生成するシンプルファクトリの例
class EmailNotifier {
  constructor({ from = "noreply@example.com" } = {}) {
    this.from = from;
  }
  send(to, message) {
    console.log(`[EMAIL] from=${this.from} to=${to} msg=${message}`);
  }
}

class SmsNotifier {
  constructor({ senderId = "APP" } = {}) {
    this.senderId = senderId;
  }
  send(to, message) {
    console.log(`[SMS] from=${this.senderId} to=${to} msg=${message}`);
  }
}

class PushNotifier {
  constructor({ app = "MyApp" } = {}) {
    this.app = app;
  }
  send(to, message) {
    console.log(`[PUSH] app=${this.app} to=${to} msg=${message}`);
  }
}

// 種別 → コンストラクタの対応表
const NotifierRegistry = {
  email: EmailNotifier,
  sms: SmsNotifier,
  push: PushNotifier,
};

// ファクトリ関数:種別とオプションから適切なインスタンスを返す
function createNotifier(kind, options = {}) {
  const Ctor = NotifierRegistry[kind];
  if (!Ctor) throw new Error(`Unknown notifier: ${kind}`);
  return new Ctor(options);
}

// 使い方
const sms = createNotifier("sms", { senderId: "MYAPP" });
sms.send("+818012345678", "コードは 123456 です");

const mail = createNotifier("email", { from: "support@example.com" });
mail.send("taro@example.com", "ようこそ!");

構造に関するパターン

ファサード(Facade)

複数の複雑なサブシステムに対して、統一された簡潔な窓口(高水準API)を提供するパターン。利用側はシンプルな操作だけで一連の処理を実行でき、内部の依存関係や手順の複雑さを隠蔽できます。

使いどころ

  • 複数のライブラリ/モジュールを組み合わせる標準フローの提供
  • レイヤ境界(アプリケーションサービス/ユースケース)の単純化
  • 外部サービスやレガシーAPIを包むラッパーでの複雑さ隠蔽
  • 共通の前処理・エラーハンドリング・リトライ・トランザクション風手続きの集約
  • SDKや内部構成を差し替えても影響を局所化したいとき
  • テスト時にサブシステムをまとめてモック化したいとき

利点

  • 学習コストと認知負荷の低減、使い間違いの抑制
  • 呼び出し側の結合度を下げ、実装変更の影響を局所化
  • エラーハンドリングやロギング/メトリクスを一元化
  • 反復する手順を再利用可能な高水準APIとして提供
  • 仕様として安定した公開インターフェースを維持しやすい

トレードオフ

  • 過度に抽象化すると機能が隠れ、拡張性や表現力が落ちる
  • Facadeが肥大化して「神オブジェクト」になりやすい
  • 抽象の漏れ(Leaky Abstraction)で内部知識が結局必要になることも
  • 追加レイヤによるわずかなオーバーヘッドや遅延
  • 内部構成の変更に伴いFacadeの契約も更新が必要になる場合がある

コード例

javascript
// 注文処理を単純なAPIで提供するファサードの例

// サブシステム(本来は各ライブラリやモジュール)
class Inventory {
  async reserve(sku, qty) {
    console.log(`[INV] reserve ${sku} x ${qty}`);
    return `resv-${Date.now()}`;
  }
  async release(reservationId) {
    console.log(`[INV] release ${reservationId}`);
  }
}

class PaymentGateway {
  async charge(amount, card) {
    console.log(`[PAY] charge ${amount} JPY`);
    return `tx-${Date.now()}`;
  }
  async refund(txId) {
    console.log(`[PAY] refund ${txId}`);
  }
}

class Shipping {
  async create(address, items) {
    console.log(`[SHP] create shipment to ${address.city}`);
    return `shp-${Date.now()}`;
  }
  async cancel(shipmentId) {
    console.log(`[SHP] cancel ${shipmentId}`);
  }
}

class Notifier {
  async email(to, subject, body) {
    console.log(`[MAIL] to=${to} subject="${subject}"`);
  }
}

// ファサード:複数サブシステムを統合した高水準API
class OrderFacade {
  constructor({ inventory, payment, shipping, notifier, priceList }) {
    this.inv = inventory;
    this.pay = payment;
    this.ship = shipping;
    this.note = notifier;
    this.price = priceList;
  }

  async placeOrder({ sku, qty, card, address, email }) {
    const amount = (this.price[sku] ?? 0) * qty;

    let reservationId = null;
    let txId = null;
    let shipmentId = null;

    try {
      reservationId = await this.inv.reserve(sku, qty);
      txId = await this.pay.charge(amount, card);
      shipmentId = await this.ship.create(address, [{ sku, qty }]);
      await this.note.email(
        email,
        "注文完了",
        `ご注文 ${sku} x ${qty} を受け付けました。`
      );
      return { reservationId, txId, shipmentId, amount };
    } catch (err) {
      // 簡易的な補償(ロールバック)
      if (shipmentId) await this.ship.cancel(shipmentId).catch(() => {});
      if (txId) await this.pay.refund(txId).catch(() => {});
      if (reservationId) await this.inv.release(reservationId).catch(() => {});
      throw err;
    }
  }
}

// 使い方:呼び出し側は単純なAPIを使うだけでよい
(async () => {
  const facade = new OrderFacade({
    inventory: new Inventory(),
    payment: new PaymentGateway(),
    shipping: new Shipping(),
    notifier: new Notifier(),
    priceList: { "SKU-001": 1200 },
  });

  const result = await facade.placeOrder({
    sku: "SKU-001",
    qty: 2,
    card: { number: "4242-4242-4242-4242" },
    address: { city: "Tokyo" },
    email: "taro@example.com",
  });

  console.log("結果:", result);
})();

アダプター(Adapter)

互換性のない既存クラスや外部APIのインターフェースをクライアントが期待する形に変換して接続するパターン。メソッド名や引数形状、同期/非同期などの差異を吸収し、既存資産の再利用や段階的移行を容易にします。

使いどころ

  • レガシーSDK/外部サービスを、アプリ側の共通インターフェース(Port)に合わせたい
  • 複数ベンダーを切り替え可能にし、呼び出し側をベンダー非依存に保ちたい
  • コールバックAPIをPromise/asyncに、イベントをメソッド呼び出しに変換したい
  • データ形状・単位・命名(snake/camel)などの差を吸収したい
  • 段階的なリプレース(旧APIと新APIの併存)を安全に進めたい
  • テストでインメモリアダプタに差し替えたい

利点

  • 既存コードの再利用と疎結合化(呼び出し側の変更を最小化)
  • ベンダー/実装の切替や移行が容易
  • エラーマッピングやバリデーションを境界で一元化できる
  • テスト容易性の向上(アダプタのモック/スタブ化が簡単)

トレードオフ

  • 追加レイヤによるわずかなオーバーヘッドとコード量の増加
  • 1対1対応できない機能は抽象の漏れが起きやすい
  • 過度に詰め込みすぎると「神アダプタ」化して保守性が低下
  • 依存先の仕様変更に追随するメンテナンスコストが発生

コード例

javascript
// 目的:アプリは Promise ベースの Storage ポートを想定
//   - get(key): Promise<any|null>
//   - set(key, value): Promise<void>
//   - delete(key): Promise<void>
// 現実:ブラウザの localStorage は同期API + 文字列のみ
// → アダプタで非同期化とシリアライズを吸収する

class LocalStorageAdapter {
  constructor(namespace = "app:") {
    this.ns = namespace;
  }

  _k(key) {
    return `${this.ns}${key}`;
  }

  async get(key) {
    const raw = localStorage.getItem(this._k(key));
    if (raw === null) return null;
    try {
      return JSON.parse(raw);
    } catch {
      // 文字列しか入っていない/壊れたJSONを想定して素直に返す
      return raw;
    }
  }

  async set(key, value) {
    const serialized =
      typeof value === "string" ? value : JSON.stringify(value);
    localStorage.setItem(this._k(key), serialized);
  }

  async delete(key) {
    localStorage.removeItem(this._k(key));
  }
}

// 使い方:呼び出し側は Promise ベースの同一インターフェースだけを見る
(async () => {
  const store = new LocalStorageAdapter("user:");

  await store.set("profile", { name: "山田太郎", age: 28 });
  const p = await store.get("profile");
  console.log(p); // { name: '山田太郎', age: 28 }

  await store.delete("profile");
  console.log(await store.get("profile")); // null
})();

振る舞いに関するパターン

ストラテジー(Strategy)

入れ替え可能なアルゴリズム(戦略)を共通インターフェースで定義し、実行時に選択して利用するパターン。呼び出し側は戦略の内部実装を意識せずに、状況や設定に応じて最適な振る舞いへ切り替えられます。

使いどころ

  • 料金/割引/税計算、ルーティング、圧縮/暗号、ソートなどのロジック切替
  • 実行時条件・設定・ABテストで処理方針を差し替えたいとき
  • ベンダー/地域/規制差に応じて仕様が異なるとき
  • テストでダミー戦略や決定論的な戦略を注入したいとき

利点

  • 巨大なif-else/switchの増殖を防ぎ、拡張に強い(OCP)
  • 新しい戦略の追加・差し替えが呼び出し側に波及しにくい
  • 戦略単位でテストでき、モック注入が容易
  • 実行時に柔軟に選択できるため実験や段階的ロールアウトに向く

トレードオフ

  • 小さなクラス/関数が増え、インターフェース設計の手間がかかる
  • 抽象化しすぎると実装固有の最適化にアクセスしにくい
  • 戦略選択ロジックが肥大化すると「神ファクトリ」化しやすい
  • 状態を持つ戦略を再利用すると副作用の温床になる

コード例

javascript
// カート合計に対して割引戦略を差し替える Strategy パターン

// 戦略インターフェース: calc(subtotal, cart) => 割引額(>=0)
class NoDiscount {
  calc(subtotal, cart) {
    return 0;
  }
}

class PercentageDiscount {
  constructor(rate) {
    this.rate = rate; // 例: 0.1 = 10%引き
  }
  calc(subtotal) {
    return Math.floor(subtotal * this.rate);
  }
}

class BulkItemDiscount {
  constructor(sku, minQty, unitOff) {
    this.sku = sku;
    this.minQty = minQty;
    this.unitOff = unitOff; // 1注文あたり固定額の割引
  }
  calc(subtotal, cart) {
    const item = cart.items.find((i) => i.sku === this.sku) || { qty: 0 };
    return item.qty >= this.minQty ? this.unitOff : 0;
  }
}

// コンテキスト: 選ばれた戦略で見積もる
class Pricing {
  constructor(strategy = new NoDiscount()) {
    this.strategy = strategy;
  }
  setStrategy(strategy) {
    this.strategy = strategy;
  }
  quote(cart) {
    const subtotal = cart.items.reduce(
      (sum, i) => sum + i.price * i.qty,
      0
    );
    const discount = Math.max(
      0,
      Math.min(subtotal, this.strategy.calc(subtotal, cart))
    );
    const total = subtotal - discount;
    return { subtotal, discount, total };
  }
}

// 戦略選択の例
function selectStrategy(coupon) {
  if (!coupon) return new NoDiscount();
  if (coupon.type === "percent") return new PercentageDiscount(coupon.rate);
  if (coupon.type === "bulk") {
    return new BulkItemDiscount(
      coupon.sku,
      coupon.minQty,
      coupon.unitOff
    );
  }
  return new NoDiscount();
}

// 使い方
const cart = {
  items: [
    { sku: "A-001", price: 1200, qty: 1 },
    { sku: "B-002", price: 800, qty: 2 },
  ],
};

const coupon1 = { type: "percent", rate: 0.1 }; // 10%引き
const coupon2 = { type: "bulk", sku: "B-002", minQty: 2, unitOff: 200 };

const pricing = new Pricing(selectStrategy(coupon1));
console.log(pricing.quote(cart)); // 10%引き

pricing.setStrategy(selectStrategy(coupon2));
console.log(pricing.quote(cart)); // B-002を2個以上で200円引き

オブザーバー(Observer)

状態変化やイベントの発生を、関心を持つ複数のオブザーバへ通知するためのパターン。発行側(Subject)と受け手(Observer)を疎結合に保ち、実行時に購読の追加・削除や振る舞いの拡張を容易にします。

使いどころ

  • UIイベントやDOMイベントの購読・通知
  • アプリ状態管理(Storeの変更通知、リアクティブUI更新)
  • ドメインイベントやアプリ内イベントの配信(モジュール間連携)
  • リアルタイム更新(WebSocket/Pushの受信を各機能へ配布)
  • 設定変更・キャッシュ無効化・ファイル変更監視のフック
  • ログ/メトリクスのサブスクライブによる横断的処理

利点

  • 1対多の通知を疎結合で実現し、拡張に強い
  • 呼び出し側(Subject)を変更せずに振る舞いを追加できる
  • 関心事を分離し、モジュールごとのテストがしやすい
  • 同期/非同期どちらの実装にも適用しやすい

トレードオフ

  • 通知順序や再入可能性の扱いが難しく、バグが潜みやすい
  • 解除忘れによるメモリリークや重複購読のリスク
  • あるオブザーバの例外が他の通知を止める恐れ(分離と捕捉が必要)
  • 配信保証やバックプレッシャは自前では提供されない
  • イベント名やペイロード契約が拡散すると保守性が低下

コード例

javascript
// シンプルな Subject/Observer の実装と利用例

class Subject {
  constructor() {
    this._observers = new Set();
  }

  // 購読。関数を登録し、解除用関数を返す
  subscribe(fn) {
    this._observers.add(fn);
    return () => this._observers.delete(fn);
  }

  // 一度だけ発火して自動解除
  once(fn) {
    const off = this.subscribe((payload) => {
      try {
        fn(payload);
      } finally {
        off();
      }
    });
    return off;
  }

  // 通知。例外は個別に握りつぶして他への通知を継続
  notify(payload) {
    // 途中で購読解除されても安全なようにスナップショットで回す
    const snapshot = [...this._observers];
    for (const fn of snapshot) {
      try {
        fn(payload);
      } catch (e) {
        console.error("Observer error:", e);
      }
    }
  }
}

// 具体例:価格ティッカー(Subject)
class PriceTicker extends Subject {
  constructor(symbol) {
    super();
    this.symbol = symbol;
    this.price = null;
  }

  updatePrice(newPrice) {
    if (this.price === newPrice) return;
    this.price = newPrice;
    this.notify({ symbol: this.symbol, price: this.price });
  }
}

// 使い方
const ticker = new PriceTicker("BTC/JPY");

// ログ用オブザーバ
const offLog = ticker.subscribe((e) => {
  console.log(`[TICK] ${e.symbol} -> ${e.price.toLocaleString()} JPY`);
});

// 最初の1回だけ実行するオブザーバ
ticker.once((e) => {
  console.log("First tick received:", e);
});

ticker.updatePrice(7_100_000);
ticker.updatePrice(7_150_000);

// 購読解除(以降はログが出ない)
offLog();

ticker.updatePrice(7_200_000);

パターンの選び方

指針

  • 長いif else連鎖はストラテジーで置き換える
  • コンストラクタが煩雑ならビルダーを使う
  • 生成の一元化や横断的関心事の注入にはファクトリが便利
  • 複数サブシステムの調停はファサードで隠蔽する
  • 外部APIの仕様差はアダプターで吸収する
  • イベント駆動設計にはオブザーバーを検討する
  • シングルトンは強力だが乱用しない。本当に単一性が必要なときだけ

よくある落とし穴

  • パターン過多: 目的なく間接化を増やさない
  • God Object(神オブジェクト): ファサードやシングルトンの肥大化に注意
  • 隠れた結合: ファクトリやシングルトンは依存を見えづらくすることがある
  • イベント暴走: オブザーバーの無制御な連鎖は把握が難しくなる

まとめ

デザインパターンはソフトウェア構造を明確に考えるための語彙です。各パターンの意図を理解し、問題との適合を見極め、必要十分な範囲で適用しましょう。さらに深掘りするなら、原典である「Design Patterns(Gang of Four)」を手に取ることをおすすめします。例は古典的でも概念は今も変わらず有効です。

107

おすすめ記事