BEMAロゴ

エンジニアの
成長を支援する
技術メディア

AI駆動開発で表面化した「認知負債」:DDDの『同じ言葉』をTypeScriptの型に落とし込む

リンクをコピー
リンクをコピーしました
Xでシェアするfacebookでシェアする

はじめに

株式会社メンバーズ デジタルサービス開発本部の村松です。

私は現在、営業の商談管理から受注、稼働管理までを扱う社内向けの業務システムを、AnthropicのClaude Codeを利用しながら開発しています。

Claude Codeを利用したAI駆動開発は便利で楽ですが、最初に困ったのは、セッションのたびにAIへドメイン知識を説明し直す手間でした。ただ、ドメイン知識が共有されないという問題自体は、AI流行以前からあったものです。

この記事では、この問題をDDD(ドメイン駆動設計)の考え方で整理し、業務の言葉がモデル設計に与える影響を解説します。そのうえで、言葉をTypeScriptの型に落とし込む関数型ドメインモデリングの実践を紹介します。AIコーディングツールで業務システムを開発している方や、DDDを実務に取り入れたい方に向けて書きました。

ドメイン知識の認知負債は、AI流行以前からあった

ドメイン知識を蓄積していない環境でClaude Codeを使って開発していると、セッションのたびに「ここでいう案件は〜のことで」「この略語は〜のステータスを指していて」と、AIにドメイン知識を説明し直すことになり、手間がかかります。
そこで最初に私は、ドメイン知識を1,000行を超えるdomain.mdにまとめ、Claude Codeが毎回読み込む指示ファイルCLAUDE.mdから参照させました。

しかし、営業や経理の担当者と話すたびに業務ルールの理解は更新され、domain.mdの更新は追いつきません。チーム内の解釈もずれていき、保守は特定の人に依存しました。最終的に残ったのは、置いてあるだけの微妙に価値のあるdomain.mdです。

この問題を振り返ってみると、原因はAIへの説明より手前にありました。

業務の言葉の意味は、コードにもドキュメントにも残らず、各メンバーの頭の中にだけある状態がデフォルトになりがちです。
そうやってコードではなく、人の理解の側にたまり、システムの構造や設計の意図を把握しにくくする負債を、「認知負債」と呼ぶことがあります。
今では、AIが生み出すコードに人の理解が追いつかない状況を指して使われ始めた言葉です。ただ、ドメイン知識の認知負債はAI流行以前から開発現場にありました。

加えて、AIはセッションをまたいで記憶を持たないため、この負債がセッションのたびに表出します。私はこれを、AI側の認知負債と捉えています。
そして、AIが凄まじいスピードで生み出すコードをレビューするときに、「なんでこういう実装になったんだっけ?」が頻発します。これは逆に、人の認知負債です。

AI流行以前は、コードレビューのときに相応の時間をかけて理解し、レビューすれば負債はその場で返済できていました。ですが、現代のAI駆動開発の現場ではコードリーディング中に2つも3つもレビュー依頼がバンバン飛んできます。
これでは返せる負債も返せません。たまったものではないですね。

そのため、認知負債は分かりやすい課題として、業界で広く認識されるようになったのだろうと考えています。

同じ言葉でも、文脈によって意味が変わる

では、ドメイン知識の認知負債はどこから生まれるのでしょうか。原因の1つは、同じ言葉が業務の場面ごとに違う意味で使われていることだと考えています。「顧客」や「ユーザー」のように、部署によって指すものが変わる言葉は、多くの現場にあるはずです。
私が開発している業務システムでは、「案件」がその代表例です。社内の営業や経理の担当者は日常的に「案件」と言いますが、商談、受注、稼働管理のどの場面の話かによって指すものが変わります。
ここでは、次のように解釈しているとします。

  • 商談の文脈では、要件のある営業機会を指し、クライアントから提示された前提条件や予算を持つ
  • 受注の文脈では、確定した取引を指し、期間や精算情報、按分など契約と収支の管理に必要な情報を持つ
  • 稼働管理の文脈では、プロジェクトの管理対象を指し、基幹システムと連携して期限や「誰がいくらでアサインされているか」を持つ

トマトが植物学では果実、料理では野菜として扱われるように、すべての文脈で通じる唯一の正しい解釈はありません。

『ドメイン駆動設計をはじめよう』Open in new tab(Vlad Khononov著、増田亨・綿引琢磨訳、オライリー・ジャパン、2024年)では、開発者と業務の担当者が共有する言葉をユビキタス言語と呼びます。
ユビキタス言語は、意味が一貫する範囲の中でだけ成り立ちます。
その範囲が境界づけられた文脈です。「案件」に3つの意味があるなら、商談、受注、稼働管理の3つの文脈に分け、それぞれの言葉として定義する必要があります。DDDでいう「同じ言葉」は、文脈ごとに切り出して初めて共有できます。

文脈ごとの言葉は、営業や経理の担当者、つまりドメインエキスパートとの対話から見つけていくものです。存在すら知らなかった業務ルールに、対話の中で初めて出会うこともあります。
そして困ったことに、こちらから聞かない限り、こうした知識は出てきません。
ラムズフェルドの4象限で言う「未知の未知」というやつですね。

言葉を切り出せていないと、モデル設計に何が起きるか

言葉を文脈ごとに切り出せていないと、その曖昧さはモデル設計にも表れます。たとえば、3つの文脈の「案件」を1つの型で表すと次のようになります。

// 説明用に簡略化した例
// NG: 3つの文脈の「案件」を1つの型にまとめる
type Anken = {
  status: "negotiating" | "ordered" | "in_progress"
  requirements?: ClientRequirement[]  // 商談のときだけ使う
  budget?: Money                      // 商談のときだけ使う
  contractPeriod?: Period             // 受注後だけ使う
  billing?: BillingInfo               // 受注後だけ使う
  assignments?: Assignment[]          // 稼働中だけ使う
}

まず、このモデルは、ほとんどのフィールドが省略可能です。
どのステータスでどのフィールドを使えるのかは、型を見ても分かりません。
どのタイミングでどういう状態になるのか、その知識は開発者の頭の中にしか残らず、コードの中にも認知負債が生まれます。
AIもこの型からは正しい組み合わせを判断できないので、推測でコードを書くしかありません。これはAI側の認知負債を生んでいます。

次に、このモデルはありえない状態を作れてしまう問題があります。商談中なのに精算情報が入っているデータも、型の上では正しいデータです。

さらに、ある文脈の変更がほかの文脈に波及します。受注の精算情報の形を変えると、同じ型を使う商談や稼働管理のコードまで影響を受けるでしょう。
会話で「案件」と言えば商談を指すのに、コードのAnkenは3つの意味を抱えています。会話とコードの間では、毎回言葉の翻訳が必要です。

ではどうするのかというと、文脈ごとに型を分けます。
以下のように、それぞれの文脈で、必要なフィールドだけを持ちます。

// 説明用に簡略化した例: 文脈ごとに型を分ける


// 商談の文脈
type Deal = { dealId: DealId; requirements: ClientRequirement[]; budget: Money }


// 受注の文脈
type SalesOrder = { orderId: OrderId; dealId: DealId; period: Period; billing: BillingInfo }


// 稼働管理の文脈
type Project = { projectId: ProjectId; orderId: OrderId; deadline: Date; assignments: Assignment[] }

商談の文脈のDealには、受注や稼働管理の情報が入りません。会話で使う言葉と型の名前が一致し、型を読めばその文脈で何を扱うのかが分かります。
文脈の中において、必要最低限かつ有用なモデルを作ることが重要です。

関数型ドメインモデリングで、文脈ごとの言葉を型に落とし込む

さて、文脈を分けても、各文脈の中には状態やビジネスルールが残ります。
AIに任せきりにすると、class Deal extends AggregateRootのようなクラスベースのコードが出てきがちです。集約の境界やモデルの責務も曖昧になりやすくなります。
また、TypeScriptではクラスベースの実装が冗長になりがちです。PrismaやDrizzleのように、クエリ結果をプレーンなオブジェクトで返すORMと組み合わせると、取得したデータをわざわざクラスのインスタンスに変換する必要があります。TypeScriptなら、クラスを使わなくても型だけで状態やルールを表現できます。

そこで私は、『関数型ドメインモデリング』Open in new tab(Scott Wlaschin著、猪股健太郎訳、2024年)の手法を参考にしました。
TypeScriptは関数型言語ではありませんが、静的型付け言語なら同書の考え方を十分に応用できます。
一例として、次のルールをClaude Codeのルールファイル.claude/rules/domain-modeling.mdに明文化し、AIにも守らせています。

状態ごとに型を分け、ありえない状態を表現できなくする

// 説明用に簡略化した例: 商談の進み方(実際には面談調整、結果待ち、受注・失注などが続く)
// NG: optionalが多く、status固有の状態を読み取れない
type Deal = {
  status: "proposing" | "candidateFixed" | "interviewScheduled"
  candidates?: Candidate[]  // proposingでも入りうる?
  interviewAt?: Date        // candidateFixedでも入りうる?
}


// OK: 各状態が自分のフィールドだけを持つ
// 提案候補検討: 先方に提案する人材を検討している
type ProposingDeal = { kind: "proposing" }
// 提案候補確定: 提案する人材が決まった。候補者の一覧を持つ
type CandidateFixedDeal = { kind: "candidateFixed"; candidates: Candidate[] }
// 面談確定: 先方との面談日時が決まった。候補者と面談日時を持つ
type InterviewScheduledDeal = { kind: "interviewScheduled"; candidates: Candidate[]; interviewAt: Date }
// 商談は、この3つの状態のどれか1つ
type Deal = ProposingDeal | CandidateFixedDeal | InterviewScheduledDeal

判別可能なユニオン型を使えば、各状態が持つのは必要なフィールドだけです。
候補が決まっていないのに面談日時がある、といった組み合わせは型として書けません。前の節で見た省略可能なフィールドの問題を、1つの文脈の中でも同じ考え方で解消できます。

状態遷移は、遷移元の型を引数に取る純粋関数で書く

// NG: クラスのメソッドで状態を書き換える
class Deal {
  scheduleInterview() { this.status = "interviewScheduled"; }
}


// OK: 遷移元の型を受け取り、新しい状態を返す(実装は省略し、シグネチャだけ示す)
function scheduleInterview(
  deal: CandidateFixedDeal,       // 提案候補が確定した商談だけを受け取る
  input: ScheduleInterviewInput   // 面談日時など、面談の確定に必要な情報
): Result<{ state: InterviewScheduledDeal; events: DealEvent[] }>
// 戻り値: 成功なら面談が確定した商談と、商談で起きた出来事(ドメインイベント)の一覧。

scheduleInterviewは提案候補が確定した商談(CandidateFixedDeal)しか受け付けないため、候補を検討中の商談(ProposingDeal)を渡すと型エラーになります。「提案候補が決まっていない商談では、面談を確定できない」という業務ルールが、関数のシグネチャにそのまま表れます。AIが書いたコードをレビューするときも、シグネチャを見れば遷移のルールを読み取れるので、「なんでこういう実装になったんだっけ?」を減らせるはずです。

ルール違反は、throwせずResult型で返す

type Result<T, E = DomainError> =
  | { ok: true; value: T }
  | { ok: false; error: E }

throwは禁止し、成功と失敗をResult型で返す形に統一します。「顧客がひもづいていなければ口頭受注に遷移できない」のようなルールの違反も、戻り値の型に表れます。呼び出す側にとっても、失敗する可能性が型から分かる形です。

ここまでの3つのルールに共通するのは、業務ルールを型として表現していることです。冒頭のdomain.mdのように、同じ業務ルールをドキュメントとコードの両方に書くと、どちらかの更新が追いつかずに食い違っていきます。
型として表現したルールなら、変えたときに影響する箇所をコンパイラが教えてくれるので、実装とずれにくくなります。実装したコードを唯一の情報源(Single Source of Truth)にし、型では表せない設計の背景や理由は、後の節で触れるようにドキュメントで補うくらいが丁度いいと考えています。

レビューは、構造・ドメインモデル・テストケースに絞る

では、AIが生み出すコードのレビューが追いつかずにたまっていく、人の認知負債はどうすればよいのでしょうか。
私は正直、すべてのコードをレビューすることは諦めています。
そもそもAIに関係なく、物事のすべてを理解しようとするのは、筋の良いやり方ではないですね。必要なのは、選択と集中です。
そこで、多角的な観点からのレビューはClaude Codeのスキルを作ってAIに任せ、私自身が見るのは次の3つに絞っています。

  • 構造が、文脈の境界やディレクトリ設計どおりになっているか
  • ドメインモデルの型が、ドメインエキスパートと共有した「同じ言葉」になっているか
  • テストケースが、業務ルールを期待どおりに表しているか

業務ルールが型に落とし込まれていれば、ルールは型定義と関数のシグネチャに表れます。実装の細部を1行ずつ読まなくても、型とテストを見れば、コードが業務の言葉どおりに作られているかを確認できます。

型だけでは「なぜ」までは表せない

注意点として、型はモデルが何であるかを伝えますが、なぜそう設計したのかや、文脈の境界がどこにあるのかまでは表せません。ディレクトリ設計・YAML等による境界と知識の構造化や、ADR(Architecture Decision Records、設計判断の記録)で背景情報を補うなど、それぞれ分担して情報管理する工夫は必要です。

最後に

AIにドメイン知識を説明し直す手間や人の認知負債は、AI流行以前からある負債の表れの一つです。その負債は、現実の業務領域(文脈)ごとに言葉を切り出し、設計に落とし込めていないことから生まれることが多いです。
言葉を文脈ごとに切り出し、関数型ドメインモデリングで型に落とし込むと、モデルは人にもAIにも読めるものになります。

そして何より肝心なのは、ドメインエキスパートに聞き、未知の未知を自ら拾いに行くことです。
ドメインエキスパートがよく使う言葉と、その言葉の意味が通じる範囲(文脈)に気を配り、何をシステム上の「同じ言葉」として設計し、モデルに落とし込むかを意識する。
反対に、設計の過程で新たに見つけたモデルには、あえて「同じ言葉」として名前を付けて声に出し、こちらからドメインエキスパートやチームのメンバーにその言葉で話しかける。

こうして聞くことと話すことを繰り返して育てた共通の言葉が、ユビキタス言語です。同じ言葉でのコミュニケーションこそが、認知負債に立ち向かうためのDDDの最初の一歩だと考えています。

同じ言葉をコードでも会話でも使い続けると、ドメインエキスパートとの会話がそのまま実装の話になり、実装の話がそのまま事業の話にも繋がります。
そうやって「ソフトウェアの実装と事業戦略を結びつける」状態を作り、価値創出のスピードを上げられる点に、AI駆動開発とDDDを組み合わせる意義があると考えています。

この記事が、良いソフトウェアづくりの一助になれば幸いです。

この記事が役に立ったと思ったら、
ぜひ「いいね」とシェアをお願いします!

��リンクをコピー
リンクをコピーしました
Xでシェアするfacebookでシェアする

この記事を書いた人

村松 大祐
村松 大祐
福岡赤坂オフィス所属。受託開発で開発リーダーを務める傍ら、社内向け業務システムのPM兼エンジニアを担当。
詳しく見る
ページトップへ戻る