Zod でドメイン層のモデルを実装する
class を使わず、type と関数と Zod でドメイン層を書く。
class を使わないのはモデルの書き方の話で、エラーだけは class で書く(Error の継承と instanceof のため)。エラーの class は、そのモデルのファイルの中に置いてよい(references/errors.md)。
対応範囲
TypeScript と Zod 4.6 以上を対象にする。この指針と references に書いた Zod の挙動は、この範囲の版で確かめたものを前提にしている。 プロジェクトの Zod が 4.6 より前なら、Zod を最新版に上げることを利用者に提案する。
書くのはドメイン層とインフラ層だけである。アプリケーション層(ユースケース・アプリケーションサービス)は書かない。 ドメインサービスやリポジトリを呼ぶ流れが要るときは、どこで何をどの順に呼ぶかを回答に示し、コードは書かない。
進め方
- 作るものを決める(下の判断表)
- 該当するパターンを読む(
references/patterns.mdの目次で引き、そのパターンに名指ししたexample/の実物を読む) - 実装する(下の「読む計画」の「実装の前」の列のファイルを読んでから書く)
- テストを書く(「テストの前」の列のファイルを読んでから書く。観点は
references/testing.mdと、書いたモデルの種類に当たるreferences/testing-*.mdを網羅する。既存の流儀との擦り合わせはreferences/test-style.mdの「どの書き方に従うか」) - formatter と linter を通し、
scripts/check.mjsで確かめる(formatter と linter はそのプロジェクトの設定で。実装例の折り返し位置を手で真似ない。check.mjs は下の「書いたコードを check.mjs で確かめる」) - 完了を確認する(「完了の確認」の列のファイル。適用の考え方は下の「完了の確認」)
読む計画
依頼・既存コード・仕様・設計した責務から、当たる基本の行と足す行をすべて選び、各列のファイルを全体で読む。 「常に読む」の行は必ず読む。生成したコードに機能が無いことだけを理由に適用外にしない。当たるか迷ったら読む。 作業の途中で対象や特徴が増えたら、その行のファイルを読んでから続ける。 レビューや修正を頼まれたときも、対象のコードに当たる行を、書くときと同じように選ぶ。 各 references の冒頭にある「読むもの」の表は、この表の写しである。食い違ったら、この表が正である。
| 行 | 実装の前 | テストの前 | 完了の確認 | example |
|---|---|---|---|---|
| 常に読む | references/patterns.md・references/naming.md・references/errors.md・references/comments.md | references/testing.md・references/test-style.md | references/checklist.md・references/checklist-docs.md・references/checklist-tests.md・references/test-style-coverage.md | references/patterns.md が該当パターンに名指しした実物 |
| 値オブジェクト | references/model-code.md | references/testing-value-objects.md・references/test-style-helpers.md | references/checklist-model.md | 同上 |
| エンティティ・集約 | references/model-code.md | references/testing-entities.md・references/test-style-helpers.md | references/checklist-model.md | 同上 |
| I/O の無いドメインサービス | references/model-code.md | references/testing-entities.md | references/checklist-model.md | 同上(実物は無い。業務操作の実物に倣う) |
| リポジトリ・ドメインサービスのインターフェース | references/persistence.md | ― | references/checklist-model.md・references/checklist-infra.md | 同上 |
| DTO・リポジトリ実装・ドメインサービスの実装 | references/persistence.md・references/model-code.md | references/testing-infra.md | references/checklist-infra.md | 同上 |
| 足す: エラー class を書く・足す | ― | references/testing-errors.md・references/test-style-errors-tests.md・references/test-style-type-tests.md | references/checklist-model.md | 同上 |
| 足す: 横断検証(refine / superRefine) | references/pitfalls.md | ― | ― | ― |
| 足す: Zod の API を使う(z.url()・z.templateLiteral・.pick() など) | references/pitfalls.md | ― | ― | ― |
| 足す: 型のテストを置くモデル(references/testing.md で決まる) | ― | references/test-style-type-tests.md | ― | ― |
| 足す: クライアントをモックに差し替えるテスト(リポジトリ実装・保存先に問い合わせるドメインサービスの実装。非同期を含む) | ― | references/test-style-mocks.md | ― | ― |
| 足す: Vitest 以外のライブラリ、テストを実装ファイルの中に書くか迷ったとき | ― | references/test-style-other.md | ― | ― |
references/pitfalls.md だけは全体を読まず、使う API と検証の見出しを引いて読む(目次で引く辞書である。横断検証なら「例外の漏れ」「検証の順序」)。
example は、references/patterns.md が名指ししたファイルを全体で読む。 ファイル全体を読む原則の例外は、example/domain/entity/Task.test.ts だけである。
長いので、references/patterns.md が describe の名前で指した範囲だけを読む(名指しが曖昧か、その describe が見つからないときはファイル全体を読む)。
example/domain/shared-errors.test.ts は、共有のエラー class(example/domain/shared-errors.ts)を書く・足すときだけ読む。モデルのエラー class のテストは、そのモデルのテスト(Task.test.ts など)の末尾に class ごとの describe で置いてある。
書いたコードを check.mjs で確かめる
formatter と linter のあとで scripts/check.mjs(このスキルのディレクトリにある)を走らせ、今回書いたファイルについて false を0件にする。既存のファイルは直さない。
プロジェクトに Vitest の設定が無いとき(vitest.config.* も、vite.config.* の test も、workspace / projects の設定も無いとき)は、ヘルパーの除外と型検査を入れた vitest.config.ts を新しく作る。確認は要らない。 作ったことを回答に書く。中身は references/test-style.md の「置き場と命名」と references/test-style-type-tests.md にある。
既存の設定ファイルを変えないと false が消えないときは、変える内容を利用者に示して確認してから変える。 既存の設定はプロジェクトで共有されているので、黙って変えない。確認が取れないうちは変えず、そのことを回答に書く。
ヘルパーの名前を変えて(*.helper.ts など)設定を避けない。 ヘルパーがテストのファイルだと名前で分からなくなり、本番のビルドや lint の対象に紛れ込む。
null の規則は、示されたファイルを全体で読み、示された見出しの規則を確かめる。
true は規則の見る範囲で違反が無いことを表し、適用外も true になる。brand-symbol は .brand() の宣言の仕方だけを見て、.brand() が1つも無ければ true を返す(列挙・DTO・テストだけの作業では付けないのが正しいため)。
付けるべき完成値にブランドを付けたかは check.mjs は見ないので、自分で確かめる(規則2、references/checklist-model.md)。
走らせられない(Node や、プロジェクトの node_modules に typescript が無い)ときは、チェックリストで確かめる。
node <このスキルのディレクトリ>/scripts/check.mjs \
--outputs <プロジェクトのルート> \
--files <今回書いた・変えたファイル。ルートからの相対パスをカンマで区切る> \
--node-modules <プロジェクトの node_modules> \
--tsconfig <プロジェクトの tsconfig>
既存の流儀を採用して既定を置き換えたときに外せる規則は、references/checklist.md の表にあるものだけである。
外した規則と、置き換えの出どころ(ファイルのパス)を回答に書く。テストの流儀なら、既定と食い違う項目ごとに、既定・採った書き方・出どころのファイルを挙げる(references/test-style.md の「どの書き方に従うか」)。
コードを書く前に、必ず該当パターンの実装例を読むこと。Zod 4 には直感に反する挙動が複数あり、知らずに書くと必ず踏む。
実装例の見た目は真似の対象ではない。引数をどこで折り返すか、import を1行に収めるか、 コメントの前に空白をいくつ置くかは formatter が決める。手で整えると、次に formatter が 走ったときに差分になる。
回答では、依頼の明示要件をどこで満たしたかを説明する。 「Zod を使う」「引数を検証する」 「型で防ぐ」のような指示を、既存の部品に委ねて満たしたなら、その担当箇所を示す。 指示どおりにしない判断をしたなら、その理由を回答の冒頭で述べる。 満たせなかった要件を、満たした要件として並べない。 要件と満たした箇所の一覧に入れず、満たせない理由と残る制約として分けて書く (「型で取り違えを防ぐ」を、型では止まらない名前付き引数で満たしたとは書かない)。 参照実装と形が違うことだけを、要件との衝突とみなさない。 回答が述べる、受け入れる値・拒否する値・振る舞いは、書いた実装と照らし合わせる。 利用者は回答の説明を仕様として読む。 実装が拒否する値を「受け入れる」と書く(ASCII 以外を拒否する URL を「国際化ドメイン名も受け入れる」と説明するなど)と、コードを読まない限り気づけない。 説明する値は、実装とテストで確かめたものに限る。
利用者はこの指針を読んでいない。 判断の根拠は、満たす要件・判断の理由・残る制約で説明する。 規則の番号や表の段だけを根拠にしない。
レビューを頼まれたら、規則違反・具体的な不具合・任意の改善提案を分けて書く。 好みの違いを違反と断定しない。
フォルダ構成
domain/
shared-errors.ts 共有のエラー定義(基底と、どのモデルにも依らない5つ)。モデルのエラーはそのモデルのファイルに置く
value-object/ 値オブジェクト
entity/ エンティティ・集約
repository/ 永続層のインターフェース
domain-service/ 1つのモデルでは決められない処理。保存先に問い合わせるものはインターフェース、I/O の無いものは実装を置く
infrastructure/
dto/ DTO と変換
repository-impl/ 永続層のインターフェースの実装
domain-service-impl/ ドメインサービスのインターフェースの実装
プロジェクトに既存の構成があればそれに合わせる。 上は定めが無い場合の既定である。
1ファイル1モデル。ファイル名はモデル名と同じにする。テスト(Xxx.test.ts)、型のテスト(Xxx.test-d.ts)、ダミー生成ヘルパー(Xxx.helper.test.ts)は対象と同じ場所に置く。
何を作るか決める
上から順に確定させる。当たらない行は飛ばす。後の行で、先に決めた分類を変えない。 3行目はエンティティを指す項目ごと、6行目は導出値ごとに判断する。 エンティティを指さない項目(表題・金額・状態など)は3行目の対象外で、そのまま次の行へ進む。 集約の境界が決まっていないなら「いいえ」にせず、未確定として利用者に確認する。
| # | 問い | はい | いいえ |
|---|------|------|--------|
| 1 | 属性値の一致ではなく、その対象自身の識別子で同一性を判定するか | エンティティ | 値オブジェクト |
| 2 | エンティティのとき、別の集約ルートの境界内に属し、そのルートを通して整合性と保存を管理するか | 子エンティティ | 自身を集約ルートとする |
| 3 | エンティティを指す項目について、そのエンティティは別の集約に属するか | 関連先の識別子で参照する | 同じ集約の子エンティティとして内包する |
| 4 | 状態によって持つ項目が変わるか | 状態別ユニオン | 5 で値の形を選ぶ |
| 5 | 4 が「いいえ」のとき、複数の名前付き項目で構成するか | 単一の strictObject | 単一値・列挙・配列など、値の形に合うスキーマ |
| 6 | 必要な導出値について、計算結果をモデルの状態として保持する要件があるか | 導出値として保存し、再計算できる整合を完成値スキーマで検証する | 読み取りの関数にする |
1行目で「変化するか」を問わない。 変わらなくてもエンティティである。
Comment は投稿されたあと変わらないが、本文と著者と時刻が同じでも
別の投稿なら別物として扱うので子エンティティである(commentEqualsById)。
値オブジェクトが不変なのは設計の帰結であって、エンティティと分ける条件ではない。
このスキルではエンティティも readonly で不変に実装するので、可変性では区別できない。
決める前に、文字列は正規化規則と長さの単位、数値は単位と精度と範囲、コレクションは空と最大件数と重複と順序、日時は瞬間か暦日かを記録する。業務で未確定の値を「Zod の既定だから」という理由で仕様にしない。
文字列の min / max はコードポイントで数える。 😀 や 𠮷 は1と数えるが、見た目が1文字でも複数のコードポイントでできた文字(ZWJ でつないだ絵文字など)は複数と数える。UTF-16 のコード単位・バイト数・見た目の文字数で制限する必要があるなら、単位を明示した refine を書く。
値オブジェクトに切り出すのは、切り出した状態でそれ自体が業務の概念として意味を持つときだけである。
「識別子」「金額」「期間」のように、業務の言葉で名前が付き、その名前で会話できるものを切り出す。
「表題」「本文」のように、持ち主のエンティティを離れると意味を失う項目は切り出さない。
XxxFields に文字列・数値として直接書き、制約もそこに書く。
型で取り違えを止めたいという理由だけで切り出さない。
識別子の大文字小文字を区別するかは、その識別子の業務上の定義で決める。 一律には決めない。
大文字小文字を同一視する形式(UUID など)で表記の揺れを許すと、同じ識別子が表記違いで2つ共存し、
=== も equalsById も保存キーも分裂する。そういう形式では受け入れる表記を1つに決め、ほかの表記を拒否する
(z.uuid() は大文字を通すので、小文字に決めたなら refine((v) => v === v.toLowerCase()) を足す)。
大文字小文字の違いで別の識別子を表す形式では、表記を揃えない。外部が発行する識別子は、発行元の比較の規則に合わせる。
子エンティティは集約ルートに含めて保存する。 子だけを取り出すリポジトリを作らない。
子エンティティを作る入口も集約ルートの業務操作に置く(Task.addComment が中で Comment.create を呼ぶ)。子を書き換える操作も集約ルートに置き、子を識別子で探す(references/model-code.md の「ファイルの書き方」)。
識別子の一意性を集約の中だけで保証するなら、識別子だけを見る等価性も同じ集約の中でしか使えない。
1つのモデルに置けない処理があるときは、下の「ドメインサービスにするか」で置き場を決める。
決まったら references/patterns.md の目次で該当パターンを引く。分類は次の4つで、各分類の中は単純なものから複合的なものへ並んでいる。
| 分類 | 内容 | |------|------| | 値オブジェクト | プリミティブ型 / 接頭辞付き識別子 / 列挙 / URL / 複数値かつ各値はプリミティブ型 / 複数値かつ各値が別の値オブジェクト / コレクション | | エンティティ | 単一状態 / 複数状態 / エンティティがエンティティを内包する / 別の集約を生成する(生成する側の集約の操作) | | ドメインサービス | I/O の無い判定 | | インフラ | 永続層のインターフェース / ドメインサービスのインターフェース / DTO と変換 / インターフェースの実装 / ドメインサービスの実装 |
ドメインサービスにするか
ドメインサービスは最後の手段である。 1つのモデルに置けない理由を言えないなら作らない。 次の順に検討し、最初に置ける場所に置く。
- 状態が変わるモデルの操作。 他の集約からは、判定に要る値だけを引数で受け取る
- 生成なら、生成する側の集約の操作
- どちらにも置けないときだけ、ドメインサービスにする。 置けないとは、判定に同格の複数の集約が要り、 1 か 2 に置くために渡す値を作ること自体が業務の規則になるときである。その値を呼び出し側が作ると、業務の規則がドメインの外に漏れる
「アーカイブ済みのプロジェクトにはタスクを作れない」は 2 に置く。生成する側の集約(Project)の業務操作 Project.createTask(project, params, deps) が、
アーカイブ済みでないことを確かめてから中で Task.create を呼び、新しい Task を返す(実物は example/domain/entity/Project.ts。形は references/model-code.md の「ファイルの書き方」)。
2つの集約を比べる、別の集約の値を使って計算する、といった処理も、たいていは 1 か 2 に置ける。
3 に当たりそうでも、集合を見て状態遷移を止める判定は、次の2つを決められないなら作らない。 「プロジェクトで同時に進行中にできるタスクは N 件まで」のような判定がこれに当たる。
- 全件がそろうことを誰が保証するか。 判定に要る集約を呼び出し側が集めるなら、取りこぼした一覧を渡すだけで判定をすり抜ける
- モデルの操作を直接呼ぶ経路をどう塞ぐか。 判定をサービスに置いても、
Task.startのようなモデルの操作は公開されたままなので、 サービスを通らずに遷移できる。同じ状態に至る別の操作(再オープンなど)も、すべて判定の対象になる
判定から保存までの間に、別の要求が同じ判定を通る競合(どちらも N−1 件を見て、両方が開始する)は、同時更新は楽観ロックでよいという要件のもとで扱う。
厳密に上限を守る必要があるかは要件で決め、定めが無ければ利用者に確認する(references/persistence.md の「同時更新」)。
この2つを決められないとき、または 1〜3 のどこに置くかを判断できないときは、利用者に質問する。 実装できる部分は実装し、決められない点を質問として回答に書く。依頼が明示した判定を黙って省かず(規則12)、決まっていない形で作ることもしない。
参照実装に I/O の無いドメインサービスの実物は置いていない。題材のタスク管理には、業務上どうしても要り、 しかも 1 にも 2 にも置けない判定が見当たらなかったためである。例のために作った規則は、この指針そのものを崩す。
ドメインサービスには2つの形がある。
| 形 | 使う場面 | 置き方 | 実物 |
|---|---|---|---|
| 保存先に問い合わせる | 判定や生成に保存先の状態が要る(採番など) | domain/domain-service/ にインターフェース、infrastructure/domain-service-impl/ に実装を置く | TaskNumberIssuer |
| I/O の無い判定 | 判定に要る集約を、呼び出し側がすべて渡せる | domain/domain-service/ に同期の関数として実装する。リポジトリも外部も呼ばない | なし |
I/O の無いドメインサービスを作ることになったときは、業務操作と同じ形で公開し、状態の遷移はモデルの操作に任せる。 参照実装に実物が無いパターンを書くときは、上の条件に当たると判断した理由を回答に書き、形は似たパターン(業務操作)の実物に倣う。 実物に無い業務の規則を、例を補うために足さない。
中核の規則
| # | 規則 | 理由 |
|---|------|------|
| 1 | 完成値スキーマは検証専用に保つ。値を変える API を入れない。検証は同期で完結させる | 値を変えると、reconstruct が壊れた保存値を黙って補正して受理し、create と完成値スキーマの受理集合もずれる。非同期の検証が混ざると同期の safeParse が例外を投げ、create / reconstruct / 業務操作が失敗を名前の付いたエラーに包めなくなる |
| 2 | ブランドは unique symbol で作る。z.enum の列挙には付けない | 同名の文字列ブランドは別々に宣言しても相互代入できる。列挙はリテラルのユニオン型になるので、列挙の外の値を型で弾ける。ただし別の列挙と同じリテラルを共有していると、その値の取り違えは防げない |
| 3 | 1本の完成値スキーマを、生成・復元・DTO の from の出口・更新後の検証が共有する | どこから来た値でも同じ不変条件を通る |
| 3a | オブジェクトと配列の完成値スキーマに .readonly() を付ける。その場で書いた z.strictObject / z.array ごとに付け、参照先のスキーマには重ねない。状態別ユニオンは外側に1回付ける。Date のような可変のインスタンスは完成値に持たせない。付け方の詳細は references/model-code.md の「.readonly() の付け方」 | .readonly() の凍結は1階層だけなので、外側に付けても、その場で書いた入れ子は凍らない。Date は凍結しても setTime で中身が変わる |
| 3b | 任意項目は .exactOptional() で宣言する。.optional() は使わない。詳細は references/model-code.md の「任意項目」 | .optional() は { key: undefined } を通し、完成値に項目が残る |
| 4 | CreateXxxProps は第1引数のスキーマの z.output から取る。XxxSchemaInput は完成値スキーマの z.input から取る | 前者はブランド付きになり、生の値も別種の識別子もコンパイルで止まる。後者は reconstruct に渡す保存値と、組み立てたリテラルに付ける satisfies の型なので、各項目まで生の型で書ける必要がある。**create の引数型を XxxSchemaInput から取らない。**ブランドが外れ、検証済みの値と未検証の値の取り違えが型で止まらなくなる |
| 5 | ドメインでは正規化しない。整形済みの値だけを受け入れる。操作が結果を組み立てるときに並び順を整えるのは正規化ではない。順序に意味のないコレクションは、並び順を不変条件にしないか、並び順を固定して create で並べ替えるかを選ぶ | create と完成値スキーマの受理集合を一致させる。禁じているのは受け取った入力を直して受理することであって、[...items, item].sort(compareItems) のような導出ではない。順序に意味のないコレクションでは、並べ替えは値の意味を変えないので例外とする。例外は並べ替えだけで、重複の除去や値の書き換えはしない。並び順を固定したなら、reconstruct は並べ替えず、決めた順でない保存値を拒否する。そのため比較の仕方を変えると保存済みの値を読めなくなる。並び順を固定する理由が無ければ、不変条件にせず、等価性は並び順を無視して比べる(参照実装の Labels)。並べ替えるときは比較関数を明示し、順序の検査と同じ比較を使う。引数なしの sort() は要素を文字列として比べるので、数値では <= の順序と食い違う |
| 6 | 横断検証から例外を投げる関数を呼ばない | safeParse は例外を捕まえない。その完成値スキーマを safeParse している場所すべてで、失敗結果のかわりに例外が飛ぶ |
| 7 | idGenerator と clock は依存として注入する。型はそれを使うモデルのファイルに書く。時刻の出どころは次で決める。create が作る値の作成時刻(Task.create の createdAt など)は deps.clock から取る。業務操作が記録する出来事の時刻(Task.start の params.at など)は、操作の引数で呼び出し側から受け取る。業務操作の中で子エンティティを create するときは、子の作成時刻なので、受け取った deps を子の create に渡して deps.clock から取る(Task.addComment が作る Comment の postedAt)。 依存が返す値は生の値のまま受け取り、create の中で取った直後に完成値と同じ項目のスキーマで確かめる(不正なら InvalidClockValueError か、識別子の型ごとの class〔InvalidGeneratedCommentIdError など〕)。クロックは、前に返した値より小さい値を返さない時刻源を前提にし、それを満たすのは依存を組み立てる側の責任とする | スキーマが非決定的になるとテストで固定できない。共有の依存ファイルを作ると、どのモデルが何を要るのかがファイルを開いても分からない。業務操作が記録する時刻は、その出来事がいつ起きたかを知っている呼び出し側の入力である。依存の値の不正を入力の不正(InvalidCreationParamError)にすると、呼び出し側が直せない失敗を直せる失敗として報告してしまう。詳しくは references/errors.md |
| 8 | 永続化の知識はアダプタ層に置く。ドメインは保存先を知らない。保存先とやり取りする規則は references/persistence.md にまとめてある | 保存先を増やすたびにドメインが変わる状態を避ける。保存キー・DTO スキーマの契約・インフラ層での値の生成と変換・外部応答の検証は、そこに揃っている |
| 9 | 失敗は名前の付いたエラー class で投げる。素の ZodError を throw しない(層の外に出さない)。プロジェクトに独自のエラー規則があればそちらに従う | 受け取る側が instanceof だけで分岐できる。エラーが持つ issues は Zod の issue の型のままなので、それを読む側は issue の形(code や path)に依存する。これは許容している。詳しくは references/errors.md を参照 |
| 10 | オブジェクトリテラルを parse / safeParse に渡すときは satisfies XxxSchemaInput を付ける | 引数の型が unknown なので、付けないとフィールドの書き忘れも打ち間違いもコンパイルを素通りする |
| 11 | JSDoc には、コードから読み取れないことだけ書く | 制約を書き写すと二重管理になり、片方だけ直した嘘が残る。検証していない制約を書くのも同じ理由で禁止 |
| 12 | 依頼にない項目・状態・操作・不変条件・導出値・業務上の数値を足さない。依頼が明示したことは省かない(下の「依頼に書かれていないことの扱い」)。create・reconstruct・等価性の関数は、この指針が必ず置くものなので、依頼に無くても置く。発明に数えない | 参照実装の形に引きずられて発明すると、決まっていない仕様を勝手に固定することになる。逆に、足りないことを理由に明示された操作を省くと、依頼を満たさない |
| 13 | 操作はモデル名のオブジェクトに集めて公開する | 利用側が Xxx. でそのモデルにできることを一覧できる |
| 14 | 未検証の入力からブランド付きの完成値を作る入口は create / reconstruct だけにする。value is Xxx を返す関数(型ガード)を書かない。ドメイン層の外から完成値スキーマを直接呼ばない。ドメイン層の外で、スプレッドやオブジェクトリテラルで完成値型の値を作らない。reconstruct を呼ぶのは、保存先から読んだ値の復元(DTO の from など)とテストだけにする | ブランドは型の上の目印でスプレッドで引き継がれるので、{ ...task, title: "" } も型検査を通る。入口を2つに絞る保証は、この規約と組になって初めて成り立つ。理由の詳細は references/model-code.md の「入口を create と reconstruct に絞る」 |
依頼に書かれていないことの扱い
実装できる仕様があるかどうかで分ける。
- 依頼された操作の実装に不可欠な項目は、発明に数えない。 退会する操作を頼まれたなら、退会したことを表す項目はその操作の一部である。
- 業務の判断を伴わない表し方の選択は、最小の案で実装し、置いた仮定を回答に書く。 選ばなかった案を毎回並べる必要はない。
- 選び方によって操作の成否や保存する情報が変わるなら、その部分だけを質問し、決まっている部分は実装する。 退会を「記録を残して状態を変える」か「データを消す」かは振る舞いが違うので、ここに当たる。
- 項目も操作も手掛かりが無く、モデルを定義できないなら、コードを書かず質問だけを返す。 参照実装の項目で埋めない。
件数や文字数の上限が依頼に無ければ、max を置かない。 上限を置くかどうかは業務の判断なので、
置いていないことと、上限が要るかどうかを回答で確認する。暫定の値を置いて先に進めない。
識別子の形式や非負のような、表現を成り立たせるための形式条件はこれに当たらず、実装に置く。
Unix epoch ミリ秒のタイムスタンプの上限(9999-12-31)も形式条件である。上限が無いと、Date が扱えない値で toISOString が RangeError を投げ、
1万年以上は ISO の文字列が6桁の年になる。マイクロ秒をミリ秒と取り違えた値も止まる。
表現の都合で上限が要る場合(保存キーの固定桁など)は、その理由とともに回答に書く。
依頼に制約が無い自由記述の項目(本文・備考など)の空文字を拒否する min(1) は、形式条件ではなく業務の条件である。
空文字も文字列として成り立つので、空を許すかは業務が決める。依頼に無ければ置かず、上限と同じく、置いていないことと要るかどうかを回答で確認する。
利用者が正規化(大文字小文字の同一視、前後の空白の除去など)を明示的に求めても、規則5は崩さない。 依頼の要件は省かず(規則12)、次のどちらかで満たす。どちらにしたかと、そう選んだ理由を回答に書く。
| 満たし方 | 向いている場面 |
|---|---|
| 比較だけを同一視する。完成値は受け取った表記のまま持ち、等価性の関数(xxxEquals)で同一視する | 利用者が入力した表記を保存しておきたいとき |
| アダプタ層で正規化する。入力を受ける境界(API のハンドラなど)で正規化してから create に渡し、ドメインは正規化済みの表記だけを受け入れる | 保存や検索のキーを1つの表記に揃えたいとき |
ドメインのスキーマに transform や .toLowerCase() を入れて満たさない。要件を満たさずに質問だけを返すこともしない。
参照
| ファイル | 内容 |
|---------|------|
| references/patterns.md | 実装パターンの目次と、パターンごとに決めること・注意点。コードは example/ の実物を読む |
| references/model-code.md | モデルのコードの書き方。公開の形、入口、create の第1引数、satisfies、ファイルの書き方、業務操作、決まりごとの置き場、superRefine、分岐 |
| references/naming.md | スキーマ・型・関数・定数・ファイルの命名 |
| references/pitfalls.md | Zod 4.6 以上と TypeScript の落とし穴。実測した挙動 |
| references/errors.md | エラーの種別・投げる場所・メッセージ・エラー class の書き方 |
| references/persistence.md | DTO・保存キー・リポジトリ実装・インフラ層での値の生成と変換 |
| references/comments.md | JSDoc と // コメントの置き場と書き方、語の選び方 |
| references/testing.md | 網羅すべきテストの観点のうち、どのモデルにも共通のもの。型のテストを置くモデル、到達しない分岐 |
| references/testing-value-objects.md / testing-entities.md / testing-errors.md / testing-infra.md | モデルの種類ごとの、網羅すべきテストの観点 |
| references/test-style.md | テストの書き方のうち、どのテストでも読むもの。どの書き方に従うかの判断、置き場と命名、Given / When / Then、テーブル駆動 |
| references/test-style-helpers.md / test-style-errors-tests.md / test-style-mocks.md / test-style-type-tests.md / test-style-coverage.md / test-style-other.md | ダミー生成ヘルパー、エラー class のテスト、クライアントのモック、型のテスト、カバレッジ、Vitest 以外と in-source testing の書き方 |
| references/checklist.md | 完了の確認に使うチェックリストのうち、どの成果物にも共通の項目。〔check.mjs〕の印の意味と、既存の流儀で外せる check.mjs の規則 |
| references/checklist-model.md / checklist-infra.md / checklist-docs.md / checklist-tests.md | 書いたものの種類ごとの、完了の確認の項目 |
| example/ | 実物。Jira に似たタスク管理ツールの一式。全モデルのテストとダミー生成ヘルパーを含み、tsc とテストが通る状態 |
| scripts/check.mjs | 書いたコードのうち、構文で決まる規則を確かめるスクリプト(手順5)。evals の採点にも同じものを使う |
完了の確認
確認するのは、対象のモデルに該当し、今回採用した規約で有効な項目だけである。 プロジェクトの規約や利用者との合意で既定を置き換えた項目(エラーの方式、テストの流儀、 フォルダ構成)は、採用したほうに読み替える。モデルの種類で当たらない項目 (単一プリミティブの値オブジェクトに第1引数のスキーマを求める行など)は飛ばす。 適用外の項目を満たすために、コード・テスト・設定を足さない。 適用外と判断した項目は、理由が自明でないものだけを理由とともに回答に書く。 単一の値の値オブジェクトに第1引数のスキーマが無い、のようにモデルの種類から明らかなものは書かない。 並べると回答が確認の報告で埋まり、判断の要る項目が埋もれる。
項目は references/checklist.md と、「読む計画」の表で選ぶ references/checklist-*.md にある。実装とテストを書き終えたら、それらを上から通す。