DRY原則は「テキストの重複」の話ではない——境界づけられたコンテキストをまたぐ共通化を避ける

DRY(Don't Repeat Yourself)原則は「同じコードを2箇所に書くな」というルールとして語られがちですが、原典である『達人プログラマー』の定義は「あらゆる知識はシステムの中でただ1つの、曖昧さのない、権威ある表現を持たなければならない」というものです。これは"コード片"の話ではなく"知識"の話であり、両者は似ているようでまったく違います。コードの見た目が同じでも、それが表している業務知識が別物なら、それは重複ではなく「たまたま同じ形になっているだけ」——偶発的重複です。境界づけられたコンテキスト(Bounded Context)が異なる処理を共通化してしまうと、この偶発的重複を本物の重複と誤認したまま、危険な密結合を生みます。

具体例:割引計算のロジックが「たまたま」同じに見える

販売コンテキストの「キャンペーン割引」と、在庫コンテキストの「在庫処分割引」を実装するケースを考えます。

// sales/checkout.ts
function applyCampaignDiscount(price: number, campaignRate: number): number {
  return Math.floor(price * (1 - campaignRate));
}

// inventory/clearance.ts
function applyClearanceDiscount(price: number, clearanceRate: number): number {
  return Math.floor(price * (1 - clearanceRate));
}

どちらも「価格 ×(1 - 割引率)を切り捨てる」というまったく同じ計算式です。ここでコードレビューの誰かが「同じ処理が2箇所にある、DRYに反する」と指摘し、共通関数に統合したとします。

// shared/discount.ts
export function applyDiscount(price: number, rate: number): number {
  return Math.floor(price * (1 - rate));
}

一見、正しいリファクタリングに見えます。しかし、この2つの割引はたまたま今日の計算式が一致しているだけで、それぞれ別のコンテキストで、別の理由から生まれた別の業務知識です。

半年後、それぞれの境界で別々の理由から変化する

販売コンテキストでは、クーポンとキャンペーン割引の重複適用を防ぐため、「割引率には上限を設ける」という要件が追加されました。

// shared/discount.ts(販売コンテキストの要件で変更された)
export function applyDiscount(price: number, rate: number, maxRate = 0.5): number {
  const effectiveRate = Math.min(rate, maxRate); // キャンペーンの重複適用を防ぐ上限
  return Math.floor(price * (1 - effectiveRate));
}

この変更は販売コンテキストの都合だけを見れば正しい修正です。しかしapplyDiscountは在庫コンテキストの処分割引からも呼ばれています。在庫処分では、賞味期限が近い在庫を70%オフ・80%オフで一気にさばくことが業務上普通にあり得るにもかかわらず、maxRate = 0.5という販売都合の上限が黙って適用され、在庫処分側の割引が意図せず50%に頭打ちされてしまいます。テストが通っていても、誰も気づかないまま在庫処分の機能が壊れているという状態です。

境界づけられたコンテキストごとに複製した方が安全

この密結合を避けるには、共通化を取り消し、コンテキストごとに独立した関数へ戻すのが正しい選択です。

// sales/discount.ts
export function applyCampaignDiscount(price: number, rate: number, maxRate = 0.5): number {
  const effectiveRate = Math.min(rate, maxRate);
  return Math.floor(price * (1 - effectiveRate));
}

// inventory/discount.ts
export function applyClearanceDiscount(price: number, rate: number): number {
  return Math.round(price * (1 - rate)); // 在庫処分は端数を四捨五入する運用に変更
}

コードの行数は増えましたが、販売コンテキストが上限ルールを追加しても在庫コンテキストには一切影響しません。同様に在庫コンテキストが端数処理を四捨五入に変えても、販売コンテキストのロジックはそのままです。それぞれのコンテキストが、自分の変更理由だけでコードを進化させられる状態に戻っています。

「知識」と「たまたま似た形」を見分ける問い

2つの処理を共通化すべきかどうかを判断する実用的な問いは、「この2つは同じ理由で変化するか」です。販売コンテキストの割引ルールを変える動機(キャンペーン設計・クーポン制度)と、在庫コンテキストの割引ルールを変える動機(在庫の鮮度・処分計画)は無関係です。変更の動機もオーナー(どの業務担当者が正しさに責任を持つか)も異なるなら、たとえ今日のコードが1文字も違わなくても、それは同じ知識の重複ではなく別々の知識がたまたま同じ形をしているだけだと考えるべきです。軽量DDDの記事で触れたような境界の分離は、コンテキストが違えば同じ処理に見えるものも共有しない、という判断を含んでいます。

まとめ

DRY原則は、コードの見た目の重複をゼロにするためのルールではありません。「1つの業務知識には1つの真実の情報源を持たせる」というルールであり、その適用範囲は知識の境界——多くの場合は境界づけられたコンテキストの境界と一致します。境界をまたいだ2つの処理が今日たまたま同じ形をしているという理由だけで共通化すると、片方のコンテキストの都合がもう片方を静かに壊す密結合を生みます。共通化を検討する前に「この2つは同じ理由で変化するか」を問うことが、本物の重複と偶発的な重複を見分ける最も実用的な基準です。