makoto-developer's テックブログ

連載「Goで書く実践圏論」 4/4

圏論でワークフローエンジンを組み直したら、追加コストは1ステップ+30nsだった —— Goで書く実践圏論(4)

連載最終回。ここまでの3回で道具は揃いました。

  • 第1回 圏・合成・法則、そして純粋性が守っているもの
  • 第2回 標準ライブラリの Functor・Monoid・Kleisli 合成・自然変換
  • 第3回 Applicative、F代数、CPS、そして Go の型システムの天井

今回はこれを実際のコードに使います。題材は注文処理のワークフロー(在庫確保 → 決済 → 配送手配 → 通知)。同じものを素直な手続き型射の合成の2通りで書いて、比べます。

そして、この連載でいちばん書きたかったことを最後に書きます。どこで壊れたかです。抽象化の代金は性能だけではありません。型で保証できなくなったもの、読めなくなったもの、レビューで説明できなくなったもの。得だけを並べた技術記事は信用できないと思っているので、損のほうも同じ強さで書きます。

先に結論を出します。

観点結果
実行速度(同じ機能)224 ns → 345 ns。1ステップあたり 30 ns、アロケーションはゼロ
行数素直な実装は 1ステップ 18行、合成版は 11行。9ステップ目から得
同じ定義から導けたもの実行 / dry-run / Mermaid図 / 設定一覧 の 4つ
壊れたところ型で進捗を保証することを諦めた。スタックトレースが深くなり、ステップ名が出ない。デコレータの順序が暗黙
総合ワークフローが複数あり、横断的関心事を一律に掛けたいなら得。1本だけなら損

コードは part4/ にあります。

インストールもログインも無しで、いますぐ手を動かしたいという方へ。下のサンドボックスは、この記事の到達点を1ファイルに縮めたものです。8行の Plan 定義から、実行・dry-run・Mermaid 図が導けること(dry-run では外部サービスは一度も呼ばれません)、そしてデコレータを巻く順序が結果を決めることが、そのまま流れます。最後に出てくる図は、その8行から生成したものです。

このサンドボックスが動かしている main.go の正本は、リポジトリの examples/workflow/main.go です。起動時に GitHub から取ってくる作りにしてあるので、リポジトリを直せばサンドボックスの中身も最新になります。


1. 題材 —— 4ステップの注文処理

1.1 やること

flowchart LR
    A[注文] -->|在庫確保| B[予約済み]
    B -->|決済| C[支払い済み]
    C -->|配送手配| D[発送済み]
    D -->|通知| E[完了]

各ステップには要求があります。

  • リトライ: 一時的な失敗(ネットワークエラー)は再試行します。恒久的な失敗(在庫切れ、カード拒否)は再試行しない
  • 計測: 各ステップの所要時間を記録する
  • タイムアウト: ステップごとに制限時間を設ける
  • キャンセル: context がキャンセルされたら止める
  • エラーの文脈: どのステップで失敗したかを残す

実務のワークフローなら、まず全部要ります。

1.2 ドメイン

// Order は処理対象の注文。金額は最小単位の整数で持つ(float64 は結合的でないため)。
type Order struct {
	ID     string
	UserID string
	Amount int
}

// State はワークフローの途中経過。各ステップはこれを受け取って更新した State を返す。
type State struct {
	Order         Order
	ReservationID string
	PaymentID     string
	TrackingNo    string
	Notified      bool
}

金額を int にしたのは第2回の結論から。float64 の加算は結合的でないので、集計や分割で数字が変わりえます。

そして State の設計が、この記事で最初にして最大の妥協です。あとで詳しく書きます。


2. 素直に書く

まず、Go らしく素直に書きます。

// Run は在庫確保 → 決済 → 配送手配 → 通知を順に実行する。
// リトライ・計測・キャンセル確認が各ステップに散らばるのが、この書き方の特徴。
func (w NaiveWorkflow) Run(ctx context.Context, s State) (State, error) {
	if err := ctx.Err(); err != nil {
		return s, err
	}

	start := time.Now()
	var reservationID string
	var err error
	for i := range w.Attempts {
		reservationID, err = w.Deps.Inventory.Reserve(ctx, s.Order)
		if err == nil {
			break
		}
		if !errors.Is(err, ErrTemporary) || i == w.Attempts-1 {
			w.Recorder.Record("reserve", time.Since(start))
			return s, fmt.Errorf("在庫確保: %w", err)
		}
	}
	w.Recorder.Record("reserve", time.Since(start))
	s.ReservationID = reservationID

	// ... 決済・配送手配・通知も、まったく同じ形が3回繰り返される
}

このコードは悪くない。強調しておきたい。Go を書く人なら誰でも読めるし、デバッガで追えます。スタックトレースも素直です。

問題は1つしかありません。同じ形が4回書かれている

  • ctx.Err() の確認: 4回
  • time.Now()Recorder.Record: 4回(しかも成功パスと失敗パスの両方に書くので実質8か所)
  • リトライのループと「一時的か恒久的か」の判定: 4回
  • fmt.Errorf でのステップ名付与: 4回

関数全体で 71行(コメントと空行を除く)。1ステップあたり約18行。うち業務ロジックは「サービスを呼んで結果を State に入れる」の2行だけになります。残りの16行は横断的関心事です。

ステップが8個になれば142行になります。そして「計測を OpenTelemetry に変えたい」と言われたら、8か所直します。1か所忘れると、そのステップだけ計測が漏れます。テストでは気づけません。


3. 射として組む

3.1 全ステップを同じ型にする

第2回では func(A) (B, error) の合成(Kleisli 合成)を見ました。今回はすべてのステップを State → State にする

これは第3回 4.4 で残した宿題への回答でもあります。あそこでは「メソッドに型パラメータを付けられないので、合成がメソッドチェーンにならず関数のネストになる。3段でもう厳しい」という話をしました。型を変えないと決めてしまえば、ネストではなくリストで書けます。 読み順は上から下に戻ります。代わりに何を失うかは、6.1 で正面から書きます。

// Endo は State から State への射。すべてのステップがこの形をしているので、
// 全体が自己射のモノイドになり、リストに並べて畳み込める。
type Endo func(context.Context, State) (State, error)

// Identity は何もしない射。空のワークフローがこれになる。
func Identity(_ context.Context, s State) (State, error) { return s, nil }

// Then は射を合成する。キャンセルの確認は合成器の側に1か所書くだけで、全段に効く。
func Then(f, g Endo) Endo {
	return func(ctx context.Context, s State) (State, error) {
		if err := ctx.Err(); err != nil {
			return s, err
		}
		next, err := f(ctx, s)
		if err != nil {
			return next, err
		}
		return g(ctx, next)
	}
}

// Sequence は射の列を1本にまとめる。空なら Identity になる。
func Sequence(steps ...Endo) Endo {
	out := Endo(Identity)
	for _, step := range steps {
		out = Then(out, step)
	}
	return out
}

図にすると、こうなっています。

flowchart LR
    S0[State] -->|Reserve| S1[State]
    S1 -->|Charge| S2[State]
    S0 -.->|"Then(Reserve, Charge)"| S2

実線が個々のステップ、点線が合成した射です。合成しても行き先の型は State のままなので、Then の結果をさらに Then に渡せます。この「型が閉じている」ことが、あとでステップをリストに並べられる理由です。

Endo(endomorphism、自己射)と名付けたのは、始点と終点が同じ対象だからです。第3回の最後で見たとおり、自己射の集まりは合成に関してモノイドをなす。middleware も Functional Option も同じ構造でした。今回もそれに乗ります。

乗ると何が手に入るか。Sequence がリストを受け取れます。ステップを値として並べられるようになります。ここが後で効いてきます。

3.2 業務ロジックから横断的関心事を追い出す

func Reserve(d Deps) Endo {
	return func(ctx context.Context, s State) (State, error) {
		id, err := d.Inventory.Reserve(ctx, s.Order)
		if err != nil {
			return s, err
		}
		s.ReservationID = id
		return s, nil
	}
}

リトライも計測もタイムアウトもキャンセル確認もありません。サービスを呼んで、結果を State に入れるだけ。4ステップ合わせて 39行、1ステップ約10行。

3.3 横断的関心事は「射を包む変換」にする

// --- 射を包む変換(デコレータ) ---
// どれも Endo → Endo なので、対象を変えずに射だけを取り替えている。

// WithRetry は一時的な失敗のときだけ呼び直す。
func WithRetry(s Endo, attempts int) Endo {
	return func(ctx context.Context, st State) (State, error) {
		var err error
		var next State
		for range attempts {
			next, err = s(ctx, st)
			if err == nil || !errors.Is(err, ErrTemporary) {
				return next, err
			}
		}
		return next, err
	}
}

// WithTiming は所要時間を記録する。
func WithTiming(s Endo, name string, rec *Recorder) Endo {
	return func(ctx context.Context, st State) (State, error) {
		start := time.Now()
		next, err := s(ctx, st)
		rec.Record(name, time.Since(start))
		return next, err
	}
}

// WithTimeout は制限時間を付ける。
func WithTimeout(s Endo, d time.Duration) Endo {
	return func(ctx context.Context, st State) (State, error) {
		ctx, cancel := context.WithTimeout(ctx, d)
		defer cancel()
		return s(ctx, st)
	}
}

// WithLabel は失敗したときにどのステップかを添える。
func WithLabel(s Endo, name string) Endo {
	return func(ctx context.Context, st State) (State, error) {
		next, err := s(ctx, st)
		if err != nil {
			return next, fmt.Errorf("%s: %w", name, err)
		}
		return next, nil
	}
}

4つとも Endo → Endo の形をしています。対象(State)はそのままに、射だけを取り替えている。第2回で見た middleware(http.Handler → http.Handler)とまったく同じ構造です。

包む前と包んだあとを並べると、変わっているのが射だけだとわかります。

flowchart LR
    R["Reserve<br/>State → State"] -->|WithRetry| R2["WithRetry(Reserve, 3)<br/>State → State"]

出ていく先も State → State です。デコレータは対象を動かさず、射を射に写している。だから包んだ結果をさらに別のデコレータへ渡せますし、Then で合成することもできます。

リトライのロジックは1か所にしか書かれていない。計測も1か所。OpenTelemetry に移行するなら WithTiming だけ直します。

そしてこれらは一様です。WithRetry は包む射の中身を一切見ていません。だから幅広いステップに同じ形で付けられます。包む射の中身に依存するデコレータを書いた瞬間に、適用できる範囲は狭くなります。

ただし、これは「巻く順序を入れ替えてよい」という意味ではありません。デコレータの合成も非可換で、順序には意味があります。6.2 でその実例を書きます。

この「中身に依存せず一様に振る舞う」性質は自然変換を連想させますが、ここで定義しているデコレータをそのまま自然変換と呼べるわけではありません。自然変換は関手どうしの間の構造であって、Endo → Endo という型を持つだけでは条件を満たしません。連想が働く、という以上のことは主張しないでおきます。


4. ワークフローを「値」にする

ここからが本題です。

4.1 実行方法ではなく、構造を書く

デコレータを手で巻くこともできます。

step := WithTiming(WithRetry(WithLabel(Reserve(deps), "在庫確保"), 3), "在庫確保", rec)

読めない。それに、これでは「何をどうするか」がコードの実行順に埋まってしまいます。

そうではなく、ワークフローの定義を値として持つ

// Node はワークフローの1ステップの「定義」。実行の仕方ではなく、何をどう実行するかを値で持つ。
// 値なので、実行する以外の解釈(図を描く、設定を並べる)も同じ定義から導ける。
type Node struct {
	Name     string
	Retries  int
	Timeout  time.Duration
	Run      Endo
	Parallel []Node // 空でなければ、これらを並列に実行して失敗を集める
}

// Plan はワークフローそのもの。実行はしない。
type Plan struct {
	Name  string
	Nodes []Node
}

そして定義はこう書けます。

// OrderPlan は注文処理の定義。実行方法ではなく構造だけを書いている。
func OrderPlan(d Deps) Plan {
	return Plan{
		Name: "注文処理",
		Nodes: []Node{
			{Name: "在庫確保", Retries: 3, Timeout: 2 * time.Second, Run: Reserve(d)},
			{Name: "決済", Retries: 1, Timeout: 5 * time.Second, Run: Charge(d)},
			{Name: "配送手配", Retries: 3, Timeout: 2 * time.Second, Run: Arrange(d)},
			{Name: "通知", Retries: 3, Run: Notify(d)},
		},
	}
}

8行。これがワークフローの全体像です。決済だけリトライしない(二重課金を避けるため)ことも、通知にはタイムアウトが無いことも、一目でわかります。

この Plan から先が、この回のいちばん美味しいところです。同じひとつの値に、何通りもの解釈を与えられます

flowchart LR
    P["Plan<br/>(注文処理の定義・8行)"]
    P -->|Compile| E["Endo<br/>実行する"]
    P -->|DryRun| D["[]string<br/>手順を出す"]
    P -->|Mermaid| M["string<br/>図を描く"]
    P -->|Explain| X["string<br/>設定を一覧する"]

矢印はどれも Plan を出発点にしています。定義は1か所にしかなく、解釈だけが増えていく。この後の 4.2 から 4.5 で、この4本の矢印を順に実装します。

第3回でやった F代数と同じ発想です。あのときは式木を Algebra で解釈しました。今回はワークフローを Plan として持ち、複数の解釈を与える。「プログラムを値として表し、あとから意味を与える」という形は、埋め込み DSL やインタプリタパターンと呼ばれるものです。自由モナドにも通じる発想ですが、この Plan を自由モナドそのものとは呼べません。ノードが実行可能な EndoRun に直接抱えていて、命令を純粋なデータとして持っていないからです。それに加えて、自由モナドに必要な部品 —— 命令を表す関手、Pure、継続を伴う Bind —— のどれも定義していません。ここにあるのは、固定した Node の列を複数の方法で解釈する埋め込み DSL です。それで十分に元は取れています。

4.2 解釈1 —— 実行する

// Compile は Plan を1本の射に変える。ここでリトライ・計測・タイムアウトが巻かれる。
func Compile(p Plan, rec *Recorder) Endo {
	steps := make([]Endo, 0, len(p.Nodes))
	for _, n := range p.Nodes {
		steps = append(steps, compileNode(n, rec))
	}
	return Sequence(steps...)
}

func compileNode(n Node, rec *Recorder) Endo {
	step := n.Run
	if len(n.Parallel) > 0 {
		step = parallelStep(n.Parallel, rec)
	}

	step = WithLabel(step, n.Name)
	if n.Retries > 1 {
		step = WithRetry(step, n.Retries)
	}
	if n.Timeout > 0 {
		step = WithTimeout(step, n.Timeout)
	}
	return WithTiming(step, n.Name, rec)
}

巻かれた結果を外側から順に並べると、こうなっています。

flowchart TB
    t[WithTiming] --> to[WithTimeout] --> r[WithRetry] --> l[WithLabel] --> run[n.Run]
    run -.->|結果は内から外へ| t

呼び出しは外から内へ降りていき、結果は内から外へ戻ります。どの層も Endo のままなので、この積み方はいくらでも組み替えられます。組み替えれば動きも変わる。

デコレータを巻く順序が compileNode の1か所に集約されています。この順序には意味がある(後述の「壊れたところ」で書く)。

4.3 解釈2 —— 実行せずに手順を出す(dry-run)

// DryRun は外部サービスを一切呼ばずに、実行される順序を返す。
func DryRun(p Plan) []string {
	var out []string
	for _, n := range p.Nodes {
		if len(n.Parallel) > 0 {
			names := make([]string, len(n.Parallel))
			for i, c := range n.Parallel {
				names[i] = c.Name
			}
			out = append(out, n.Name+"(並列: "+strings.Join(names, ", ")+")")
			continue
		}
		out = append(out, n.Name)
	}
	return out
}

副作用がゼロであることは、テストで保証します。

// dry-run は外部サービスを一切呼ばない。同じ定義から手順だけを取り出せる。
func TestDryRunTouchesNothing(t *testing.T) {
	deps, inv, pay, ship, notif := newDeps()

	steps := DryRun(OrderPlan(deps))

	want := []string{"在庫確保", "決済", "配送手配", "通知"}
	if !slices.Equal(steps, want) {
		t.Fatalf("手順が違う: got=%v, want=%v", steps, want)
	}
	if inv.calls+pay.calls+ship.calls+notif.calls != 0 {
		t.Fatal("dry-run で外部サービスが呼ばれた")
	}
}

素直な実装で dry-run を足そうとすると、Run の中に if dryRun を4か所書くことになります。そして本番のコードに if dryRun が混ざります。ここでは実行と定義が分離しているので、そもそも混ざりようがない

4.4 解釈3 —— 図を描く

// Mermaid は同じ定義から Mermaid のフローチャートを生成する。
// ドキュメントとコードがずれないのは、両方が同じ Plan から導かれるため。
func Mermaid(p Plan) string {
	var sb strings.Builder
	sb.WriteString("flowchart TD\n")
	sb.WriteString("    start([" + p.Name + "])\n")

	prev := "start"
	for i, n := range p.Nodes {
		id := fmt.Sprintf("n%d", i)
		// ... 並列ノードは subgraph にする
		sb.WriteString("    " + id + "[" + label(n) + "]\n")
		sb.WriteString("    " + prev + " --> " + id + "\n")
		prev = id
	}

	sb.WriteString("    " + prev + " --> done([完了])\n")
	return sb.String()
}

実際に出力されたもの。

flowchart TD
    start([注文処理])
    n0[在庫確保<br/>最大3回<br/>2s]
    start --> n0
    n1[決済<br/>5s]
    n0 --> n1
    n2[配送手配<br/>最大3回<br/>2s]
    n1 --> n2
    n3[通知<br/>最大3回]
    n2 --> n3
    n3 --> done([完了])

そのままレンダリングすると、こうなります。

flowchart TD
    start([注文処理])
    n0[在庫確保<br/>最大3回<br/>2s]
    start --> n0
    n1[決済<br/>5s]
    n0 --> n1
    n2[配送手配<br/>最大3回<br/>2s]
    n1 --> n2
    n3[通知<br/>最大3回]
    n2 --> n3
    n3 --> done([完了])

この図はコードから自動生成されている。ステップを足せば図に出ます。リトライ回数を変えれば図の数字が変わります。設計ドキュメントの図が実装とずれる、という問題が構造的に起きません。

生成コストは 750 ns。デバッグ用のエンドポイントでその場で生成しても何の問題もありません。

4.5 解釈4 —— 設定を一覧する

運用中にいちばん聞かれる質問は「このステップ、何回リトライするんだっけ」です。

// Explain は運用時に「どのステップが何回リトライするのか」を答えるための一覧を返す。
func Explain(p Plan) string {
	// ... Plan を舐めて設定を並べるだけ
}
注文処理
  - 在庫確保: 最大3回 / 2s
  - 決済: リトライなし / 5s
  - 配送手配: 最大3回 / 2s
  - 通知: 最大3回 / 制限なし

素直な実装では、この答えはコードを読まないと出てきません。しかも読む場所が4か所に散っています。

4.6 並列ステップ —— 第3回の Applicative が効く

通知を複数チャネル(メール・プッシュ・SMS)に送る場合を考えます。3つは独立しているので、並列に走らせて失敗を全部集めたい。第3回で見た Applicative 的な組み方が、そのまま当てはまります。

// parallelStep は独立したステップを同時に走らせ、失敗を全部集める。
// 第3回の Applicative と同じ形。State は変更せず、副作用だけを持つステップに使う。
func parallelStep(nodes []Node, rec *Recorder) Endo {
	return func(ctx context.Context, s State) (State, error) {
		errs := make([]error, len(nodes))
		var wg sync.WaitGroup

		for i, n := range nodes {
			wg.Add(1)
			go func(i int, n Node) {
				defer wg.Done()
				_, errs[i] = compileNode(n, rec)(ctx, s)
			}(i, n)
		}
		wg.Wait()

		return s, errors.Join(errs...)
	}
}

errors.Join がモノイドである(第2回 2.2)ことがそのまま効いています。ここで使っているのは、第2回で限定したとおりの性質 —— 失敗ゼロ件なら nil が返ることと、集めた失敗を errors.Is で1件ずつ取り出せることです。だから成功判定に特別扱いが要りません。

// 並列ノードは独立したステップを同時に走らせ、失敗を全部集める(Applicative)。
func TestParallelNodeCollectsAllFailures(t *testing.T) {
	failA := errors.New("メール送信に失敗")
	failB := errors.New("プッシュ通知に失敗")

	plan := Plan{
		Name: "通知だけ",
		Nodes: []Node{{
			Name: "通知",
			Parallel: []Node{
				{Name: "メール", Run: failing(failA)},
				{Name: "プッシュ", Run: failing(failB)},
				{Name: "SMS", Run: Identity},
			},
		}},
	}

	_, err := Compile(plan, NewRecorder())(context.Background(), State{Order: sampleOrder})

	if !errors.Is(err, failA) || !errors.Is(err, failB) {
		t.Fatalf("両方の失敗が集まっていない: %v", err)
	}
}

「メールだけ失敗した」のか「全部失敗した」のかが区別できます。片方で打ち切っていたら、この情報は失われていました。


5. 測る

5.1 実行コスト

// naive にはステップ単位のタイムアウトが無いので、条件を揃えた版も測る。
b.Run("composed_no_timeout", func(b *testing.B) {
	deps, _, _, _, _ := newDeps()
	plan := OrderPlan(deps)
	for i := range plan.Nodes {
		plan.Nodes[i].Timeout = 0
	}
	compiled := Compile(plan, NewRecorder())
	for b.Loop() {
		sinkState, _ = compiled(ctx, in)
	}
})
実装機能ns/opB/opallocs/op
素直な手続き型リトライ + 計測 + ステップ名22400
射の合成リトライ + 計測 + ステップ名34500
射の合成上記 + ステップ単位タイムアウト86881612
射の合成(デコレータ無し)なし66.500

ステップ名の付与は両方に入っています(素直な実装は fmt.Errorf("在庫確保: %w", err)、合成版は WithLabel)。どちらも失敗したときにしか文字列を組み立てないので、成功パスを測っているこのベンチには実質的に乗りません。

最初に測ったときは 224 ns 対 868 ns で「4倍遅い」という結果になりました。ですがこれはフェアな比較ではありませんでした。素直な実装にはステップ単位のタイムアウトがありません。合成版だけが多くの仕事をしていました。

条件を揃えると 224 ns → 345 ns、1.54倍。4ステップなので 1ステップあたり約 30 ns、しかもアロケーションはゼロです。第1回で測った「合成1段あたり約1 ns」に、デコレータ3層分(WithTimingWithRetryWithLabel)が乗った形になります。

タイムアウト版が遅い原因もはっきりしました。context.WithTimeout1回あたり約 174 ns / 272 B / 4 allocs かかり、それが3ステップ分。これは合成の代金ではなく、機能追加の代金です。素直な実装で同じ機能を足せば、同じだけかかります。

そして最後の行。デコレータを付けない素の合成は 66.5 ns で、素直な実装(224 ns)より速い。素直な実装が各ステップで Recorder(mutex + map)を叩いているためです。抽象化のコストより、計測のコストのほうが大きかった

実務的な結論は明快です。外部サービスの呼び出しはミリ秒単位なので、345 ns も 868 ns も全体の 0.1% 未満。ワークフローエンジンの設計で、合成のコストを気にする理由はありません。

5.2 行数

内訳行数
素直な実装の Run 関数(4ステップ)71
合成版の業務ステップ4つ39
合成版の Plan 定義8
合成器とデコレータ一式57
  • 素直な実装は 1ステップあたり約 18行
  • 合成版は 1ステップあたり約 11行(業務ロジック10行 + Plan の1行)
  • 合成器の 57行が初期投資

差は 1ステップあたり 7行。初期投資 57行を回収するには 9ステップ必要になります(8ステップだと 行しか浮かず、57行にあと1行届きません)。4ステップの今回は、行数ではまだ損している(47 + 57 = 104行 対 71行)。

ただし合成器はワークフローをまたいで共有できます。2本目のワークフローからは初期投資ゼロで書き始められるので、実際の回収はずっと早くなります。

5.3 行数に表れないもの

行数の比較には、次が入っていません。

  • dry-run を同じ Plan から低コストで導出できる(素直な実装なら if dryRun を4か所)
  • 図の生成が同じ Plan から導出できる(素直な実装なら図は手で描きます。そしてずれる)
  • 設定一覧も同じ Plan から導出できる
  • リトライ方針の変更が Plan の1行(素直な実装なら該当ステップのループを直す)
  • 計測基盤の差し替えが1か所(素直な実装なら8か所)
// 定義を書き換えるだけでリトライ方針を変えられる。業務ロジックには触らない。
func TestPolicyChangeDoesNotTouchBusinessLogic(t *testing.T) {
	deps, inv, _, _, _ := newDeps()
	inv.tempFailures = 4

	plan := OrderPlan(deps)
	plan.Nodes[0].Retries = 5

	if _, err := Compile(plan, NewRecorder())(context.Background(), State{Order: sampleOrder}); err != nil {
		t.Fatalf("リトライ回数を増やしても回復しなかった: %v", err)
	}
	if inv.calls != 5 {
		t.Fatalf("リトライ回数が反映されていない: calls=%d, want=5", inv.calls)
	}
}

運用中の設定変更が、業務ロジックに一切触れずにできる。これは行数では測れないが、実務では最も価値がある部分だと思います。


6. 壊れたところ

ここからが本題です。良かったことだけ書くのはフェアではありません。

6.1 型で進捗を保証することを諦めた

いちばん大きな妥協はこれです。

第2回で書いた Kleisli 合成なら、ステップごとに型を変えられました。

// 型が進捗を表現している
func Reserve(Order) (Reserved, error)
func Charge(Reserved) (Paid, error)
func Arrange(Paid) (Shipped, error)

この書き方には強い保証があります。決済前に配送手配を呼ぶコードはコンパイルできないArrangePaid を要求するので、Paid を作る唯一の方法である Charge を通らなければ辿り着けません。型が「正しい順序」を強制しています。

でも今回は、全ステップを State → State にしました。

type State struct {
	Order         Order
	ReservationID string
	PaymentID     string
	TrackingNo    string
	Notified      bool
}

すると、こう書けてしまいます。

// コンパイルは通る。実行時に PaymentID が空のまま配送手配が走る
Sequence(Reserve(d), Arrange(d), Charge(d))

順序を間違えてもコンパイラは何も言いません。型で守られていた不変条件が、実行時のバグに降格した

なぜ諦めたのか。型が変わる射はリストに入らないからです。

// これは書けない。要素の型がバラバラなので
steps := []Step{Reserve, Charge, Arrange}  // Step[Order,Reserved] と Step[Reserved,Paid] は別の型

Go の型システムでは、Step[A,B]Step[B,C] を同じスライスに入れられません。異種リスト(heterogeneous list)が要ります。Haskell なら型レベルリストと GADT で書けますが、Go では書けません。

そしてリストに入らなければ Plan が作れないPlan が作れなければ、dry-run も図の生成も設定一覧もできません。

つまりこうです。

型による順序の保証と、ワークフローを値として扱うことは、Go では両立しません。

これは Go の型システムの制約が直接生んだトレードオフです。第3回で見た高階カインドの不在と根は同じで、「型を抽象化する能力の不足」がここに出ています。

どちらを取るべきかは、ワークフローの性質によります。

  • ステップが固定で、順序を間違えたら致命的(決済、医療、法令対応)→ 型を取る。dry-run は諦める
  • ステップが設定で変わる、種類が多い、運用中に組み替える → 値を取る。順序はテストで守る

今回は後者を選びました。そして選んだ以上、順序の正しさをテストで担保する責任が生じます。型が守ってくれていたものを、人間が守ることになります。これは明確な損失です。

6.2 デコレータの順序が暗黙になった

	step = WithLabel(step, n.Name)
	if n.Retries > 1 {
		step = WithRetry(step, n.Retries)
	}
	if n.Timeout > 0 {
		step = WithTimeout(step, n.Timeout)
	}
	return WithTiming(step, n.Name, rec)

この4行の順序には意味があります。

  • WithRetryWithLabel外側にある → リトライのたびにラベルが付き直します。逆なら「在庫確保: 在庫確保: ...」と入れ子になる
  • WithTimeoutWithRetry外側にある → タイムアウトは全リトライの合計に効きます。逆なら1回ごとに制限時間がリセットされる
  • WithTiming が最も外側 → 計測にはリトライの時間が含まれる

これは重大な設計判断だが、コードには順序としてしか現れていない。コメントを書かなければ、次に読む人(3か月後の自分を含む)は気づきません。

素直な実装なら、リトライループの中と外にコードを書くので、順序が目に見えていました。ここでは見えなくなりました。

第2回で「結合律はあるが可換律はない」と書きました。デコレータの合成もまさにそれで、括り方は自由だが順序は自由でない。そして順序が意味を持つのに見えにくい、という状態はバグの温床です。

対策としてテストは書いた(タイムアウトが全リトライに効くこと)。だが、テストは「今こうなっている」ことしか言いません。「なぜこの順序なのか」は書けません。ここはコメントで補うしかなかった。

6.3 スタックトレースが読めない —— 予想と少し違った

ここは書きながら予想を外した箇所なので、実際に測った結果を書きます。

同じパニック(在庫サービスが panic する)を両方の実装で起こして、スタックトレースを取るテストを書きました。

// 同じパニックが、2つの実装でスタックトレースにどう出るかを比べる。
// 記事に載せた出力はこのテストで実際に取得したもの。
func TestPanicTraceShape(t *testing.T) {
	naiveTrace := captureTrace(func() {
		w := NaiveWorkflow{
			Deps:     Deps{Inventory: panickingInventory{}},
			Recorder: NewRecorder(),
			Attempts: 3,
		}
		_, _ = w.Run(context.Background(), State{Order: sampleOrder})
	})

	plan := Plan{
		Name:  "落ちるワークフロー",
		Nodes: []Node{{Name: "在庫確保", Retries: 3, Timeout: time.Second, Run: panicking()}},
	}
	composedTrace := captureTrace(func() {
		_, _ = Compile(plan, NewRecorder())(context.Background(), State{Order: sampleOrder})
	})

	// 合成版のほうがフレームが深くなる。これが読みにくさの主因。
	if part4FrameCount(composedTrace) <= part4FrameCount(naiveTrace) {
		t.Fatalf("合成版のフレームが増えていない: naive=%d, composed=%d",
			part4FrameCount(naiveTrace), part4FrameCount(composedTrace))
	}
}

素直な実装(テストの足回りを除いた実質部分)

part4.panickingInventory.Reserve(...)
    trace_test.go:32
part4.NaiveWorkflow.Run({...}, ...)
    naive.go:28

2フレーム。naive.go:28 を開けば、そこが在庫確保のブロックだとすぐわかります。

合成版

part4.TestPanicTraceShape.panicking.func3({0x0?, 0x0?}, {{{0x1005d9e04, 0x5}, ...}, ...})
    trace_test.go:25
part4.compileNode.WithLabel.func2({0x10065da50?, ...}, {{{0x1005d9e04, 0x5}, ...}, ...})
    workflow.go:101
part4.compileNode.WithRetry.func3({0x10065da50, ...}, {{{0x1005d9e04, 0x5}, ...}, ...})
    workflow.go:70
part4.compileNode.WithTimeout.func4({0x10065d938?, ...}, {{{0x1005d9e04, 0x5}, ...}, ...})
    workflow.go:94
part4.compileNode.WithTiming.func5({0x10065d938, ...}, {{{0x1005d9e04, 0x5}, ...}, ...})
    workflow.go:83
part4.Compile.Sequence.Then.func1({0x10065d938, ...}, {{{0x1005d9e04, 0x5}, ...}, ...})
    workflow.go:48

予想は半分外れていました。

書く前は「func1 が並んで何もわからない」と思っていました。実際は違った。Go のスタックトレースはインライン化された呼び出し元の関数名を連結して表示するので、compileNode.WithRetry.func3 のようにデコレータの名前がちゃんと読めます。どの順序で巻かれているかまで一目でわかる。予想よりずっとマシでした。

ただ、問題は別のところにありました。

1. ステップ名が出ない。 トレースのどこにも「在庫確保」という文字列がありません。Node.Name はデータであって関数名ではないからです。ステップが10個ある本番のワークフローで、どのステップで落ちたのかはトレースからは分かりません。素直な実装なら行番号でわかったことが、わからなくなりました。

2. フレームが 2 から 6 に増えました。 デコレータ1層につき1フレーム。5層巻けば5フレーム深くなります。本当に見たい行は、いつも一番上ではなく数フレーム下にあります。

3. 1行が長い。State の中身が16進数で展開されるので、1フレームが横に長くなります。上に載せたものは ... で省略してあるが、実際の出力は1行が200文字を超えます。ターミナルでは折り返して読みにくい。

緩和策として WithLabel を入れてあります。エラーメッセージには「在庫確保: 在庫が足りない」とステップ名が残ります。

// 失敗したステップの名前がエラーに残る。
func TestErrorCarriesStepName(t *testing.T) {
	deps, _, _, ship, _ := newDeps()
	ship.permanent = errors.New("配送業者がダウン")

	_, err := Compile(OrderPlan(deps), NewRecorder())(context.Background(), State{Order: sampleOrder})

	if err == nil || !strings.Contains(err.Error(), "配送手配") {
		t.Fatalf("ステップ名が含まれていない: %v", err)
	}
}

ただしこれ、エラーを返す場合にしか効かない。panic には効きません。nil ポインタ参照で落ちたときは、やはりトレースを睨むことになります。

合成を使うなら、エラーに文脈を載せる仕組みは必須です。 「あとで足す」ものではなく、合成器と同時に作るものだと考えたほうがいいです。

6.3.1 実務で採るなら —— 追跡性をどう担保するか

「スタックトレースが読めないなら採用できない」で止まらないよう、手当ての方法を3つ書いておきます。いずれもデコレータ層で1回書けば全ステップに効くのが利点です。

この節のコードだけは、リポジトリに含めていない実装案です。 連載の他のコードはすべて go test が通る状態で置いてありますが、ここは「こう書けば追える」というスケッチなので、tracer のような外部依存も込みで読んでください。写経するなら下の落とし穴に注意してください。

① ステップ名を context に積む。 panic にも効く唯一の手です。

// WithTrace は実行中のステップ名を context に積む。
// panic 時の recover でここから経路を取り出せるので、エラーを返さない落ち方にも効く。
func WithTrace(name string) func(Endo) Endo {
	return func(next Endo) Endo {
		return func(ctx context.Context, s State) (State, error) {
			// 兄弟ステップと backing array を共有しないよう、必ず複製してから足す。
			trail := append(slices.Clone(TrailFrom(ctx)), name)
			return next(context.WithValue(ctx, trailKey{}, trail), s)
		}
	}
}

最外周に recover を置き、TrailFrom(ctx) を落ちた場所として出力します。在庫確保 → 決済 → 配送手配 という経路が panic のログに残るので、フレームを数える必要がなくなります。ステップ名はもともと Node.Name として持っているので、Compile の中で自動的に巻けます。

slices.Clone を挟んでいるのは、うっかりすると踏む罠があるからです。append(TrailFrom(ctx), name) と素直に書くと、元のスライスに容量の余りがあったとき、追記先の配列が呼び出し元と共有されます。直列なら「経路の履歴が上書きされる」で済みますが、この記事の parallelStep のように同じ ctx から複数の goroutine が枝分かれすると、兄弟どうしが同じ場所に書き込んでデータ競合になります。context に載せる値は複数の枝から同時に読まれうるので、スライスを載せるときは複製が原則です。

② カスタムエラーにステップと位置を持たせる。WithLabelfmt.Errorf から構造体に変えます。

type StepError struct {
	Step  string
	Index int
	Err   error
}

func (e *StepError) Error() string { return e.Step + ": " + e.Err.Error() }
func (e *StepError) Unwrap() error { return e.Err }

errors.As で取り出せるので、ログに構造化フィールドとして出せます。文字列に埋めるだけだと後から機械処理できません。Index を持たせておくと「8ステップ中の3番目で落ちた」まで分かります。

③ トレーシングのスパンをデコレータで張る。 OpenTelemetry を入れているなら、これが一番効きます。

func WithSpan(name string) func(Endo) Endo {
	return func(next Endo) Endo {
		return func(ctx context.Context, s State) (State, error) {
			ctx, span := tracer.Start(ctx, name)
			defer span.End()
			out, err := next(ctx, s)
			if err != nil {
				span.RecordError(err)
			}
			return out, err
		}
	}
}

スタックトレースで追いたかったこと(どこを通って、どこで、なぜ落ちたか)が、そのままトレース画面で見られます。合成した実装のほうがこれを入れやすいのは皮肉なところで、素直な実装だと8か所に手で埋めることになります。

整理するとこうなります。合成は「静的な読みやすさ」を犠牲にする代わりに、「動的な観測しやすさ」を安く手に入れられます。 デバッガとスタックトレースで追う文化のままだと損をしますが、構造化ログとトレーシングに寄せるなら、むしろ相性がいい。トレードオフの向きを理解して選ぶ話だと思います。

6.4 デバッガが使いにくい

ステップイン実行が地獄になります。compiled(ctx, in) にブレークポイントを置いて F11 を押すと、Then.func1WithTiming.func1WithTimeout.func1WithRetry.func1WithLabel.func1 → やっと Reserve.func1業務ロジック1行にたどり着くまでに5回ステップインする

print デバッグに逃げることになります。これは行数や速度では測れないが、開発体験としては明確な劣化です。

6.5 やらなかったこと —— 補償トランザクション

決済が成功したあとで配送手配に失敗したら、決済を取り消したいです。いわゆる Saga パターンです。

構造としては書けます。各ステップに「打ち消しの射」を持たせて、失敗時に逆順で適用すればいいです。圏論の言葉なら、射が可逆であることを要求する形になります。

やらなかった。 理由は、State → State の射に打ち消しを持たせようとすると、NodeCompensate Endo が増え、Compile が「成功した分だけ逆順に巻き戻す」ロジックを持ち、parallelStep との相互作用を考える必要が出てくるからです。この記事の範囲では、抽象化の骨格が見えなくなる量になります。

実務でやるなら、まず冪等性で解決できないかを先に考えるべきだと思います。第1回で書いたとおり、冪等性とは「純粋でない射を外から純粋に見せる工作」です。打ち消しより、やり直しても壊れない設計のほうが運用が楽になります。


7. テストはどう変わったか

行数や速度と並んで、実務で効くのがテストの書きやすさです。ここは明確に良くなりました。

7.1 モックの数は変わらない

まず期待を下げておきます。モックの数は減らない。両方の実装が同じ4つの外部サービスに依存しているので、テストで差し替えるものは同じです。

// newDeps は4つのフェイクを束ねて返す。個別に設定を変えたいときは戻り値をいじる。
func newDeps() (Deps, *fakeInventory, *fakePayments, *fakeShipping, *fakeNotifier) {
	inv, pay, ship, notif := &fakeInventory{}, &fakePayments{}, &fakeShipping{}, &fakeNotifier{}
	return Deps{Inventory: inv, Payments: pay, Shipping: ship, Notifier: notif}, inv, pay, ship, notif
}

このヘルパーは両方の実装で共有しています。「圏論を使うとテストが楽になる」と一般化するのは誇張です。依存の数は設計の問題であって、合成とは関係ありません。

7.2 だが、テストの粒度が細かくなった

変わったのはここです。デコレータを単体でテストできる

// デコレータは業務ロジックを一切使わずに単体でテストできる。
// 素直な実装では、リトライの挙動を確かめるのにワークフロー全体を動かす必要がある。
func TestWithRetryOnlyRetriesTemporaryErrors(t *testing.T) {
	tests := []struct {
		name      string
		err       error
		wantCalls int
	}{
		{"一時的な失敗は再試行する", ErrTemporary, 3},
		{"恒久的な失敗は再試行しない", ErrOutOfStock, 1},
		{"成功したら再試行しない", nil, 1},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			calls := 0
			step := Endo(func(_ context.Context, s State) (State, error) {
				calls++
				return s, tt.err
			})

			_, err := WithRetry(step, 3)(context.Background(), State{})

			if calls != tt.wantCalls {
				t.Fatalf("呼び出し回数が違う: got=%d, want=%d", calls, tt.wantCalls)
			}
			if !errors.Is(err, tt.err) {
				t.Fatalf("エラーが違う: got=%v, want=%v", err, tt.err)
			}
		})
	}
}

在庫サービスも決済サービスも出てこない。リトライという関心事だけを、3行の偽ステップでテストしている

素直な実装で同じことをテストするには、在庫サービスを「2回失敗してから成功する」設定にして、ワークフロー全体を走らせるしかありません。リトライのテストなのに決済も配送も通知も動きます。しかも「決済のリトライ」を確かめたいなら、在庫確保は成功させたうえで決済を失敗させる、という組み合わせを毎回作ることになります。

7.3 暗黙の設計判断をテストで固定できる

6.2 で「デコレータの順序が暗黙になった」と書きました。せめてテストで固定します。

// タイムアウトはリトライ全体に効く。デコレータを巻く順序が結果を決めている。
func TestTimeoutWrapsAllRetries(t *testing.T) {
	calls := 0
	slow := Endo(func(ctx context.Context, s State) (State, error) {
		calls++
		select {
		case <-time.After(20 * time.Millisecond):
			return s, ErrTemporary
		case <-ctx.Done():
			return s, ctx.Err()
		}
	})

	// compileNode と同じ順序: リトライの外側にタイムアウトを巻く
	step := WithTimeout(WithRetry(slow, 10), 50*time.Millisecond)

	_, err := step(context.Background(), State{})

	if !errors.Is(err, context.DeadlineExceeded) {
		t.Fatalf("タイムアウトしていない: %v", err)
	}
	if calls >= 10 {
		t.Fatalf("タイムアウトがリトライを止めていない: calls=%d", calls)
	}
	t.Logf("50ms のタイムアウトで %d 回試行された", calls)
}
decorator_test.go:84: 50ms のタイムアウトで 3 回試行された

1回20msかかるステップを最大10回リトライする設定で、50msのタイムアウトを掛けました。3回で打ち切られた。もし順序が逆(リトライの内側にタイムアウト)なら、10回×20msで200ms走り続けていたはずです。

このテストがあれば、誰かが compileNode の行を並べ替えたときに落ちます。暗黙になった設計判断を、テストという形で明示に戻している

7.4 定義そのものをテストできる

もう1つ増えたのは、実行せずに構造を検証できることです。

// 運用時の問い「どのステップが何回リトライするのか」に、定義から答えられる。
func TestExplainListsRetryPolicy(t *testing.T) {
	deps, _, _, _, _ := newDeps()

	explained := Explain(OrderPlan(deps))

	if !strings.Contains(explained, "決済: リトライなし") {
		t.Fatalf("決済のリトライ設定が読み取れない:\n%s", explained)
	}
	if !strings.Contains(explained, "在庫確保: 最大3回 / 2s") {
		t.Fatalf("在庫確保の設定が読み取れない:\n%s", explained)
	}
}

「決済は絶対にリトライしてはいけない」という業務要件が、外部サービスを1つも呼ばずにテストできる。素直な実装なら、決済サービスを失敗させてワークフローを走らせ、呼び出し回数を数えることになります。

要件が「設定」として値になっているので、要件のテストも値の検査で済みます。これは F代数で式木の構造を扱ったときと同じ利点です。


8. 結局、どこで使うべきなのか

4回の連載を通して測ってきた結果を、判断の形にまとめます。

使う価値がある条件

1. ワークフローが複数あり、横断的関心事を一律に掛けたい

リトライ・計測・タイムアウトを10種類のワークフローに一律で入れるなら、合成器を1つ書くほうが安く済みます。「計測を OpenTelemetry に変える」が1か所で済みます。

2. 定義から実行以外のものを導きたい

dry-run、図、設定一覧、権限チェック、影響範囲の解析。同じ定義から複数の解釈を出したいなら、定義を値にするしかない。これは合成の副産物ではなく、それ自体が目的になりえます。

3. 運用中に構成が変わる

「このテナントだけ通知を飛ばさない」「この環境ではリトライを増やす」。定義が値なら、設定から組み立てられます。

使わないほうがいい条件

1. ワークフローが1本しかない

初期投資 57行を回収できません。素直に書いたほうが速く、読みやすく、デバッグしやすい。

2. ステップの順序を型で守りたい

前述のとおり両立しません。順序を間違えたら致命的なら、型を取るべきです。

3. チームが Go の素直さを重んじている

これは技術的な理由ではないが、現実には最も重い。EndoThenWithRetry が並ぶコードは、Go らしくないという理由でレビューを通らないことがあります。そしてその判断は間違っていない。読めないコードは、正しくても保守されません。

導入するなら、まず合成器を使わない形で「業務ロジックと横断的関心事を分ける」ところから始めるほうがいいです。それだけでも 71行が 39行になります。合成器と Plan は、その次の段階の話です。


9. 連載のまとめ —— 圏論はGoプログラマに何をもたらすか

4回にわたって、Goのコードを圏論の目で見てきました。最後に、何が得られたのかを書きます。

新しい道具は増えなかった

正直に言えば、この連載で新しいライブラリは1つも導入していません。使ったのは slicesitererrorsnet/httpcmp といった標準ライブラリと、テスト用の property-based test だけです。

圏論を学んでも、書けるコードが増えるわけではありません。Go にできることは Go にできることのままです。

既に持っていた道具が、整理された

代わりに得られたのは、別々に覚えていたものが1つの構造だと分かることでした。

同じ「自己射のモノイド」が、3か所に出てきました。

対象合成単位元
http.HandlermiddlewareChain素通し
ServerConfigFunctional OptionApplyOptions何もしない Option
StateワークフローのステップSequenceIdentity

3つとも、括り方は自由で、順序は自由でなく、空なら素通しになります。この3つを別々のパターンとして覚えていたのが、1つになりました。

同じ「モノイド」が、集計の至るところにありました。errors.Join、文字列の連結、多段ソートの比較関数、cmp.Ortime.Duration の最大値。そして平均はモノイドではないので並列化で壊れる。MapReduce で合計と件数を持ち回るのは、平均をモノイドにするための変形でした。

ただし「モノイドである」と言うたびに、何を等しいとするかが付いてきます。errors.Join なら分岐に使うセンチネルの集合を固定した観測的等価性のもとで、time.Duration の最大値なら duration >= 0 に限って。第2回 2.2 で見たとおりです。

判断の道具になった

いちばん実用的だったのは、問いの立て方が変わったことです。

場面圏論の問い判断できること
集計を分散させたいモノイドか?分割して並列化してよいか
バリデーションを書く各項目を独立に走らせられるか蓄積する Applicative として組めるか、Monad で繋ぐしかないか
共通処理を作る包む相手の中身に依存するか適用できる範囲が広いか
デコレータや middleware を並べ替えるその合成は可換か順序を変えてよいか
リトライを入れるこの射は純粋か冪等性の実装が別途要るか
ステップを繋ぐ型が変わるか変わらないか型で順序を守れるか、値として扱えるか
2回ループを1回にするFunctor 則を満たすか融合してよいか

答えを与えてくれるわけではありません。問いを与えてくれます。 そして良い問いは、設計レビューで「なんとなく気持ち悪い」を「この処理は結合的でないので分割できません」に変えてくれます。

測ったものの一覧

この連載では、抽象化のたびに値段を測りました。まとめておきます。

抽象化代金判断
合成(変数に保存)手書きとの差 1 ns 未満実質ゼロ。使ってよい
合成(毎回組み直す)+11 ns、32 B/回やってはいけない。起動時に1回
合成1段(スライス経由)+1.09 ns16段でも 18 ns。気にしなくてよい
ループ融合小さい配列で −45%の時間・メモリ 1/3、大きい配列で −5%の時間・メモリ 半分メモリは常に得
並列畳み込み2〜3万要素未満では最大8倍測ってから決める
F代数の fold1.6倍解釈が2〜3個で構成子が増えないなら手書き、増え続けるなら F代数
iter.Seq(push)要素あたり +1 ns使ってよい
iter.Pull要素あたり 33 ns(スライス比 120倍、push 比 26倍)pull が必要なときだけ
any による型消去1.8倍、アロケーション2倍ジェネリクスか辞書渡しを使う
ワークフローの合成1ステップ +30 ns外部サービス呼び出しの前では誤差

抽象化のコストはほとんどの場合ゼロに近い。例外は3つ、「毎回組み直す」「iter.Pull」「any で型を消す」。 これが4回かけて測って得た、いちばん実用的な結論だと思います。

最後に

圏論を学ぶ最大の効果は、たぶん「すでに書いたコードが違って見える」ことです。

if err != nil の3行が、合成できない射を手で繋いでいる作業に見えます。middleware の順序を変えると壊れるのに括り方は変えても壊れない理由が、結合律と可換律の違いとして見えます。フォームの検証を全部走らせられるのか、前の結果を見ないと次が決まらないのかが、Applicative と Monad の選択として見えます。

見え方が変わると、判断が速くなります。それだけです。それだけだが、毎日書くコードに効きます。

Haskell は要らなかった。Go で十分でした。


この連載のコード: https://github.com/makoto-developer/category-theory-go — 各パートの README に「動かし方」と「何を確認すればいいか」を書いてあります。Codespaces で開けばGo を入れずに全部動きます。

すべてのコードは go testgo test -bench が通る状態で置いてあります。記事に載せた数字は BENCHMARKS.md に測定条件とともにまとめてあります。手元の環境で走らせれば、違う数字が出るはずです。そのときは、ぜひ自分の数字で判断してほしいです。

連載一覧

  1. Goの関数と型は圏をなす
  2. errors.Join はモノイドである
  3. Goに高階カインドはない
  4. 圏論でワークフローエンジンを組み直したら、追加コストは1ステップ+30nsだった(本記事)