共通関数に呼び出し元ごとの分岐を継ぎ足していくと何が起きるか

別の記事では、最初から異なる業務知識を「見た目が同じ」という理由で誤って共通化してしまう失敗を扱いました。今回のテーマはそれとは別の失敗です。最初は正しく共通化された、本当に同じ知識を表している関数が、時間の経過とともに呼び出し元ごとの都合をif分岐で継ぎ足され続け、徐々に誰にとっても扱いにくいものに変わっていくパターンです。

最初は正しい共通化だった

注文の明細を人間向けの文字列に整形する関数を考えます。

function formatOrderSummary(order: Order): string {
  const items = order.items.map((i) => `${i.name} × ${i.qty}`).join(', ');
  return `${order.customerName}様のご注文: ${items}(合計 ¥${order.total.toLocaleString()})`;
}

これは注文確認メールと管理画面の注文一覧の両方から呼ばれています。どちらも「注文の中身を人間向けに要約する」という同じ知識を表しているので、共通化は妥当です。

機能追加のたびに、呼び出し元の都合が分岐として入り込む

半年後、管理画面側から「社内向けには税抜金額も併記してほしい」という要望が来ます。既存の関数に手を入れるのが手っ取り早いので、フラグを1つ足します。

function formatOrderSummary(order: Order, forAdmin = false): string {
  const items = order.items.map((i) => `${i.name} × ${i.qty}`).join(', ');
  const totalText = forAdmin
    ? `合計 ¥${order.total.toLocaleString()}(税抜 ¥${order.totalExcludingTax.toLocaleString()})`
    : `合計 ¥${order.total.toLocaleString()}`;
  return `${order.customerName}様のご注文: ${items}(${totalText})`;
}

さらに数ヶ月後、注文確認メールにプレーンテキスト版とHTML版の2種類が必要になり、担当者は同じ関数にformat: 'text' | 'html'という引数を足します。

function formatOrderSummary(
  order: Order,
  forAdmin = false,
  format: 'text' | 'html' = 'text'
): string {
  const items = order.items
    .map((i) => (format === 'html' ? `<li>${i.name} × ${i.qty}</li>` : `${i.name} × ${i.qty}`))
    .join(format === 'html' ? '' : ', ');
  const totalText = forAdmin
    ? `合計 ¥${order.total.toLocaleString()}(税抜 ¥${order.totalExcludingTax.toLocaleString()})`
    : `合計 ¥${order.total.toLocaleString()}`;
  const body = format === 'html' ? `<ul>${items}</ul>` : items;
  return format === 'html'
    ? `<p>${order.customerName}様のご注文</p>${body}<p>${totalText}</p>`
    : `${order.customerName}様のご注文: ${body}(${totalText})`;
}

この時点でformatOrderSummaryは、3つの呼び出し元(注文確認メールのテキスト版・HTML版、管理画面)それぞれの都合が絡み合った関数になっています。forAdminformatの組み合わせは本来2×2=4通りありますが、実際に使われるのは3通りだけです。「管理画面をHTMLで出す」という誰も使わない組み合わせのコードパスも、読む・保守するコストとして残り続けます。

何が壊れたのか

DRY原則をめぐる記事で扱った失敗は「別の知識を、見た目が同じという理由で最初から誤って1つにした」ものでした。今回の失敗はそれとは順序が逆で、最初は同じ知識を表す正しい共通化だったものが、後から生えてきた「その呼び出し元固有の事情」を、関数の外に出さず内側にifとして飲み込み続けた結果です。

見分ける手がかりは、関数に渡している引数の性質です。orderのようにドメインのデータを渡しているうちは健全ですが、forAdminformatのように「これはどの呼び出し元から来たか」を表すためだけの引数が増えていくのは危険信号です。この手の引数は、関数の中身が「Aから呼ばれた場合はこう、Bから呼ばれた場合はこう」という呼び出し元ベースの分岐に変わっていくことを意味します。共通化していたのは本来「注文を要約する」という1つの知識のはずが、気づけば「呼び出し元が誰かによって微妙に違う出力をする」という別物になっています。

直し方は、分岐を関数の外に出す

対処は、後から入り込んだ呼び出し元固有の事情を、関数の中の分岐としてではなく、呼び出し元の外側の処理として組み立て直すことです。

function summarizeOrderItems(order: Order): string {
  return order.items.map((i) => `${i.name} × ${i.qty}`).join(', ');
}

function formatOrderTotal(order: Order): string {
  return `合計 ¥${order.total.toLocaleString()}`;
}

// 管理画面側だけが必要とする事情は、管理画面側のコードに置く
function formatAdminOrderTotal(order: Order): string {
  return `${formatOrderTotal(order)}(税抜 ¥${order.totalExcludingTax.toLocaleString()})`;
}

// HTML版が必要とする事情も、HTML版を組み立てる側に置く
function formatOrderSummaryHtml(order: Order): string {
  const items = order.items.map((i) => `<li>${i.name} × ${i.qty}</li>`).join('');
  return `<p>${order.customerName}様のご注文</p><ul>${items}</ul><p>${formatOrderTotal(order)}</p>`;
}

function formatOrderSummaryText(order: Order): string {
  return `${order.customerName}様のご注文: ${summarizeOrderItems(order)}(${formatOrderTotal(order)})`;
}

関数の数は増えましたが、それぞれの関数は1つの呼び出し元の都合だけを表しており、forAdminformatのような「呼び出し元を識別するためだけの引数」は消えています。管理画面が税抜表示のフォーマットを変えたくなっても、メール送信側のコードには一切触れる必要がありません。共通化していた本当に共通な部分(summarizeOrderItemsformatOrderTotal)は、そのまま部品として残せます。

実務での見分け方

共通関数に手を入れる際、有効な問いは「この引数は"何のデータを扱うか"を表しているか、それとも"誰が呼んでいるか"を表しているか」です。前者はドメインのデータであり、共通化の対象として健全です。後者はほぼ確実に、時間とともに呼び出し元固有の分岐を関数の内側に呼び込む入り口になります。すでにそうした引数が1つ以上ある関数を見つけたら、それは危険信号ではなく「すでに手遅れになりかけているサイン」と捉え、分岐を関数の外に出す作業を先延ばしにしない方が安全です。

まとめ

共通化そのものは間違っていなくても、後から追加される呼び出し元固有の要望を、関数の内側への分岐として継ぎ足し続けると、本来1つだった知識が複数の呼び出し元の都合が絡み合った別物に変質していきます。forAdminformatのように「呼び出し元が誰か」を表すためだけの引数が増えていないかを定期的に点検し、増えていたら分岐を関数の外——呼び出し元ごとの組み立て処理——に戻すことが、共通化を長く健全に保つための実用的な基準です。