仕組みから学ぶ Web
基礎読了目安 14#凝集度#結合度#設計#保守性

凝集度と結合度 — 変更したとき何ファイル触るか

良い設計は「動くか」では測れない。測れるのは、仕様変更が来たときの影響範囲。それを制御する2つの語彙。

この記事の進み方

What — 2つの軸で構造を測る

Compare凝集度と結合度

1つのモジュールの中身が、どれだけ1つの目的に向かっているか。高いほどよい。

// 凝集度が低い:関係ないものが同居している
class UserService {
createUser() {}
sendEmail() {}          // ← メール送信は別の関心事
generateCsvReport() {}  // ← レポートも別
validateCreditCard() {} // ← 決済も別
}

// 凝集度が高い:1つの目的に向かっている
class UserRegistration {
register() {}
validateEmail() {}
checkDuplicate() {}
}
測り方
この中身は1文で説明できるか
低いと
無関係な変更で同じファイルを触る
高いと
変更の影響が中に閉じる
  • 「〜Manager」「〜Util」「〜Helper」という名前は、凝集度が低いサインになりやすい。何をするか説明できないから曖昧な名前になる。
  • クラス名を1文で説明できないなら、2つ以上の責務が混ざっている。

「凝集度を高く、結合度を低く」が設計の基本方針。この2つは独立ではなく、責務を正しく分けると両方が改善する。

結合度には段階がある

Figure結合の強さ(弱い順)
データ結合必要な値だけを引数で渡すcalculateTax(amount, rate)
スタンプ結合構造体を渡すが、一部しか使わないcalculateTax(order)
制御結合相手の動作を指示するフラグを渡すprocess(data, isDryRun)
共通結合グローバルな状態を共有するglobal.currentUser
内容結合相手の内部を直接触るobj._privateField = 1
1/5
データ結合最も弱い結合。相手の内部を一切知らず、値だけをやり取りする。テストも書きやすい。

上ほど弱く、望ましい。下に行くほど、相手の変更が自分に波及しやすくなる。

確認 — ここまで読めたか

calculateAmount(items, isMember, hasCoupon, forExport) のようなフラグ引数が危ういのはなぜですか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜこの2つで測るのか

ソフトウェアの品質は「正しく動くか」だけでは測れません。動くコードは、良いコードとは限らないからです。

では何で測るか。仕様変更が来たときに、何ファイル触るかです。

  • 凝集度が高い → 1つの変更が1つのモジュールに収まる
  • 結合度が低い → その変更が他へ波及しない

この2つが揃うと、変更の影響範囲が予測可能になります。予測できれば、見積もりができ、テスト範囲が決まり、レビューで見るべき場所が分かります。

逆に、凝集度が低く結合度が高いコードは、どこを触ると何が壊れるか誰にも分からない状態になります。この状態のコードベースでは、小さな修正にも大きな見積もりがつきます。

「共通化」が失敗する理由

冒頭の事故は、DRY 原則の誤用です。DRY(Don't Repeat Yourself)が言っているのは「同じ知識を重複させるな」であって、「似ているコードをまとめろ」ではありません。

判断基準は変わる理由です。

// 一見同じだが、変わる理由が違う
function formatUserName(user) {   // 表示のルールで変わる
  return `${user.lastName} ${user.firstName}`;
}
function formatInvoiceName(user) { // 請求書の法的要件で変わる
  return `${user.lastName} ${user.firstName}`;
}

この2つを共通化すると、表示ルールを変えたときに請求書まで変わります。現時点で同じでも、変わる理由が違うなら分けておくのが正しい。

逆もあります。

// 見た目は違うが、同じ知識
const TAX_RATE = 0.10;              // A ファイル
const taxAmount = price * 0.1;      // B ファイル(直書き)

こちらは同じ知識が2か所にあります。税率が変わったとき、片方だけ直して事故になる。これこそが DRY が防ごうとしているものです。

フラグ引数が危険な理由

process(data, true);   // ← この true は何?

呼び出し側を読んでも意味が分かりません。そして、フラグで分岐する関数は実質的に2つの関数です。

function process(data, isDryRun) {
  if (isDryRun) { /* 検証だけ */ }
  else { /* 実際に実行 */ }
}

この2つの経路は、共通部分より違う部分のほうが多いことがよくあります。分ければ、それぞれの凝集度が上がり、呼び出し側も読みやすくなります。

processDryRun(data);
processAndCommit(data);
確認 — ここまで読めたか

似たコードを見つけたとき、共通化してよいかの判断軸はどれですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

このクラスをどう分割しますか?

与えられた条件
  • EC サイトの OrderService クラス。現在 1200 行あり、以下のメソッドを持つ
  • createOrder / cancelOrder / calculateTotal / applyCoupon
  • chargeCreditCard / refund(決済代行 API を呼ぶ)
  • sendOrderConfirmationEmail / sendShippingNotice(メール送信)
  • exportOrdersToCsv(経理部向けの日次エクスポート)
  • updateInventory(在庫システムの API を呼ぶ)
  • 直近の変更履歴: メールの文面変更が5回、決済代行の仕様変更が2回、注文ロジックの変更が1回
  • テストを書こうとすると、決済 API・メール送信・在庫 API・S3 の4つをモックする必要がある
この軸で考える
  • · 変更履歴から、何が別々の理由で変わっているかが読めるか
  • · モックが4つ必要という事実は、何を示しているか
  • · 分割したあと、それぞれを1文で説明できるか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「共通化したのに、なぜか変更が怖くなった」という相談に、何が起きたかを説明してください

与えられた条件
  • 3画面の似た処理を1関数にまとめた経緯がある
  • 現在はフラグ引数が4つある
  • 分割の指針まで示したい

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事