「この処理、前にも似たような形で書いたな」と感じたことはないでしょうか。設計の悩みの多くは、実は何度も繰り返し現れる よくある問題 です。それに対する定番の解決策に名前を付けて共有できるようにしたものが デザインパターン です。

この記事では、1994年に出版された GoF(Gang of Four)の『デザインパターン』の23パターンを整理したうえで、現代の TypeScript でもよく使う6つを、動くコードとともに読み直します。

1. デザインパターンとは

デザインパターンは、ある状況で繰り返し現れる設計上の問題と、その解決策の組 に名前を付けたものです。

大事なのは、パターンが コードそのものではなく、設計の考え方 だという点です。同じ Strategy パターンでも、言語やプロジェクトによって書き方は変わります。

パターンを知っていると、次のような利点があります。

  • 会話が短くなる:「ここは Strategy にしよう」の一言で、構造と意図が伝わります。
  • 設計の選択肢が増える:問題に出会ったとき、検討できる定番の解決策を思い出せます。
  • 既存コードが読みやすくなる:フレームワークの中に隠れたパターンに気づけます。

GoF の2つの原則

GoF の本には、個々のパターンより大事とも言える2つの原則が書かれています。

GoF の設計原則

  1. 実装ではなく、インターフェースに対してプログラムせよ
  2. クラスの継承より、オブジェクトのコンポジション(組み合わせ)を優先せよ

後で紹介する Strategy、Adapter、Decorator は、どれもこの2つの原則を具体化したものと見ることができます。

2. 23パターンの全体像

GoF は、パターンを目的によって3つに分類しています。

分類目的パターン
生成オブジェクトの 作り方 を切り離すAbstract Factory、Builder、Factory Method、Prototype、Singleton
構造クラスやオブジェクトの 組み合わせ方 を整えるAdapter、Bridge、Composite、Decorator、Facade、Flyweight、Proxy
振る舞いオブジェクト間の 責任の分担と連携 を整理するChain of Responsibility、Command、Interpreter、Iterator、Mediator、Memento、Observer、State、Strategy、Template Method、Visitor

それぞれを一言で表すと、次のようになります。

パターン一言でいうと
Abstract Factory関連する部品の「一式」をまとめて作る窓口を用意する
Builder複雑なオブジェクトを、段階的に組み立てる
Factory Methodどのクラスを作るかを、サブクラスに決めさせる
Prototype既存のオブジェクトを複製して新しいものを作る
Singletonインスタンスが1つだけであることを保証する
Adapter合わないインターフェースを、期待する形に変換する
Bridge「抽象」と「実装」を別々に拡張できるよう分離する
Composite部分と全体(木構造)を同じように扱う
Decorator元のオブジェクトを包んで、機能を後から重ねる
Facade複雑なサブシステムに、簡単な窓口を1つ用意する
Flyweight共有できる状態を共有して、大量のオブジェクトを軽くする
Proxy本物の代わりに立つ代理を置き、アクセスを制御する
Chain of Responsibility処理できる者が見つかるまで、要求を順に渡していく
Command「操作」をオブジェクトにして、記録・取り消し・遅延実行できるようにする
Interpreter簡単な言語の文法を、クラスの構造で表して解釈する
Iterator内部構造を見せずに、要素を順にたどる
Mediatorオブジェクト同士の直接のやり取りを、仲介役に集める
Memento内部状態を保存し、後で元に戻せるようにする
Observer状態の変化を、登録された相手全員に通知する
State状態ごとに振る舞いをクラスに分け、状態の切り替えで振る舞いを変える
Strategyアルゴリズムを部品にして、差し替えられるようにする
Template Method処理の流れを親クラスで決め、細部をサブクラスに任せる
Visitorデータ構造を変えずに、それに対する新しい処理を追加する

全部を覚える必要はありません。以下では、現代の TypeScript でも出番の多い6つを詳しく見ていきます。

3. Strategy:アルゴリズムを差し替える

問題:割引の計算方法が「割引なし」「10%引き」「500円引き」…と増えるたびに、if や switch の分岐が膨らんでいく。

解決策:計算方法を共通のインターフェースを持つ部品(戦略)に切り出し、使う側は「どの戦略か」を知らずに呼び出す。

strategy.ts
interface DiscountStrategy {
  apply(price: number): number
}

const noDiscount: DiscountStrategy = { apply: (price) => price }
const percentOff = (rate: number): DiscountStrategy => ({
  apply: (price) => Math.round(price * (1 - rate)),
})
const amountOff = (amount: number): DiscountStrategy => ({
  apply: (price) => Math.max(0, price - amount),
})

class Checkout {
  constructor(private strategy: DiscountStrategy = noDiscount) {}

  setStrategy(strategy: DiscountStrategy) {
    this.strategy = strategy
  }

  total(prices: number[]): number {
    const subtotal = prices.reduce((sum, p) => sum + p, 0)
    return this.strategy.apply(subtotal)
  }
}

const checkout = new Checkout()
checkout.total([1200, 800])          // 2000
checkout.setStrategy(percentOff(0.1))
checkout.total([1200, 800])          // 1800
checkout.setStrategy(amountOff(500))
checkout.total([1200, 800])          // 1500

新しい割引方法を追加しても、Checkout には手を入れずに済みます。「拡張に対して開いていて、修正に対して閉じている」(開放閉鎖の原則)の典型例です。

4. Observer:変化を購読する

問題:カートの中身が変わったら、ヘッダーのバッジ、合計金額、おすすめ商品…と、更新したい場所がいくつもある。カートの処理が、それらをすべて知っているのは避けたい。

解決策:変化を通知する側(Subject)は、購読者(Observer)のリストだけを持ち、変化があったら全員に知らせる。誰が何をするかは知らない。

observer.ts
type Listener<T> = (payload: T) => void

class Subject<T> {
  private listeners = new Set<Listener<T>>()

  subscribe(listener: Listener<T>): () => void {
    this.listeners.add(listener)
    return () => this.listeners.delete(listener) // 購読をやめる関数を返す
  }

  notify(payload: T): void {
    for (const listener of this.listeners) listener(payload)
  }
}

interface CartEvent {
  itemCount: number
  total: number
}

const cartChanged = new Subject<CartEvent>()

const unsubscribeBadge = cartChanged.subscribe(({ itemCount }) =>
  console.log(`バッジ表示を ${itemCount} に更新`)
)
cartChanged.subscribe(({ total }) => console.log(`合計 ${total} 円を表示`))

cartChanged.notify({ itemCount: 2, total: 2000 })
// → バッジ表示を 2 に更新
// → 合計 2000 円を表示

unsubscribeBadge()
cartChanged.notify({ itemCount: 3, total: 3500 })
// → 合計 3500 円を表示(バッジはもう購読していない)

subscribe が「購読をやめる関数」を返すようにしておくと、後片付けの書き忘れを防ぎやすくなります。

Observer は、身の回りのいたるところにあります。DOM の addEventListener、Node.js の EventEmitter、RxJS、そして Vue のリアクティビティ(ref と watch) も、この考え方の上に成り立っています。

5. Factory Method:何を作るかをサブクラスに任せる

問題:レポートを CSV と Markdown の両方で出力したい。「見出しを書いて、行を順に書く」という流れは同じなのに、出力形式ごとにほぼ同じ処理を書いてしまう。

解決策:共通の流れは親クラスに書き、「どの整形係(Formatter)を作るか」だけをサブクラスに決めさせる。

factory-method.ts
interface Row {
  name: string
  score: number
}

interface Formatter {
  header(): string
  row(row: Row): string
}

abstract class ReportExporter {
  // サブクラスが「どの Formatter を作るか」を決める(Factory Method)
  protected abstract createFormatter(): Formatter

  export(rows: Row[]): string {
    const formatter = this.createFormatter()
    return [formatter.header(), ...rows.map((r) => formatter.row(r))].join('\n')
  }
}

class CsvExporter extends ReportExporter {
  protected createFormatter(): Formatter {
    return {
      header: () => 'name,score',
      row: (r) => `${r.name},${r.score}`,
    }
  }
}

class MarkdownExporter extends ReportExporter {
  protected createFormatter(): Formatter {
    return {
      header: () => '| name | score |\n|---|---|',
      row: (r) => `| ${r.name} | ${r.score} |`,
    }
  }
}

const rows: Row[] = [{ name: 'Alice', score: 92 }, { name: 'Bob', score: 85 }]
new CsvExporter().export(rows)
// name,score
// Alice,92
// Bob,85

export が処理の流れを決め、細部をサブクラスに任せている点は Template Method パターンでもあります。Factory Method は、Template Method の「細部」がたまたま「オブジェクトの生成」である場合、と見ることもできます。

6. Adapter:合わない形を変換する

問題:アプリ全体で使う Logger の形は決まっている。ところが、導入したい外部のログライブラリは、まったく違う形のメソッドしか持っていない。ライブラリのコードは変更できない。

解決策:外部ライブラリを包み、アプリが期待するインターフェースに変換する「変換アダプター」を間に挟む。

adapter.ts
// アプリが期待するインターフェース
interface Logger {
  info(message: string): void
  error(message: string, cause?: unknown): void
}

// 外部ライブラリ(変更できない)
class VendorLogger {
  write(level: 'INFO' | 'ERROR', fields: Record<string, unknown>): void {
    console.log(`[vendor] ${level}`, JSON.stringify(fields))
  }
}

class VendorLoggerAdapter implements Logger {
  constructor(private readonly vendor: VendorLogger) {}

  info(message: string): void {
    this.vendor.write('INFO', { msg: message })
  }

  error(message: string, cause?: unknown): void {
    const detail = cause instanceof Error ? cause.message : cause
    this.vendor.write('ERROR', { msg: message, cause: detail })
  }
}

const logger: Logger = new VendorLoggerAdapter(new VendorLogger())
logger.error('保存に失敗しました', new Error('disk full'))
// → [vendor] ERROR {"msg":"保存に失敗しました","cause":"disk full"}

アプリのコードは Logger だけを知っているので、将来ログライブラリを乗り換えるときも、アダプターを1つ書き直すだけで済みます。外部 API のレスポンスを、アプリ内部の型に変換する層を設けるのも同じ発想です。

7. Decorator:機能を後から重ねる

問題:データを取得する処理に「失敗したら再試行」「結果をキャッシュ」を付けたい。しかし、取得処理そのものに手を入れたり、組み合わせの数だけサブクラスを作ったりはしたくない。

解決策:同じインターフェースを持つ「包み紙」を用意し、元のオブジェクトを包んで機能を足す。包み紙は何重にも重ねられる。

decorator.ts
interface DataSource {
  fetch(key: string): Promise<string>
}

// 失敗したら、指定回数まで再試行する
class RetrySource implements DataSource {
  constructor(private readonly inner: DataSource, private readonly retries = 2) {}

  async fetch(key: string): Promise<string> {
    let lastError: unknown
    for (let attempt = 0; attempt <= this.retries; attempt++) {
      try {
        return await this.inner.fetch(key)
      } catch (e) {
        lastError = e
      }
    }
    throw lastError
  }
}

// 同じキーの結果を使い回す
class CachedSource implements DataSource {
  private readonly cache = new Map<string, Promise<string>>()

  constructor(private readonly inner: DataSource) {}

  fetch(key: string): Promise<string> {
    let hit = this.cache.get(key)
    if (!hit) {
      hit = this.inner.fetch(key)
      this.cache.set(key, hit)
      hit.catch(() => this.cache.delete(key)) // 失敗はキャッシュしない
    }
    return hit
  }
}

// 1回目は失敗し、2回目に成功する API(例)
let calls = 0
const flakyApi: DataSource = {
  async fetch(key) {
    calls++
    if (calls === 1) throw new Error('temporary failure')
    return `value of ${key}`
  },
}

const source: DataSource = new CachedSource(new RetrySource(flakyApi))
await source.fetch('a') // "value of a"(内部で1回再試行。calls = 2)
await source.fetch('a') // "value of a"(キャッシュから。calls = 2 のまま)

ここでは CachedSource(RetrySource(api)) の順に包んでいるので、「再試行込みで成功した結果」がキャッシュされます。逆に RetrySource(CachedSource(api)) の順にすると、再試行のたびにキャッシュを経由します。もし CachedSource が失敗した結果も残す実装だったら、再試行しても同じ失敗を受け取るだけで、再試行が意味をなさなくなります。このように 包む順番が意味を持つ のが Decorator の特徴です。

8. Chain of Responsibility:要求を順に渡す

問題:リクエストに対して「ログを取る」「認証を確認する」「本来の処理をする」を順に行いたい。途中で認証に失敗したら、そこで処理を打ち切りたい。

解決策:処理を小さな部品(ミドルウェア)に分け、鎖のようにつなぐ。各部品は「自分で処理を終える」か「次に渡す」かを決める。

middleware.ts
interface Context {
  path: string
  user?: string
  status?: number
  body?: string
}

type Middleware = (ctx: Context, next: () => Promise<void>) => Promise<void>

// ミドルウェアの配列を、1つの処理につなげる
const compose = (middlewares: Middleware[]) => (ctx: Context): Promise<void> => {
  const dispatch = (i: number): Promise<void> => {
    const mw = middlewares[i]
    return mw ? mw(ctx, () => dispatch(i + 1)) : Promise.resolve()
  }
  return dispatch(0)
}

const logging: Middleware = async (ctx, next) => {
  const start = Date.now()
  await next()
  console.log(`${ctx.path} -> ${ctx.status} (${Date.now() - start}ms)`)
}

const auth: Middleware = async (ctx, next) => {
  if (ctx.path.startsWith('/admin') && !ctx.user) {
    ctx.status = 401
    ctx.body = 'Unauthorized'
    return // next() を呼ばない=ここで処理を打ち切る
  }
  await next()
}

const handler: Middleware = async (ctx) => {
  ctx.status = 200
  ctx.body = `Hello ${ctx.user ?? 'guest'}`
}

const app = compose([logging, auth, handler])

await app({ path: '/admin/settings' })                 // /admin/settings -> 401
await app({ path: '/admin/settings', user: 'kyosei' }) // /admin/settings -> 200
await app({ path: '/blog' })                           // /blog -> 200

logging は await next() の前後に処理を書けるので、「後続の処理全体にかかった時間」を測れます。Koa のミドルウェアはまさにこの形で、Express のミドルウェアや Nuxt のルートミドルウェアも同じ考え方です。

9. Singleton には要注意

Singleton は「インスタンスが1つだけであることを保証する」パターンで、23パターンの中でも特に有名です。ただし、現代ではむしろ 慎重に使うべきもの とされています。

TypeScript(ES Modules)では、モジュールの中で作ったインスタンスは、そのモジュールが1回しか評価されないため、それだけで実質的に1つになります。わざわざ Singleton クラスを書く必要はほとんどありません。

それよりも、必要な部品を 外から渡す(依存性の注入) 形にすると、テストで差し替えやすくなります。Vue の provide / inject も、この考え方の仕組みです。

10. パターンはどこに溶け込んでいるか

1996年、ピーター・ノーヴィグは講演で、GoF の23パターンのうち16は、第一級関数などを持つ動的な言語では 不要になるか、ずっと簡単に書ける と指摘しました。多くのパターンは、当時の C++ や Smalltalk に足りなかった機能を補う工夫でもあったのです。

現代の TypeScript やフロントエンド開発では、次のようにパターンが言語やフレームワークに溶け込んでいます。

パターン溶け込んでいる場所
Strategy、Command関数を値として渡す(コールバック、sort の比較関数、イベントハンドラー)
Iteratorfor...of、ジェネレーター(function*)、Symbol.iterator
ObserveraddEventListener、Vue の ref / watch、RxJS
Chain of ResponsibilityExpress / Koa のミドルウェア、Nuxt のルートミドルウェア
Decorator高階関数(関数を受け取って、機能を足した関数を返す)
ProxyJavaScript の Proxy(Vue 3 のリアクティビティもこれで作られている)
CompositeDOM ツリー、コンポーネントツリー
PrototypeJavaScript のプロトタイプチェーン、structuredClone
Facadefetch のラッパー、composable(useXxx)で複雑な処理を1つの窓口にまとめる
SingletonES Modules のモジュールスコープ、provide / inject

パターンを知っていると、こうした仕組みの 設計意図 が読めるようになります。「なぜ Vue は watch の戻り値で監視を止められるのか」は、Observer の「購読解除」を知っていれば自然に理解できます。

11. パターンとの付き合い方

判断に迷ったときは、次の問いが役に立ちます。

  • その 変化は本当に起きるか?(「将来増えるかもしれない」だけでは、まだ早いことが多い)
  • 関数1つ で済まないか?
  • チームの全員が、そのパターンの 名前と意図 を共有しているか?

12. デザインパターンの歴史

デザインパターンの考え方は、ソフトウェアではなく 建築 から始まりました。

1970年代

  1. アレグザンダー『パタン・ランゲージ』

    建築家クリストファー・アレグザンダーらが、町や建物の設計で繰り返し現れる問題と解決策を253のパターンとしてまとめた。「問題・状況・解決策」に名前を付けて共有するという考え方が、のちにソフトウェアへ持ち込まれる。

  2. アレグザンダー『時を超えた建設の道』

    パターン・ランゲージの背後にある思想を論じた。良い設計には言葉にしにくい「名づけ得ぬ質」があり、パターンはそれを生み出すための手段であるとした。

1980年代

  1. ベックとカニンガム、パターンをプログラムに応用

    ケント・ベックとウォード・カニンガムが、OOPSLA '87 のワークショップで、アレグザンダーのパターン・ランゲージをオブジェクト指向プログラムの設計に使う試みを発表した。

1990年代

  1. GoF『デザインパターン』出版

    ガンマ、ヘルム、ジョンソン、ブリシディースの4人(Gang of Four)が、オブジェクト指向設計の23パターンを生成・構造・振る舞いの3分類で体系化した。「実装ではなくインターフェースに対してプログラムせよ」「継承よりコンポジションを優先せよ」という原則も示した。

    • GoF の23パターン関連
  2. 第1回 PLoP(Pattern Languages of Programs)開催

    パターンを書き、互いに批評し合うための会議が始まった。著者同士が原稿を読み合う「ライターズ・ワークショップ」の形式が定着する。

  3. POSA『パターン指向ソフトウェアアーキテクチャ』

    ブッシュマンらが、Layers、Pipes and Filters、Broker、Model-View-Controller などのアーキテクチャパターンをまとめた。クラスの設計より大きな単位でパターンを扱う流れが生まれる。

  4. ノーヴィグ「動的言語におけるデザインパターン」

    ピーター・ノーヴィグが講演で、GoF の23パターンのうち16は、第一級関数やマクロを持つ言語では不要になるか、ずっと簡単に書けると指摘した。パターンの多くが言語の機能不足を補うものだという見方を広めた。

    • GoF の23パターン関連
  5. 『アンチパターン』出版

    ブラウンらが、うまくいかない解決策が繰り返される様子を「アンチパターン」として整理した。何をすべきでないかにも名前を付けて共有する考え方が広まる。

  6. ファウラー『リファクタリング』

    外から見た振る舞いを変えずにコードの構造を改善する手法を、名前の付いた手順の集まりとしてまとめた。既存のコードを少しずつパターンへ近づける道筋が示された。

2000年代

  1. ファウラー『エンタープライズアプリケーションアーキテクチャパターン』

    Repository、Unit of Work、Active Record、Data Mapper など、業務システムでデータベースや層構造を扱うためのパターンをまとめた。

  2. エヴァンス『ドメイン駆動設計』と『エンタープライズ統合パターン』

    エヴァンスは Entity、Value Object、Aggregate、Repository などで業務の知識をモデルに表す方法を、ホーペとウルフはメッセージングによるシステム連携の65のパターンを示した。

  3. ファウラー「依存性の注入(Dependency Injection)」

    制御の反転(IoC)コンテナの考え方を整理し、「依存性の注入」という名前を広めた記事を公開した。Singleton に頼らずに部品を組み立てる方法として定着する。

  4. 『Head First デザインパターン』

    図や会話を多用した入門書として、GoF のパターンを多くの開発者に広めた。

    • GoF の23パターン関連

まとめ

  • デザインパターンは、繰り返し現れる設計の問題と解決策に 名前を付けて共有 するためのもの。
  • GoF の23パターンは「生成」「構造」「振る舞い」の3つに分類される。根底には「インターフェースに対してプログラムせよ」「継承よりコンポジション」という原則がある。
  • Strategy、Observer、Factory Method、Adapter、Decorator、Chain of Responsibility は、現代の TypeScript でも出番が多い。
  • 多くのパターンは、関数やフレームワークの仕組みとして すでに溶け込んでいる。パターンを知ることは、それらの設計意図を読むことにつながる。
  • パターンは目的ではない。必要になったときに、必要な分だけ使う。