ソフトウェア設計のデザインパターン
デザインパターンは「23個の型」として語られることが多い。ただしあの23個は、1994年のC++とSmalltalkという文脈、つまり継承がほぼ唯一の拡張手段で、関数を値として渡せず、メモリを手で管理していた時代に、繰り返し現れた解決策の記録でもある。
30年経って状況は変わった。当時パターンで埋めていた穴のうち、いくつかは言語機能そのものになり、いくつかは形を変えて残り、いくつかは前提ごと消えた。この記事では、パターンがどこから来たのか、各パターンが何を解決するのか、なぜ当時それが必要だったのかを追い、そのうえで23個すべてについて「いまのGoならどう書くか、あるいは何に置き換わったか」を並べる。
掲載するGoコードは go1.27.1 で go test と go vet を通したものだけを載せる。
まとめ
- パターンの発想は建築家クリストファー・アレグザンダーの『A Pattern Language』(1977)に由来する。ソフトウェアへの持ち込みは1987年の Beck と Cunningham の発表が最初期で、GoF の『Design Patterns』(1994)はその流れの集大成にあたる
- 23パターンは「オブジェクト指向の真理」ではなく、当時の言語に足りなかったものの一覧として読むほうが正確だ。1996年には Peter Norvig が、23個のうち16個は動的言語では「見えなくなるか、もっと単純になる」と指摘している
- Goから見た現在地は4つに分かれる。そのまま使う(Decorator、Composite、Adapter、Proxy、State など8個)、言語機能に吸収(Strategy、Command、Iterator、Template Method など6個)、形が変わる(Observer、Bridge など4個)、出番が減った(Singleton、Flyweight、Visitor など5個)
- 吸収の主因は3つ。第一級関数がメソッド1つだけのクラスを不要にし、構造的型付けのインターフェースが適合のためだけの型を不要にし、埋め込みが委譲を安くした
- Goで最も生き残るのは Decorator だ。「同じインターフェースを受け取って同じインターフェースを返す」形は、HTTPミドルウェアと
http.RoundTripperとして標準ライブラリの設計そのものになっている - Iterator は Go 1.23 の range over func で言語構文になった。
breakがyieldの戻り値falseに化ける仕組みまで含めて、パターンとして設計する対象ではなくなった - Singleton は避けたほうがよい。
sync.OnceValueで書けるが、テストで差し替えられなくなる。GoF自身も2009年のインタビューで、いま書き直すなら Singleton は外すと述べている。使っていること自体が、設計の危うさを示す兆候になりやすいからだ - パターンの主戦場はクラスの組み合わせから外へ移った。層構造(PoEAA)、ドメインモデル(DDD)、分散システム(Circuit Breaker、Saga、Outbox)が現在の語彙になっている
- GoFの3分類は実務の問いと噛み合わない。記事の後半では「何を安定させたいか」で8領域に分け直し、実際に使うパターンを66個並べた。APIと型の設計、依存の組み立て、エラーと失敗、並行と資源、データと整合性、サービス間と信頼性、入出力の境界、テスト
- 66個の内訳は、古典カタログ(GoF・PoEAA・POSA)由来が8個、Go固有の定石が32個(Functional Options、WithTx、型付きcontextキーなど)、分散・運用由来が26個(Circuit Breaker、singleflight、冪等な受信など)だった。GoFの23個の子孫は少数派にすぎない
- 実務での使い方は「構造をまねる」ではなく「名前を借りて意図を伝える」に寄せる。Goでは、パターンを適用するより素直に書いたほうが短く読みやすい場面は多い
検証環境
| 項目 | 値 |
|---|---|
| OS | Linux (x86_64) |
| Go | go1.27.1 |
| 確認方法 | 記事中のコードは1つのパッケージにまとめ、go test ./... と go vet ./... を通した |
対象読者は、Goを書いていて「このパターン名は知っているが、Goでどう書くのが自然なのか」が気になる中級者を想定する。
パターンはどこから来たか
建築の設計知をソフトウェアへ
出発点はソフトウェアではない。建築家クリストファー・アレグザンダーが『A Pattern Language』(1977)で、街や建物の設計知を253個のパターンとして記述した。各パターンは「繰り返し現れる問題」「その文脈」「解の核」を名前付きで書き、パターン同士をつないで1つの言語にする。重要なのは形式ではなく、同じ問題が何度も現れるなら、その解に名前を付けて共有できるという発想だ。
これをオブジェクト指向設計へ持ち込んだ最初期の例が、Kent Beck と Ward Cunningham が1987年の OOPSLA で報告した、Smalltalk のウィンドウ設計に使った5つのパターンとされる。その後1993年に Hillside Group ができ、1994年から PLoP 会議が始まり、パターンを書いて査読し合う文化が育った。
年表
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
GoFの『Design Patterns』が出たのは1994年だ。Erich Gamma、Richard Helm、Ralph Johnson、John Vlissides の4人が、GUIツールキットやフレームワークの実装経験から23個を選び、生成・構造・振る舞いの3つに分類してカタログ化した。
なぜ1994年に必要だったのか
23パターンの多くは、当時の言語の制約を人手で埋めるための工夫だった。制約を並べると、それぞれのパターンが何の代わりだったのかが見えてくる。
| 1994年の制約 | 何が困るか | 埋めたパターン |
|---|---|---|
| 関数が値ではない(クロージャがない) | 「あとで実行する処理」「差し替えたい処理」を渡せない | Strategy、Command、Template Method |
| 拡張点が継承しかない | 組み合わせのたびにサブクラスが増える | Bridge、Decorator、Template Method |
| 型を実行時に選べない | 生成する型を切り替えられない | Abstract Factory、Factory Method、Prototype |
| 名前空間とモジュールがない | グローバル変数の衝突と初期化順序が壊れる | Singleton、Facade |
| メモリが小さく手動管理 | 小さなオブジェクトを大量に持てない | Flyweight |
| 単一ディスパッチしかない | 「2つの型の組み合わせ」で処理を分岐できない | Visitor |
| 走査方法がコレクションごとに違う | 共通のループが書けない | Iterator |
| インターフェースの実装を明示宣言する必要がある | 既存の型を後から抽象へ当てられない | Adapter |
つまり23パターンは、言語が用意していない機能を、クラスの組み合わせでエミュレートした結果として読める。この見方は1996年に Peter Norvig が講演で明確にした。彼は23個のうち16個が Lisp や Dylan のような動的言語では「見えなくなるか、もっと単純になる」と指摘している。
23パターンの地図
先に全体像を置く。各パターンの「解決する問題」「1994年に必要だった理由」「いまのGoでの書き方」「代替と注意」は、以下の図でカードをクリックすると読める。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
色分けは、Goから見た現在地を4段階で表している。
- そのまま使う(8個)Adapter、Composite、Decorator、Facade、Proxy、Chain of Responsibility、Memento、State
- 言語機能に吸収(6個)Builder、Factory Method、Command、Iterator、Strategy、Template Method
- 形が変わる(4個)Abstract Factory、Bridge、Mediator、Observer
- 出番が減った(5個)Prototype、Singleton、Flyweight、Interpreter、Visitor
以下、分類ごとにコードを見ていく。
生成に関するパターン
生成の5つは、Goでは最も原形をとどめない。理由は単純で、関数を値として渡せ、ゼロ値の使える構造体を持ち、可変長引数も書けるからだ。
Singleton — 書けるが、避けたい
インスタンスを1つに制限し、どこからでも取れる入口を用意するパターンだ。当時のC++には名前空間がなく、翻訳単位をまたぐグローバル変数の初期化順序も未定義だった。「安全なグローバル」を作るには、静的メンバへのアクセサを1つ用意するのが現実的な解だった。
Goでは sync.OnceValue(Go 1.21〜)で書ける。
1type Config struct{ DSN string }
2
3var loadConfig = sync.OnceValue(func() *Config {
4 return &Config{DSN: "postgres://localhost/app"}
5})
loadConfig() は何度呼んでも同じポインタを返し、初期化は1回しか走らない。並行に呼んでも安全だ。
問題は設計面にある。この形にすると、テストから設定を差し替える手段がない。呼び出し側は loadConfig() を直接呼ぶので、依存が引数に現れず、何に依存しているかがシグネチャから読めなくなる。素直な代替は、依存として渡すことだ。
1type Service struct{ cfg *Config }
2
3func NewService(cfg *Config) *Service { return &Service{cfg: cfg} }
GoFの3人も2009年のインタビューで、いま書き直すなら Singleton は落とすと述べている。理由は、使っていること自体が設計の危うさを示す兆候になりやすいからだ。
Factory Method / Abstract Factory — 関数と構造体で足りる
Factory Method は「生成する具体型の決定を、差し替え可能な場所に委ねる」パターンだ。継承しか拡張点がなかった時代には、フレームワークがサブクラスにオーバーライドさせるしかなかった。
Goではインターフェースを返す関数がそのまま抽象になる。
1type Store interface {
2 Get(key string) (string, error)
3}
4
5func NewStore(kind string) (Store, error) {
6 switch kind {
7 case "mem":
8 return &memStore{m: map[string]string{}}, nil
9 case "file":
10 return &fileStore{dir: "/var/lib/app"}, nil
11 default:
12 return nil, fmt.Errorf("unknown store kind: %q", kind)
13 }
14}
種類が増えて switch が膨らむなら、レジストリにして各実装に自己登録させる。
1var storeFactories = map[string]func() Store{}
2
3func RegisterStore(kind string, f func() Store) { storeFactories[kind] = f }
database/sql の sql.Register がこの形だ。ドライバのパッケージを _ インポートすると init() で自己登録され、sql.Open("postgres", dsn) が名前から実装を引く。
Abstract Factory は「関連する部品の一式」をまとめて作るパターンで、GoFではファクトリのクラス階層になっていた。Goでは、コンストラクタ関数を並べた構造体を1つ渡せば済む。
1// Store と Queue はどちらもインターフェース
2type Platform struct {
3 NewStore func() Store
4 NewQueue func() Queue
5}
この形なら、テスト用のプラットフォームは構造体リテラルで作れる。継承階層は要らない。
Builder — Functional Options に置き換わった
Builder は、生成手順が複雑なオブジェクトを段階的に組み立てるパターンだ。名前付き引数やデフォルト引数を持たない言語では、任意項目が1つ増えるたびにコンストラクタのオーバーロードが倍になった。
Goの定番は Functional Options で、func(*T) を可変長引数で受ける。
1type Server struct {
2 addr string
3 timeout time.Duration
4 maxConn int
5}
6
7type Option func(*Server)
8
9func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
10func WithMaxConn(n int) Option { return func(s *Server) { s.maxConn = n } }
11
12func NewServer(addr string, opts ...Option) *Server {
13 s := &Server{addr: addr, timeout: 30 * time.Second, maxConn: 100}
14 for _, opt := range opts {
15 opt(s)
16 }
17 return s
18}
呼び出しは NewServer(":8080", WithTimeout(time.Second)) になる。必須引数は位置引数、任意引数はオプションと分けられ、既定値はコンストラクタの中に1か所だけ書ける。
ただし常にこれが正しいわけではない。項目が素直で、ゼロ値が妥当な既定値なら、構造体リテラルのほうが短い。http.Server{Addr: ":8080"} がその例だ。オプション関数を用意する価値が出るのは、既定値がゼロ値と違うとき、検証を挟みたいとき、後方互換を保ったまま項目を足したいときに絞られる。
Prototype — 値セマンティクスとGCで大半が消えた
既存インスタンスを複製して新しいインスタンスを得るパターンだ。クラスを値として扱えない言語で「種類を実行時に選ぶ」ための逃げ道であり、生成コストが高いオブジェクトの使い回しにも使われた。
Goでは構造体の代入がコピーになるので、値型なら何もしなくてよい。参照を含むときだけ明示的に複製する。
1type Template struct {
2 Name string
3 Headers map[string]string
4}
5
6func (t *Template) Clone() *Template {
7 return &Template{Name: t.Name, Headers: maps.Clone(t.Headers)}
8}
maps.Clone、slices.Clone、bytes.Clone が標準で用意されている。いずれも浅いコピーなので、値の中にさらに参照があるなら自分で再帰する必要がある。
そもそも複製が必要になるのは、可変な状態を共有しているからだ。イミュータブルな値として設計できるなら、複製という操作自体が消える。
構造に関するパターン
構造の7つのうち5つ、Adapter・Decorator・Proxy・Facade・Bridge は、どれも「呼び出し側と対象の間に一枚挟む」点で同じに見える。違いは3点しかない。インターフェースを変えるか、いくつ包むか、何のために挟むか、だ。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
Adapter — 構造的型付けで大半が消える
既存の型のインターフェースを、呼び出し側が期待する別のインターフェースへ変換する。Goのインターフェースは構造的型付けなので、メソッドの形が合っていれば宣言なしで適合する。つまり「適合させるためだけの型」を書く場面自体が減る。
形が合わないときは、関数を型にする方式が定番だ。標準ライブラリの http.HandlerFunc がその形になっている。
1type WriterFunc func(p []byte) (int, error)
2
3func (f WriterFunc) Write(p []byte) (int, error) { return f(p) }
これで、ただの関数が io.Writer として通る。io.NopCloser や bufio.NewReader も、目的は違うが構造としては同じアダプタだ。
Decorator — Goで最も生き残るパターン
インターフェースを保ったまま、実行時に機能を積み増す。継承で機能の組み合わせを表すとクラス数が爆発するうえ、実行時の付け外しもできなかった。この問題は言語が変わっても消えていない。
Goでは「同じインターフェースを受け取り、同じインターフェースを返す関数」を書く。
1type headerRT struct {
2 base http.RoundTripper
3 k, v string
4}
5
6func (t headerRT) RoundTrip(req *http.Request) (*http.Response, error) {
7 req = req.Clone(req.Context())
8 req.Header.Set(t.k, t.v)
9 return t.base.RoundTrip(req)
10}
11
12func WithHeader(base http.RoundTripper, k, v string) http.RoundTripper {
13 if base == nil {
14 base = http.DefaultTransport
15 }
16 return headerRT{base: base, k: k, v: v}
17}
http.Client{Transport: WithHeader(nil, "X-Trace", id)} とすれば、リクエストを出すコードを一切変えずにヘッダが付く。ログ、リトライ、計測、認証はすべてこの層に載せられる。
構造体の埋め込みを使うと、変えたいメソッドだけを書けばよくなる。
1type loggingStore struct {
2 Store // 埋め込み。Get 以外のメソッドはそのまま委譲される
3 log *slog.Logger
4}
5
6func (s loggingStore) Get(k string) (string, error) {
7 v, err := s.Store.Get(k)
8 s.log.Info("get", "key", k, "err", err)
9 return v, err
10}
Proxy — 構造はDecoratorと同じ、意図が違う
対象と同じインターフェースを保ったまま、アクセスを制御する。1994年当時は CORBA のような分散オブジェクトのスタブが具体的な用途だった。いまも gRPC の生成クライアントは、リモート呼び出しをローカルのメソッド呼び出しに見せるプロキシだ。
1type cachingStore struct {
2 next Store
3 cache map[string]string
4}
5
6func (c *cachingStore) Get(k string) (string, error) {
7 if v, ok := c.cache[k]; ok {
8 return v, nil
9 }
10 v, err := c.next.Get(k)
11 if err != nil {
12 return "", err
13 }
14 if c.cache == nil {
15 c.cache = map[string]string{}
16 }
17 c.cache[k] = v
18 return v, nil
19}
Decorator との違いは「対象を必ず呼ぶか」にある。キャッシュヒット時に本体を呼ばないなら、それはアクセス制御であり Proxy だ。
Composite — インターフェースと再帰で素直に書ける
個と集合を同じインターフェースで扱い、木構造を再帰的に処理する。
1type Node interface{ Size() int }
2
3type File struct{ Bytes int }
4
5func (f File) Size() int { return f.Bytes }
6
7type Dir struct{ Children []Node }
8
9func (d Dir) Size() int {
10 total := 0
11 for _, c := range d.Children {
12 total += c.Size()
13 }
14 return total
15}
標準ライブラリでは io.MultiWriter が典型で、複数の io.Writer を1つの io.Writer に見せる。http.ServeMux の入れ子や fs.FS も同じ構造だ。
注意点は、葉と枝でインターフェースを共通化しすぎることだ。GoFの本でも「葉に Add(child) を生やすか」が議論になっている。葉に意味のないメソッドを実装させたくなったら、インターフェースを分けるサインとみなす。
Facade / Bridge / Flyweight
Facade は、複雑なサブシステムに単純な入口を1つ用意する。Goではパッケージの公開APIがそのままファサードになり、実装は internal/ に置けば外から参照できない。言語の機能で達成できるので、パターン名を持ち出す機会はほとんどない。
Bridge は、抽象と実装を独立して差し替えられるようにし、「図形の種類 × 描画バックエンド」のような掛け算のクラス爆発を足し算へ変える。継承のないGoでは、実装側のインターフェースをフィールドで持つ形が自然に出てくる。
1type Renderer interface {
2 Draw(shape string) string
3}
4
5type Circle struct{ R Renderer }
6
7func (c Circle) Render() string { return c.R.Draw("circle") }
database/sql と driver、log/slog と slog.Handler の関係が、標準ライブラリでのブリッジにあたる。
Flyweight は、大量の小さなオブジェクトの共通部分を共有してメモリを節約する。1994年の一般的なマシンのメモリは数十MBで、文字1つをオブジェクトにするような設計では共有が生死を分けた。いまは値型とスライスで詰めるだけで足りることが多い。それでも同じ文字列やキーが大量に重複するなら、Go 1.23 で入った unique パッケージが使える。
1h1 := unique.Make("application/json")
2h2 := unique.Make("application/json")
3// h1 == h2 はポインタ比較1回で済み、元の値は h1.Value() で取れる
sync.Pool は名前が近いが目的が違う。あちらは「使い終わったオブジェクトの再利用」であって、「同じ値の共有」ではない。どちらも、まず計測してから使う。
振る舞いに関するパターン
11個ある。Goでは、関数値とチャネルと型スイッチのどれかに落ちることが多い。
Strategy / Template Method / Command — 全部クロージャになる
この3つは、GoFの記述では別のパターンだが、Goで書くとどれも「関数を渡す」に収束する。当時これらが独立したパターンだったのは、関数を値として渡せなかったからだ。
Strategy はアルゴリズムの差し替えだ。
1type Pricing func(cents int) int
2
3func Discount(rate float64) Pricing {
4 return func(cents int) int { return int(float64(cents) * (1 - rate)) }
5}
6
7func Total(items []int, p Pricing) int {
8 sum := 0
9 for _, c := range items {
10 sum += p(c)
11 }
12 return sum
13}
sort.Slice の less 関数や、http.Client.Transport の差し替えが標準ライブラリでの例になる。差し替えたい操作が2つ以上になったら、関数値をやめてインターフェース1つにまとめる。関数値とインターフェースの違いは、メソッドの本数だけだ。
Template Method は処理の骨組みを固定し、可変部分だけを差し替える。継承ベースのフレームワークでは、抽象クラスが骨組みを持ち、サブクラスが穴を埋めた。Goでは骨組みを関数、穴を引数にする。
1func Retry(attempts int, do func() error, onErr func(int, error)) error {
2 var err error
3 for i := range attempts {
4 if err = do(); err == nil {
5 return nil
6 }
7 if onErr != nil {
8 onErr(i, err)
9 }
10 }
11 return fmt.Errorf("after %d attempts: %w", attempts, err)
12}
Command は操作そのものをオブジェクトとして表し、キューへ積む・記録する・元に戻す、といった扱いを可能にする。クロージャがあれば、操作そのものが値になる。
1type Command func() error
2
3type CommandQueue struct{ cmds []Command }
4
5func (q *CommandQueue) Add(c Command) { q.cmds = append(q.cmds, c) }
6
7func (q *CommandQueue) Run() error {
8 for _, c := range q.cmds {
9 if err := c(); err != nil {
10 return err
11 }
12 }
13 return nil
14}
undo が必要なときだけ、逆操作を持つ構造体に戻す価値がある。記録が目的なら、関数ではなくイベントを値として残すほうが後で扱いやすい。
Iterator — 言語構文になった
内部表現を晒さずに要素を順にたどるパターンだ。STL 普及前は、コレクションごとに走査の書き方がばらばらで、カーソルをオブジェクトにするのが唯一の統一手段だった。
Goでは for range が最初から言語機能で、Go 1.23 の range over func によって、自作の型でも同じ構文が使えるようになった。
1type Ring[T any] struct{ items []T }
2
3func (r *Ring[T]) All() iter.Seq2[int, T] {
4 return func(yield func(int, T) bool) {
5 for i, v := range r.items {
6 if !yield(i, v) {
7 return
8 }
9 }
10 }
11}
呼び出し側は for i, v := range r.All() { ... } と書ける。ここで面白いのは制御の流れだ。ループ本体は yield という関数としてコンパイラから渡され、break は yield の戻り値 false に化ける。次の図で1ステップずつ追える。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
GoFの Iterator は Next() と HasNext() を持つカーソルオブジェクトを別に用意する外部イテレータだった。iter.Seq は本体を関数として渡す内部イテレータだが、戻り値で早期終了を伝えられるため、外部イテレータの利点だった break も失われていない。
標準ライブラリ側も、slices.All・slices.Values・maps.Keys(いずれも Go 1.23)、strings.SplitSeq・strings.Lines(Go 1.24)と、反復子を返すAPIへ広がっている。
Observer / Mediator — チャネルで形が変わる
Observer は状態変化を依存する側へ自動的に伝える。MVCの中核であり、GUIツールキットの根幹だった。当時は単一スレッド前提で、通知は同期的なメソッド呼び出しだった。
Goではチャネルで書ける。ただし本質は通知そのものではなく、購読解除と、遅い購読者への対処にある。
1type Topic struct{ chans []chan string }
2
3func (t *Topic) Subscribe(buf int) <-chan string {
4 ch := make(chan string, buf)
5 t.chans = append(t.chans, ch)
6 return ch
7}
8
9func (t *Topic) Publish(msg string) {
10 for _, ch := range t.chans {
11 select {
12 case ch <- msg:
13 default: // 遅い購読者で全体を止めない
14 }
15 }
16}
select の default で「詰まっていたら捨てる」を選んでいる。捨てたくないなら、ブロックするか、キューを別に持つか、購読者を切り離すかを選ぶことになる。この判断こそが設計の中身で、GoFの記述にはなかった論点だ。
なお、通知の中でも「終了とキャンセル」は標準に取り込まれた。context.Context の Done() チャネルは、キャンセルを木構造に沿って伝播させる仕組みそのものだ。
Mediator は多対多の直接参照をやめ、やり取りを1か所に集める。Goでは調停役のゴルーチンか、購読者を持つイベントバスになる。集約しすぎると神オブジェクトになるので、イベントの種類で境界を切って小さく保つ。
State — 「次の状態を返す関数」
状態ごとに振る舞いを切り替え、巨大な条件分岐を消すパターンだ。GoFでは状態ごとにクラスを作り、コンテキストが現在の状態オブジェクトを持ち替えた。
Goで有名なのは、状態を関数として表す形だ。Rob Pike が text/template の字句解析に使った書き方で、状態遷移が型に現れる。
1type stateFn func(*lexer) stateFn
2
3func lexText(l *lexer) stateFn {
4 start := l.pos
5 for l.pos < len(l.in) && l.in[l.pos] != ' ' {
6 l.pos++
7 }
8 if l.pos > start {
9 l.tokens = append(l.tokens, l.in[start:l.pos])
10 }
11 if l.pos >= len(l.in) {
12 return nil
13 }
14 return lexSpace
15}
16
17func Lex(s string) []string {
18 l := &lexer{in: s}
19 for st := lexText; st != nil; {
20 st = st(l)
21 }
22 return l.tokens
23}
返り値の型が stateFn 自身という再帰的な定義が肝で、状態遷移表を書かずに遷移を表現できる。ただし状態が3つ程度なら、素直な switch のほうが読みやすい。
Chain of Responsibility — ミドルウェアそのもの
要求を処理できる相手が見つかるまで候補を順にたどる。GUIのイベントを親ウィジェットへ伝播させる仕組みが元の用途だった。
Goでは net/http のミドルウェアが事実上の標準形になっている。
1type Middleware func(http.Handler) http.Handler
2
3func Chain(h http.Handler, ms ...Middleware) http.Handler {
4 for _, m := range slices.Backward(ms) {
5 h = m(h)
6 }
7 return h
8}
slices.Backward(Go 1.23)で後ろから包むと、Chain(h, logging, auth) が「logging の内側に auth、その内側に h」という直感どおりの順序になる。
エラーの世界にも同じ構造がある。errors.Is と errors.As は、ラップされたエラーを Unwrap でたどりながら該当するものを探す。連鎖のたどり方を標準ライブラリが持っている形だ。
Memento / Visitor / Interpreter
Memento はカプセル化を壊さずに内部状態を保存・復元する。Goでは非公開フィールドだけを持つ小さな構造体を返せばよい。
1type Editor struct{ text string }
2
3type snapshot struct{ text string }
4
5func (e *Editor) Save() snapshot { return snapshot{text: e.text} }
6func (e *Editor) Restore(s snapshot) { e.text = s.text }
snapshot は同一パッケージ内でしか中身を触れないため、外部からは不透明な値として扱える。イミュータブルな値を使っているなら、そもそもスナップショットは代入1つで済む。
Visitor は、データ構造のクラスを変えずに新しい操作を後から足す技法だ。当時の言語は単一ディスパッチしか持たず、「ノードの型 × 操作の種類」で分岐するには二重ディスパッチを手で組む必要があった。accept(visitor) と visit(concreteNode) を往復させるあの構造は、言語機能の不足を埋めるための仕掛けだ。
Goでは型スイッチで足りる。
1func Depth(e Expr) int {
2 switch v := e.(type) {
3 case Add:
4 return 1 + max(Depth(v.L), Depth(v.R))
5 default:
6 return 1
7 }
8}
go/ast.Inspect はコールバック1つで構文木を歩く。代償として、型スイッチの網羅性はコンパイラが保証しない。型の集合を閉じたいなら、非公開メソッドを持つインターフェース(sealed interface)にして、実装を同一パッケージに限る方法がある。
Interpreter は小さな言語の文法を型で表し、構文木を再帰的に評価する。
1type Expr interface{ Eval(env map[string]int) int }
2
3type Lit int
4
5func (l Lit) Eval(map[string]int) int { return int(l) }
6
7type Var string
8
9func (v Var) Eval(env map[string]int) int { return env[string(v)] }
10
11type Add struct{ L, R Expr }
12
13func (a Add) Eval(env map[string]int) int { return a.L.Eval(env) + a.R.Eval(env) }
構造としては素直だが、実務でミニ言語を自作する機会は減った。設定なら CUE や Starlark のような既存の言語に載せられるし、パーサが必要なら生成器を使える。GoF自身も2009年に、Interpreter と Flyweight は他と性質が違うので別枠へ移す案を語っている。
言語機能がパターンを吸収する
ここまでを整理すると、「パターンが消える」現象は、言語機能がその役目を引き受けた結果として説明できる。どの機能がどのパターンを引き受けたかを図にした。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
吸収の主因は3つに絞れる。
- 第一級関数とクロージャ。メソッドを1つしか持たないクラスを書く理由が消える。Strategy、Command、Template Method、Factory Method、State がここに落ちる。可変長引数と組み合わせると Functional Options になり、Builder も引き受ける
- 構造的型付けのインターフェース。実装側が「このインターフェースを実装します」と宣言しなくてよい。既存の型に後から抽象を当てられるので、Adapter や Abstract Factory の多くが不要になる
- 構造体の埋め込み。埋め込んだ型のメソッドは自動的に委譲される。Decorator と Proxy を書くコストが、継承の副作用なしで下がる
ここで注意したいのは、吸収されていないパターンが不要だという意味ではないことだ。Decorator や Composite は言語が肩代わりするものではなく、設計判断としてそのまま残る。逆に Singleton のように、書けるが避けたほうがよいものもある。
Norvig の1996年の指摘(23個中16個は動的言語では見えなくなるか単純になる)は、Goのような静的型付け言語にも半分は当てはまる。Goには動的言語のような実行時の型操作はないが、第一級関数と構造的型付けのインターフェースが、同じ穴を別の方向から埋めている。
GoF以降、パターンの主戦場はどこへ行ったか
23パターンの粒度は「クラス数個の組み合わせ」だ。この粒度で解ける問題は、言語機能とフレームワークにかなり吸収された。代わりに、パターンとして語られる対象は上と外へ移った。
| 層 | 代表的な語彙 | 出典 |
|---|---|---|
| アプリケーションの層構造 | Repository、Unit of Work、Data Mapper、Service Layer | Fowler『PoEAA』(2002) |
| ドメインモデル | Entity、Value Object、Aggregate、Bounded Context | Evans『DDD』(2003) |
| 分散システム | Circuit Breaker、Retry with Backoff、Bulkhead、Saga、Outbox、Idempotent Receiver | Nygard『Release It!』(2007)、Hohpe/Woolf『EIP』(2003) ほか |
| 実行基盤 | Sidecar、Ambassador、Adapter(コンテナ構成)、Leader Election | クラウドネイティブの各種ガイド |
Goで実務のコードを書いていて名前が役に立つのは、いまはこちら側であることが多い。「ここは Circuit Breaker を入れる」「この処理は冪等にして Outbox で送る」という会話は、構造を一意に決めないまま意図を共有できる。これはアレグザンダーが最初に狙ったパターンの使い方に近い。
一方で、23パターンの知識が無駄になるわけでもない。標準ライブラリを読むときの見取り図になるからだ。slog.Handler を見て「これはブリッジだ」と分かれば、Handler を差し替える設計が言語の慣習であることを一瞬で理解できる。
分類をやり直す — 「何を安定させたいか」で並べる
ここまでGoFの3分類に沿って書いてきたが、実務でパターンを探すときにこの分類はあまり効かない。理由は切り口にある。「生成・構造・振る舞い」はオブジェクトをどう作り、どう組み合わせ、どう動かすかという実装の形で切った分類だ。一方、実務で発生する問いは「この不安定さをどう抑えるか」であり、両者は噛み合わない。
具体的にずれる例を挙げる。
- Decorator(構造)と Chain of Responsibility(振る舞い)は、Goではどちらも同じHTTPミドルウェアとして書く。分類は違うのに、書くコードは同じ形になる
- Singleton(生成)と依存性注入は、どちらも「依存をどうやって手に入れるか」という同じ問いへの別の答えだ。しかしDIはGoFに載っていない
- Circuit Breaker やリトライのように、実務で頻繁に使うものが23個の中に1つもない
そこで後半では、何を安定させたいかを軸に8つへ分け直す。GoF由来のパターンも、Go固有の定石も、分散システムの都合から来たものも、同じ軸の上に並べる。
| 領域 | 抑えたい不安定さ | 代表例 |
|---|---|---|
| APIと型の設計 | 呼び出し側が間違った使い方をできてしまう | Functional Options、スマートコンストラクタ |
| 依存の組み立て | 依存が固定され、差し替えとテストができない | コンストラクタ注入、ポートとアダプタ |
| エラーと失敗 | 失敗の原因が追えない、失敗が伝播して広がる | エラーのラップ、errgroup、リトライ |
| 並行と資源 | 同時実行数と寿命が制御できない | context、同時実行数の上限、singleflight |
| データと整合性 | 更新が競合する、部分的に失敗する | WithTx、楽観ロック、Outbox |
| サービス間と信頼性 | 相手が落ちる、遅い、二重に届く | Circuit Breaker、冪等な受信、Saga |
| 入出力の境界 | 横断的な処理が各所に散る | ミドルウェア、型付きcontextキー、構造化ログ |
| テスト | 検証が遅く、壊れやすい | テーブル駆動、fake、testcontainers |
以下の図に、この記事で扱う全パターンを領域別に並べた。カードをクリックすると、解決する課題・Goでの形・実例・注意点が出る。由来(GoF由来 / Go固有の定石 / 分散・運用)でも絞り込める。
この図は JavaScript で描画する。JavaScript を有効にすると、図を操作しながら確認できる。
由来で絞ると分かるとおり、GoF・PoEAA・POSA といった古典カタログに載る系統は66個中8個しかない。残りは言語機能から生まれたGo固有の定石(32個)と、ネットワークとデプロイの都合から生まれたもの(26個)だ。
以下、領域ごとに中身を見ていく。掲載するコードはここまでと同様、go1.27.1 で go test を通したものだけを載せる。
APIと型の設計
「呼び出し側が間違った使い方をできてしまう」を、コンパイル時か生成時に潰す領域だ。
| パターン | 解決する課題 |
|---|---|
| Functional Options | 任意項目が増えるたびにコンストラクタが増殖し、項目を足すと既存の呼び出しが壊れる |
| 設定構造体とゼロ値 | 設定項目が多く、呼び出し側が指定可能な項目を一覧したい |
| 単位を型にする | int の引数が並び、金額と個数、秒とミリ秒を取り違えてもコンパイルが通る |
| スマートコンストラクタ | 不正な値を持つ構造体を、パッケージの外から自由に作れてしまう |
| ゼロ値を有効にする | New の呼び忘れで nil を触る。初期化の作法が型ごとに違う |
| 受け取りは抽象、返すのは具体 | 引数が具体型だと差し替えられず、戻り値が抽象だと情報が失われる |
| 使う側でインターフェースを定義 | 実装側の大きなインターフェースに、利用者まで縛られる |
| 封じたインターフェース | 型スイッチの網羅性が、外部からの実装追加で崩れる |
| 型付き列挙 | 文字列定数のタイプミスに気づけず、不正な値が流れる |
| Option / Result 型 | 多段の変換で、エラー処理が本筋のコードを埋める |
とくに効くのは「単位を型にする」と「スマートコンストラクタ」の組み合わせだ。両方を使うと、不正な値がドメインの内側へ入る経路を塞げる。
1type Yen int64
2
3type Quantity struct{ n int }
4
5func NewQuantity(n int) (Quantity, error) {
6 if n <= 0 {
7 return Quantity{}, fmt.Errorf("quantity must be positive, got %d", n)
8 }
9 return Quantity{n: n}, nil
10}
11
12func Subtotal(unit Yen, q Quantity) Yen { return unit * Yen(q.n) }
Subtotal(120, 3) は型が合わずコンパイルできない。Quantity は非公開フィールドを持つため、検証を通らない値は外から作れない。残る穴はゼロ値だけなので、そこが問題になるなら Valid() を用意して境界で弾く。
型スイッチの分岐を安全にしたいなら、非公開メソッドでインターフェースを封じる。
1type Event interface {
2 isEvent() // 非公開メソッドなので、他パッケージからは実装できない
3}
4
5type Created struct{ ID string }
6
7func (Created) isEvent() {}
8
9type Deleted struct{ ID string }
10
11func (Deleted) isEvent() {}
実装を同一パッケージに閉じ込められるので、型スイッチで扱う型の集合が変わらないと保証できる。ただしGoのコンパイラは網羅性を検査しないため、default で「未知の型」を必ず落とす。
依存の組み立て
「何に依存するかを外から決める」領域だ。GoFの生成パターンが担っていた役割の大半は、いまここにある。
| パターン | 解決する課題 |
|---|---|
| コンストラクタ注入 | 依存をパッケージ変数から取ると、テストで差し替えられず並列実行もできない |
| DIのコード生成(wire) | main関数の組み立てが数百行になり、依存順序を人手で保てない |
| functional core, imperative shell | 計算とI/Oが混ざり、テストのたびにDBやネットワークが要る |
| ポートとアダプタ | ドメインのコードがDBやHTTPクライアントの都合に引きずられる |
| レジストリ登録 | 実装を追加するたび、中央の switch を編集する必要がある |
| 依存を関数で渡す | メソッド1つのインターフェースを、実装とモックの両方で書くのが重い |
| ライフサイクル管理 | 起動順と停止順を誤り、使用中の資源を先に閉じる |
Goで最初に選ぶのはコンストラクタ注入だ。DIコンテナは、組み立てが手に負えなくなってから検討すればよい。実行時にリフレクションで解決する方式より、wire のようにコードを生成する方式のほうが、誤りをコンパイル時に見つけられる分だけGoに合う。
依存が1メソッドで足りるなら、インターフェースではなく関数型で受けるほうが軽い。
1type Clock func() time.Time
2
3func NewService(store Store, now Clock) *Service {
4 if now == nil {
5 now = time.Now
6 }
7 return &Service{store: store, now: now}
8}
テストでは func() time.Time { return fixed } を渡すだけで時刻を固定できる。モックの生成もインターフェースの宣言も要らない。
エラーと失敗の扱い
Goには例外がないので、失敗の設計はそのままAPI設計になる。ここはGoFに対応物がほとんどない領域だ。
| パターン | 解決する課題 |
|---|---|
| エラーのラップ | エラーだけ返すと、どの処理で失敗したのかが分からない |
| 番兵エラー | 「見つからない」のような予期される分岐を、文字列比較で判定してしまう |
| 構造化エラー型 | 呼び出し側が失敗の詳細(不正なフィールド、HTTPステータス)を必要とする |
| errors.Join | 失敗を1つ見つけた時点で止めたくない場面がある |
| Must 系ヘルパ | 設定ミスを、実行中のリクエストで初めて発見する |
| パニックの境界 | 1つのハンドラのバグでプロセス全体が落ちる |
| errgroup | 並行処理の1つが失敗したとき、残りを止めて最初のエラーを返したい |
| リトライと指数バックオフ | 一時障害で失敗が確定し、即時リトライが下流をさらに詰まらせる |
エラーの表現は3つを使い分ける。文脈を足すラップ、分岐に使う番兵エラー、詳細を運ぶ構造化エラー型だ。
1var ErrNotFound = errors.New("not found") // 番兵エラー
2
3type ValidationError struct { // 構造化エラー
4 Field string
5 Reason string
6}
7
8func (e *ValidationError) Error() string {
9 return fmt.Sprintf("invalid %s: %s", e.Field, e.Reason)
10}
11
12func loadUser(id string) error {
13 if id == "" {
14 return &ValidationError{Field: "id", Reason: "empty"}
15 }
16 if id == "ghost" {
17 return fmt.Errorf("load user %q: %w", id, ErrNotFound) // ラップして文脈を足す
18 }
19 return nil
20}
呼び出し側は errors.Is(err, ErrNotFound) で分岐し、errors.As(err, &ve) で詳細を取り出す。バリデーションのように失敗を集めたい場面では、errors.Join(Go 1.20〜)でまとめて返す。
一時障害へのリトライは、指数バックオフとジッタをセットにする。ジッタがないと、障害から復帰した瞬間に全クライアントが同時に再送し、また倒れる。
1func RetryBackoff(ctx context.Context, attempts int, base time.Duration, do func(context.Context) error) error {
2 var err error
3 for i := range attempts {
4 if err = do(ctx); err == nil {
5 return nil
6 }
7 if i == attempts-1 {
8 break
9 }
10 wait := base << i // 指数的に伸ばす
11 wait += time.Duration(rand.Int64N(int64(wait / 2))) // ジッタで同期再送を散らす
12 select {
13 case <-ctx.Done():
14 return ctx.Err()
15 case <-time.After(wait):
16 }
17 }
18 return fmt.Errorf("after %d attempts: %w", attempts, err)
19}
リトライしてよいのは冪等な操作だけだ。全体の上限は試行回数ではなく context の期限で決めると、呼び出し側の待ち時間を守れる。
並行と資源の制御
Goでバグが出やすい領域であり、同時にGoの特徴が最も出る領域でもある。
| パターン | 解決する課題 |
|---|---|
| キャンセルの伝播 | 呼び出し側が諦めた後も処理が走り続け、ゴルーチンと接続が漏れる |
| 同時実行数の上限 | 入力の数だけゴルーチンを起こし、下流の接続数やメモリが際限なく増える |
| パイプライン | 多段の変換を1関数に書くと、段ごとの並列度を変えられない |
| 所有権を1つに | 共有状態にロックを足すほど、デッドロックと取り忘れの危険が増える |
| singleflight | キャッシュ切れの瞬間に同じキーへの要求が殺到し、下流を叩き潰す |
| レート制限 | 再試行やバッチが、下流のAPI制限やDBの上限を踏み抜く |
| バックプレッシャ | 生産者が消費者より速く、キューが無限に伸びる |
| 安全な停止 | デプロイのたびに処理中のリクエストが切断される |
| 一度だけの初期化 | 高価な初期化が並行に呼ばれ、二重に走る |
まず押さえるのは同時実行数の上限だ。errgroup に SetLimit を付けるだけで、入力数に関係なく並列度を固定できる。
1func FetchAll(ctx context.Context, ids []string, fetch func(context.Context, string) error) error {
2 g, ctx := errgroup.WithContext(ctx)
3 g.SetLimit(8) // 同時に走るのは8本まで
4 for _, id := range ids {
5 g.Go(func() error { return fetch(ctx, id) })
6 }
7 return g.Wait()
8}
上限値はCPU数ではなく、下流が受け付けられる本数から決める。I/O主体の処理でCPU数を根拠にすると、たいてい外す。
キャッシュが切れた瞬間の殺到(キャッシュスタンピード)には singleflight が効く。同じキーへの同時呼び出しを1回にまとめ、結果を全員へ配る。
1func (c *Cache) Get(ctx context.Context, key string) (string, error) {
2 c.mu.RLock()
3 v, ok := c.items[key]
4 c.mu.RUnlock()
5 if ok {
6 return v, nil
7 }
8 // 同じキーへの同時アクセスは1回の origin 呼び出しにまとめられる
9 got, err, _ := c.group.Do(key, func() (any, error) {
10 v, err := c.origin(ctx, key)
11 if err != nil {
12 return nil, err
13 }
14 c.mu.Lock()
15 if c.items == nil {
16 c.items = map[string]string{}
17 }
18 c.items[key] = v
19 c.mu.Unlock()
20 return v, nil
21 })
22 if err != nil {
23 return "", err
24 }
25 return got.(string), nil
26}
手元のテストでは、20本の同時アクセスに対して origin の呼び出しは1回になった。なお失敗も共有されるため、エラー時にキャッシュへ入れるかどうかは別途決める。
デプロイのたびに5xxを出さないためには、停止の設計も要る。
1func Serve(srv *http.Server) error {
2 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
3 defer stop()
4
5 errCh := make(chan error, 1)
6 go func() {
7 if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
8 errCh <- err
9 }
10 }()
11
12 select {
13 case err := <-errCh:
14 return err
15 case <-ctx.Done():
16 // 新規受付を止め、処理中のリクエストの終了を待つ
17 shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
18 defer cancel()
19 return srv.Shutdown(shutdownCtx)
20 }
21}
停止待ちにも上限を置く。無限に待つ停止は、運用で最も困る挙動になる。
データと整合性
永続化まわりは、GoFではなく Fowler の『PoEAA』以降の語彙が中心になる領域だ。
| パターン | 解決する課題 |
|---|---|
| リポジトリ | SQLがドメインのコードに散らばり、保存先の変更で全体が壊れる |
| WithTx | Begin と Commit が別の場所に散り、ロールバック漏れが起きる |
| 作業単位 | 複数リポジトリへの変更を、まとめてコミットしたい |
| 楽観ロック | 同じ行の同時更新で、後勝ちにより変更が黙って消える |
| キャッシュアサイド | 同じ問い合わせが繰り返され、下流の負荷とレイテンシが跳ねる |
| トランザクショナル・アウトボックス | DB更新とメッセージ送信を、両方成功させたい |
| 前方互換なスキーマ変更 | 改名や削除を一度に行い、旧バージョンのアプリが動かなくなる |
| 読み書きモデルの分離 | 画面の都合で結合が増え、書き込みモデルが歪む |
| イベントソーシング | 現在の状態になった理由を、後から説明できない |
トランザクション境界は関数で閉じるのが扱いやすい。開始と終了が1か所に集まり、ロールバック漏れが構造的に起きなくなる。
1func WithTx(ctx context.Context, db *sql.DB, fn func(context.Context) error) (err error) {
2 tx, err := db.BeginTx(ctx, nil)
3 if err != nil {
4 return fmt.Errorf("begin: %w", err)
5 }
6 defer func() {
7 if p := recover(); p != nil {
8 _ = tx.Rollback()
9 panic(p)
10 }
11 if err != nil {
12 _ = tx.Rollback()
13 return
14 }
15 err = tx.Commit()
16 }()
17 return fn(context.WithValue(ctx, txKey{}, tx))
18}
戻り値に名前を付けている点が肝で、defer の中から err を見てコミットとロールバックを切り替えている。パニックが起きた場合もロールバックしてから再送出する。
同時更新の衝突は、楽観ロックで検出する。
1res, err := db.ExecContext(ctx,
2 `UPDATE items SET name = ?, version = version + 1 WHERE id = ? AND version = ?`,
3 name, id, version)
4// ...
5if n, _ := res.RowsAffected(); n == 0 {
6 return ErrConflict // 誰かが先に更新している
7}
影響行数が0なら、読み込んでから更新するまでの間に誰かが書き換えている。悲観ロックと違って待たないため、衝突が稀な場合はこちらが有利だ。ただし衝突時の方針(自動で再試行するか、利用者へ返すか)は決めておく。
サービス間と信頼性
ネットワーク越しの呼び出しは「落ちる・遅い・二重に届く」が前提になる。GoFの23個には対応物がなく、Nygard の『Release It!』以降に整理されてきた領域だ。
| パターン | 解決する課題 |
|---|---|
| サーキットブレーカ | 落ちている下流を呼び続け、タイムアウト待ちで自分の資源が枯渇する |
| 隔壁 | 1つの遅い依存が共有プールを食い尽くし、無関係な機能まで止まる |
| デッドラインの伝播 | 各段が独自のタイムアウトを持ち、合計が利用者の待てる時間を超える |
| 冪等な受信 | 再送や重複配送で、同じ課金や同じ注文が二重に処理される |
| サーガ | サービスをまたぐ処理を、分散トランザクションなしで整合させたい |
| 段階的な機能低下 | 補助機能の障害で、主要機能まで落ちる |
| ヘルスチェック | 依存に繋がる前のインスタンスへ、トラフィックが流れる |
| フィーチャーフラグ | デプロイと機能公開が一体で、問題時にロールバックしか手がない |
サーキットブレーカは、外部ライブラリを入れる前に構造を理解しておくと選定が楽になる。最小構成は次の程度だ。
1func (b *Breaker) Do(fn func() error) error {
2 b.mu.Lock()
3 if time.Now().Before(b.openUntil) {
4 b.mu.Unlock()
5 return ErrOpen // 下流を呼ばずに即座に失敗させる
6 }
7 b.mu.Unlock()
8
9 err := fn()
10
11 b.mu.Lock()
12 defer b.mu.Unlock()
13 if err != nil {
14 b.failures++
15 if b.failures >= b.threshold {
16 b.openUntil = time.Now().Add(b.cooldown)
17 b.failures = 0
18 }
19 return err
20 }
21 b.failures = 0
22 return nil
23}
要点は「開いている間は下流を呼ばない」ことにある。呼ばないからこそ、タイムアウト待ちのゴルーチンが積み上がらない。実運用では、開いている間に何を返すか(キャッシュ、既定値、エラー)と、開閉の状態をメトリクスに出すことがセットで要る。
デッドラインは入口で1回決め、context で下流まで持ち回る。各段が独自にタイムアウトを設定すると、合計が利用者の待てる時間を簡単に超える。
入出力の境界
横断的な関心事を1か所に集める領域だ。ここはGoF由来のDecoratorがそのまま生きている。
| パターン | 解決する課題 |
|---|---|
| ミドルウェア | ログ、認証、計測、パニック回収を全ハンドラに書き足すことになる |
| 依存を閉じ込めたハンドラ | ハンドラがグローバル変数から依存を取り、テストで差し替えられない |
| RoundTripper のラップ | 外部API呼び出しの共通処理を、呼び出し箇所ごとに書いている |
| 型付きcontextキー | ctx.Value("user") はキーが衝突し、型も保証されない |
| 構造化ログ | 文字列を連結したログは、後から検索も集計もできない |
| 分散トレーシング | サービスをまたぐ遅延の原因が、ログの突き合わせなしに分からない |
| 入力検証を境界に集約 | 内部の各所に「本当に正しいか」を確認する防御的コードが増える |
ハンドラへの依存の渡し方は、クロージャで包む形が定番になっている。
1func handleGetUser(store Store) http.HandlerFunc {
2 return func(w http.ResponseWriter, r *http.Request) {
3 v, err := store.Get(r.PathValue("id"))
4 if err != nil {
5 http.Error(w, "not found", http.StatusNotFound)
6 return
7 }
8 fmt.Fprint(w, v)
9 }
10}
グローバル変数を使わずに依存を渡せるため、テストでは fake の Store を入れて httptest で叩ける。r.PathValue は Go 1.22 で入った標準のパスパラメータで、ルータを追加しなくてもこの形が書ける。
context に値を載せるときは、非公開の型をキーにする。
1type userIDKey struct{}
2
3func WithUserID(ctx context.Context, id string) context.Context {
4 return context.WithValue(ctx, userIDKey{}, id)
5}
6
7func UserIDFrom(ctx context.Context) (string, bool) {
8 id, ok := ctx.Value(userIDKey{}).(string)
9 return id, ok
10}
空構造体をキーにすると他パッケージと衝突せず、取り出しを専用関数に閉じ込めれば型も保証できる。ただし、必須の依存を context で渡すのは避ける。載せてよいのはリクエストに紐づく値(リクエストID、認証済みの利用者、トレース)に限る。
テスト
パターンとして語られることは少ないが、実務で最も反復して使う定石が集まる領域だ。
| パターン | 解決する課題 |
|---|---|
| テーブル駆動テスト | 似たテストが増殖し、条件を1つ足すたびに全部を直すことになる |
| ゴールデンテスト | 出力が大きい処理を、逐一アサートできない |
| 偽サーバ(httptest) | 外部API依存のテストが、ネットワークと相手の状態で不安定になる |
| 手書き fake | モックの期待値設定が実装の詳細に密着し、リファクタで壊れる |
| testcontainers | SQLの方言やトランザクション挙動は、モックでは検出できない |
| t.Helper / t.Cleanup / t.Parallel | 失敗行がヘルパを指し、後始末が漏れ、テストが遅い |
| ファジング | 人間が思いつく入力では、パーサや境界処理の穴が見つからない |
| ベンチマークとプロファイル | 勘で最適化し、実際のボトルネックを外す |
テーブル駆動テストは、標準ライブラリのテストの大半が採る形だ。
1func TestNewQuantity(t *testing.T) {
2 tests := []struct {
3 name string
4 in int
5 want int
6 wantErr bool
7 }{
8 {name: "正の数", in: 3, want: 3},
9 {name: "ゼロ", in: 0, wantErr: true},
10 {name: "負の数", in: -1, wantErr: true},
11 }
12 for _, tt := range tests {
13 t.Run(tt.name, func(t *testing.T) {
14 got, err := NewQuantity(tt.in)
15 if (err != nil) != tt.wantErr {
16 t.Fatalf("err = %v, wantErr = %v", err, tt.wantErr)
17 }
18 if err == nil && got.Int() != tt.want {
19 t.Errorf("got %d, want %d", got.Int(), tt.want)
20 }
21 })
22 }
23}
t.Run でサブテストにすると、失敗したケース名がそのまま出力に現れる。条件を足すときは表に1行足すだけで済む。
モックについては、Goでは手書きの fake を優先する場面が多い。インターフェースが小さければ、map を持つだけの実装で足りるからだ。呼び出し回数の検証が本当に必要かどうかは、書く前に一度考える価値がある。
使うときの注意点
- 構造をまねるのではなく、名前を借りる。GoFのクラス図をそのままGoへ写すと、たいてい行数だけ増えて読みにくくなる。インターフェースと関数値による実装を先に置き、「これは Decorator だ」と説明するほうが実用的だ
- パターンを先に決めない。重複が3回現れてから抽象化しても遅くない。Goの格言にある「少しのコピーは、少しの依存よりまし」は、この文脈で効く
- インターフェースは使う側で定義する。Goでは、実装のパッケージではなく利用側のパッケージに小さなインターフェースを置くのが慣習だ。これだけで Abstract Factory の多くは不要になる
- Singleton とグローバル状態は避ける。テストの並列実行が難しくなり、依存がシグネチャに現れなくなる
- 図と実装を混同しない。GoFの図は継承を前提に描かれている。Goに継承はないので、図の矢印をそのまま実装へ写すと不自然な構造になる
- 1つのパターンに複数の意図を持たせない。Proxy と Decorator のように、構造は同じで意図だけが違うものは、名前で意図を伝える価値が高い。逆に「便利だから」と両方の役割を1つの型に持たせると、名前の意味が消える
参考
- Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, “Design Patterns: Elements of Reusable Object-Oriented Software”, Addison-Wesley, 1994 — 23パターンの原典
- Christopher Alexander et al., “A Pattern Language: Towns, Buildings, Construction”, Oxford University Press, 1977 — パターンという記述形式の起源
- “Design Patterns 15 Years Later: An Interview with Erich Gamma, Richard Helm, and Ralph Johnson”, InformIT, 2009(https://www.informit.com/articles/article.aspx?p=1404056)— Singleton を落とす、Interpreter と Flyweight を別枠へ、といった見直しが語られている
- Peter Norvig, “Design Patterns in Dynamic Programming”, 1996(https://norvig.com/design-patterns/)— 23個中16個は動的言語では見えなくなるか単純になる、という指摘
- Frank Buschmann et al., “Pattern-Oriented Software Architecture Volume 1”, Wiley, 1996
- Martin Fowler, “Patterns of Enterprise Application Architecture”, Addison-Wesley, 2002
- Eric Evans, “Domain-Driven Design”, Addison-Wesley, 2003
- Michael Nygard, “Release It!”, Pragmatic Bookshelf, 2007 — Circuit Breaker などの安定性パターン
- The Go Blog, “Range Over Function Types”(https://go.dev/blog/range-functions)— Go 1.23 の反復子の設計と
yieldの戻り値の意味 - The Go Blog, “New unique package”(https://go.dev/blog/unique)— Go 1.23 の値の正規化
- Rob Pike, “Lexical Scanning in Go”, GTUG Sydney, 2011 —
stateFnによる状態機械。実装は標準ライブラリのtext/template/parse/lex.goにある - Go公式ドキュメント
iterパッケージ(https://pkg.go.dev/iter)、sync.OnceValue(https://pkg.go.dev/sync#OnceValue)、unique(https://pkg.go.dev/unique) - 過去記事: Goで関数型プログラミングをする — 型で不正な状態を表現不能にする話。本記事の設計方針と地続きになっている