本記事は Zenn にも同内容を公開している: https://zenn.dev/flip451/articles/sotohe-tddd-type-contract
型システムは雄弁だ。それだけでコードの意図や制約を表現できる。この性質は、自然言語で書かれた仕様書と実装の間のギャップを仲介するのに役立つ。さらに型そのものに制約をかければ、アーキテクチャ上の約束やコーディングの作法も、守られているかを機械が検査できる対象になる。
本稿では、この性質を開発の手順に組み込む TDDD(Type-Definition-Driven Development、型定義駆動開発)を扱う。実装コードを書く前に型の契約を機械可読な形で宣言し、実装が済んだらその宣言と実際のコードを機械で突き合わせる、という進め方だ。順に次を見ていく。
- 実装前に機械可読な契約を置く動機
- 契約が持つ役割分類 (role) と種別 (kind) と操作 (action)
- 契約そのものにかける機械的な制約
- テストだけに頼らず、型を最初の契約に据える理由
- 実装後の双方向突合で 🔵🟡🔴 の信号を出す仕組み
考え方自体は言語やツールを選ばないが、説明には筆者の実装(Rust)を引く。
これはシリーズ第 3 回である。第 1 回で「ADR ← 仕様書 ← 型契約 ← 実装」を一方向参照で結ぶ SoT Chain の全体像を、第 2 回で track ワークフローの分業を示した1。本稿はその「型契約」= フェーズ 2 の中身に踏み込む。シリーズ全体の地図では、本稿は 📍 の位置、チェーンの 1 階層を掘り下げる回である。
用語だけ短く再掲する。track は 1 機能追加や 1 バグ修正に相当する開発単位、信号機 (signal) は参照検査の判定結果(🔵 = 根拠がつながっている / 🟡 = つながっているが要解消の課題が残る / 🔴 = 切れている)で、色で作業を止めるかはゲート側が決める(🔴 は常に停止、🟡 は設定次第)。
なぜ実装前に契約を置くのか
出発点は、冒頭で触れた、自然言語の仕様書と実際のコードの間の距離である。従来の開発では、この距離を人間が埋めていた。いまは、ここを LLM に埋めさせる場面が増えている。しかし LLM に実装を任せると、振る舞いは正しいのにアーキテクチャやコーディング規則に反していたり、ときには仕様に書かれてもいない機能を勝手に追加したりする。仕様からコードへの変換に、雑音が乗るのだ。通信の世界では、雑音の乗る経路に信号を通すとき、あらかじめ冗長な情報を付加しておき、受信側でそれと突き合わせて誤りを検出する。同じことが開発でもできる。仕様書とコードの間に、それ自体は冗長な中間成果物を挟み、双方と機械的に突き合わせて逸脱を検出し、やり直させる。本稿で紹介する型契約書と TDDD はその一例だ。
実装の前に、型を宣言する
TDDD の骨格は単純だ。型契約(その実体が型契約書 <layer>-types.json)を実装より先に書き、実装がそれに従う。以後、概念を指すときは「型契約」、その成果物ファイルを指すときは「型契約書」と呼び分ける。
型契約を書くのは、実装に着手する前の設計工程である。入力は上流の設計判断と仕様、出力は層ごとの型契約書だ。ここでいう層は、レイヤードアーキテクチャの区分けを指す。筆者の実装は、ドメイン層・ユースケース層・インフラストラクチャ層の中核 3 層と CLI 系の 3 層に分かれており、依存してよい向きが層の間で固定されている。型契約書に宣言されるのは「どんな型が、どんなメソッドとフィールドを、どんな引数と戻り型で持つか」という構造の契約であって、コードそのものではない。実装は後から来る。契約を書く手間は当然かかるが、筆者の運用ではこの執筆自体も型設計を担当するエージェントに任せている(第 2 回で紹介した分業の一部だ)。
実装が済んだら、宣言とコードを両方向から突き合わせる。
- 順方向の突合(型契約書 → コード)= 宣言した型が実装に存在するか
- 逆方向の突合(コード → 型契約書)= 宣言されていない型が実装に紛れ込んでいないか
型契約書の中身
型契約書は 1 つの型につき 1 件の型宣言を持ち、各宣言は三つの軸と、仕様への参照を持つ。実物を 1 件例示する。
"TemplatePathClassification": {
"action": "add",
"role": { "ValueObject": {} },
"kind": {
"kind": "enum",
"variants": [
{ "name": "Include", "payload": { "kind": "unit" } },
{ "name": "Exclude", "payload": { "kind": "unit" } },
{ "name": "Overlay", "payload": { "kind": "unit" } }
]
},
"module_path": "template_export",
"docs": "Boundary classification value-enum (IN-01, IN-02, AC-02, CN-03).",
"spec_refs": [
{ "file": ".../spec.json", "anchor": "IN-01" },
{ "file": ".../spec.json", "anchor": "AC-02" }
]
}
これは TemplatePathClassification という enum の型宣言である。"action": "add" は、この型が進行中の track で新たに追加される型であることを表す。"role": { "ValueObject": {} } は、この型が値オブジェクトとして定義されることを宣言する。"kind": "enum" 以下は、この型が enum だという構造そのものを宣言する。spec_refs は、根拠となる仕様要素への参照である。以下、role・kind・action の三つの軸を順に見ていく(spec_refs は後半で扱う)。
role(役割分類)
role は、その型の設計上の役割を表す項目である。ValueObject や Entity、リポジトリ、アプリケーションサービス、プライマリーポートなどといった、ドメイン駆動設計 (DDD) やクリーンアーキテクチャ、ヘキサゴナルアーキテクチャにおける概念を指定できる2。
role の選定には層との相性がある。その相性は「役割 × 層」の互換マトリクスとして 1 か所にまとめておく。たとえば ApplicationService は usecase 層のみ、SecondaryAdapter は infrastructure 層のみに置ける。役割ごとに置いてよい層は設定ファイルに宣言されていて、そこに無い層へ型を宣言すると、宣言を書いた時点で機械的に弾かれる。
マトリクスを一部抜粋して雰囲気を示す(✓ 許可、✗ 禁止)。
| role | domain | usecase | infrastructure |
|---|---|---|---|
Entity |
✓ | ✗ | ✗ |
ApplicationService |
✗ | ✓(この層のみ) | ✗ |
SecondaryAdapter |
✗ | ✗ | ✓(この層のみ) |
✗ は機械が強制する。実際のマトリクスには、これらに加えて「置いてよいが根拠が要る」を表す △ という印もある。たとえば ValueObject は domain・usecase・infrastructure の三層のいずれにも置けるが、どの層に置く場合も、なぜその層に置くのかという根拠の記述が求められる。ただし △ と ✓ の区別は機械に渡らない。どちらも「置いてよい」であり、違いは根拠を書く義務の有無だけだからだ。根拠は型設計者が書き、その妥当性はレビューが照合する。すなわち層配置は、△ なら LLM レビューによる意味論的な判定で、✗ なら機械的な判定で、チェックされる。
kind(種別)
kind は Rust の構造そのものを指す。struct(さらに unit/tuple/plain の shape)、enum、type_alias を区別する。上の例は 3 variant の enum で、各 variant が payload を持たない unit であることまで宣言している。メソッドを持つ型では、引数や戻り値の型も Result<Self, TemplatePathPatternError> のような完全な形で宣言する。だから「メソッド名は合っているが、契約が UserId を求める所で i64 を受け取っている」という実装のずれも、コンパイルが通っていても突合で検出できる。逆に言えば、プリミティブ型の濫用(primitive obsession)は、型契約の段階で専用型を宣言しておくことで、実装に入り込む前に締め出せる。
action(操作)
action は開発者の意図表明で、add(新規追加)、modify(既存型の変更)、reference(変更せず参照目的で転記)、delete(意図的な削除)を取る。意図が明示的に記録されるので、「事故的な削除」と「意図的な削除」を信号機が区別できる。宣言なしに型が消えれば 🔴、delete 宣言つきで消えれば 🔵、という具合だ。
型契約書そのものに制約をかける
契約が機械可読であることの利点は、実装との突合だけではない。契約そのものにも制約をかけられる。先に見た「役割 × 層」マトリクスによる層配置の検査は、その一例である。
筆者の実装では、ほかにも次のような規則が型契約書に対して機械検査される。
- フィールドや
Resultのエラー位置に、裸のStringを置けない。概念には専用型を宣言させ、プリミティブ型の濫用を契約の段階で締め出す - プライマリーアダプターのメソッドの引数や戻り値に、
Entityやリポジトリといった内側の役割の型を露出できない。層の漏れを宣言の時点で禁じる - 値オブジェクトには
PartialEq/Eqの実装宣言を義務付ける。値としての比較可能性を、作法ではなく規則にする
これらはすべて、実装が 1 行も書かれていない段階で走る。コードになってからレビューで指摘するのではなく、設計の逸脱を宣言の時点で弾く。型は雄弁だからこそ、型だけを見てもこれだけの検査が成り立つ。
なぜテストだけに頼らず、型を最初の契約に据えるのか
ここまでの進め方は、TDD の相似形として読める。TDD では「テストを先に書き、コードがそれを通す」。TDDD はその「テスト」を型に置き換えたものと見なせる。型契約書は期待する型、メンバー、遷移を宣言した「型レベルのテスト」であり、順方向の突合は「テストが通っているか」、逆方向の突合は「テストに書かれていないコードが無いか」にあたる。
では、なぜはじめからテストだけに頼らず、型を最初の契約に据えるのか。
型もテストも、ソースコードに何かしらの制約を課すという点で共通している。しかし、その制約のかけ方が違う。テストは、入出力という、ソースコードの外部から見た情報に制約を加える。一方、型は、ソースコードの内部構造そのものに制約を加える。内部構造の自由度が下がれば、入出力のパターンも自ずと絞られ、必要なテストの数が減る。これが、型契約がテストに先行する理由の一つだ。
また、冒頭で述べたとおり型は設計を語るうえで雄弁なので、型契約書は設計意図を伝える文書としても機能する。空文字を許さない専用のラッパー型(newtype)、有限の値集合を表す enum、状態遷移そのものを型で表現する技法(typestate)。役割分類と合わせて読めば、型契約書は「どんな設計パターンで作るつもりか」まで宣言している。メソッドの契約(どんなエラー型を返すか、Option が付くか)も一覧から読める。テストの羅列から設計意図を復元するのは難しいが、型の一覧からは骨格が見える。さらに前節で見たとおり、この雄弁さには機械的な制約をかけることもでき、ソースコードの形を設計に沿わせて整えながら、取りうる状態空間をいっそう狭めていく。
バグの発生源は、lint や規約文書で禁じるより、型システムで表現不能にするほうが確実だ。ルールは忘れられるが、型は破れない。TDDD は、この原則を実装前の設計フェーズへ前倒しする仕組みである。
実装のあとで型契約書と突合する
型契約書は宣言に過ぎない。実装が本当にそれに従ったかは、実装後に検証される。突合の材料には、コンパイラ自身が出す情報を使う。Rust なら cargo rustdoc が公開 API を JSON で吐くので、それを型契約書と突き合わせる。
突合は双方向である。
| 状態 | 信号 | 意味 |
|---|---|---|
| 宣言済みで、実装と構造が一致 | 🔵 Blue | 契約が果たされている |
| 宣言済みだが、未実装または不一致 | 🟡 Yellow | 契約が未達(作業中) |
| 宣言がないのに実装がある(その track で増えた分に限る) | 🔴 Red | 契約に無いコード |
順方向(型契約書 → コード)で宣言した型がまだ無ければ 🟡。実装が進めば 🔵 に昇格する。逆方向(コード → 型契約書)で、型契約書に無い型やメソッドがコードに現れれば 🔴 になる。契約に無いコードが、黙って検査をすり抜けることはできない。もっとも、リポジトリに元からある型まで一律に「宣言が無い」と責めるわけではない。検査の対象は track の開始時点からの差分で、🔴 になるのは「この track が新たに持ち込んだのに宣言が無い型」だけである。
この型ごとの信号はダッシュボードで眺めるだけのものではなく、ワークフローのゲートとして消費される。担当タスクに帰属する型が 🔵 で揃うまでレビューに入れない「レビュー入場ゲート」(第 2 回で紹介)がその代表で、型契約の構造的な履行はレビュアーが呼ばれる前にタスク単位で機械が確定させる。
ここまでの流れを一枚にまとめる。
契約の消費先は信号だけではない。型契約書が宣言した role は、後段で「書くべきテストの義務」を導く入力にもなる。このテスト義務の仕組みはシリーズ第 5 回で扱う。
型契約書を上流の仕様につなぐ
最初に示した型宣言の JSON をもう一度見ると、各型宣言は spec_refs で仕様要素(IN-01, AC-02 といった、フェーズ 1 の仕様書の項目識別子)を引用している。つまり型契約書は、下側は実装との突合で、上側は仕様書への引用で、両側から機械検証に留められている。仕様に根拠を持たない型、あるいは型で受け止められていない仕様要素は、この参照で炙り出される。
型契約書を読む道具
型契約書は JSON だが、人間が読むための描画も用意されている。track ごとに 2 種類の図が mermaid で生成される。型グラフは track 開始時点の実装全体を描いたもので、これから手を入れる土地の現況図にあたる。契約マップは、その track で宣言した型契約の可視化である。型、メソッド、variant のノードと、それらを結ぶ参照エッジや impl エッジが、役割別に色分けされて描かれる。設計の空白(たとえば adapter が実装するはずの port が型契約書に宣言されておらずエッジが繋がらない、といった綻び)は、この図の上で目に見える形になる。
実物からの抜粋を示す(3 つの型と 1 つの trait だけに刈り込んである。実際は track の全型が描画される)。
classify メソッドの戻り値が値オブジェクトを指し、usecase 層の trait のメソッドが domain の型を受け取り、infrastructure の adapter が破線の impl エッジで trait に留められている。設計の依存方向が、そのまま絵になる。
冒頭の見立てに戻ろう。仕様からコードへの変換には雑音が乗る。型契約は、その経路にあらかじめ足しておく冗長な情報である。実装より先に構造を宣言し、実装後に双方向から突き合わせることで、契約に無いコードも、契約を果たしていない実装も、機械的に検出できる。LLM がどれだけ速くコードを書いても、この検査は決定論で追いつく。
ただし、機械が確定させる検査が見ているのは構造の一致である。型契約書が仕様書を引用していても、その引用が意味として正しいかまでは、突合では判定できない。次回は、この「参照が意味的に正しいか」を LLM に判定させ、その判定を CI のゲートにする機構を扱う。
シリーズ一覧
- AI エージェントに「仕様どおり」を保証させる — SoT Chain という設計
- ADR から PR まで自走する track ワークフローとマルチエージェント分業
- 型契約書を SSoT にする — TDDD(本記事)
- LLM による意味論検証を支える規律 〜参照整合性への応用例を添えて〜
- LLM が書いたテストを信頼する方法 — テスト義務ゲート
- SoTOHE を使い始める — テンプレート export と新規プロジェクト実走記録(公開予定)
- SoTOHE を支える設計原則(公開予定)
リポジトリ: https://github.com/Flip451/SoTOHE-core
-
本シリーズの矢印の書き分け。→ は参照の向き(参照する側 → 参照される側、つまり下流から上流)。← はその逆から書いた表記で、チェーン全体の列挙に使う。⇒ は工程の順序を表す。 ↩
-
語彙をどこまで揃えるかは、どんな設計様式を受け止めるかで決まる。採用先がクリーンアーキテクチャ、DDD の Application Service、CQRS、struct だけのユースケースのどれを選ぶか分からないなら、そのどれもが表現できる幅が要る。筆者の実装ではデータ用・契約用・関数用の 3 系統に分け、計 23 値を用意しており、受け止めたい設計様式が増えるのに合わせていまも拡張している。port の方向がどちら向きかまで型契約書から読めるようにするには、このくらいの幅が必要だった。単一の様式に決め打ちするなら、もっと少なくてよい。 ↩