はじめに
こんにちは。AXLBIT株式会社の@ax_osakiです。
駆け出しのエンジニアとして日々コードを書く中で、「動くコードは書けるけど、設計となると自信がない」と感じることが多く、 『良いコード/悪いコードで学ぶ設計入門』 を読んでみました。
この記事では、本書の中から特に印象に残った設計の考え方を3つ取り上げ、Javaのコード例を交えながら紹介します。題材には、趣味であるレコード(アナログ盤)の管理システムを使っています。
悪いコードとは何か
本書での「悪いコード」の定義は、シンプルに言うと以下のようなものでした。
- 見通しが悪く、どこで何が起きているか追いにくい
- 変更しようとすると、関係ないはずの箇所まで壊れる
- 名前から意図が読み取れず、動きを読まないと理解できない
まだ経験は浅いですが、これまで自分が書いたコードや、人のコードをレビューする中で感じていた「読みにくさ」の原因が、実はこの3つのどれかに当てはまることが多いと感じました。「動くから良いコード」ではなく、「他の人が読んでも壊さずに変更できるコード」が良いコードだ、という視点が自分には抜けていたのだと思います。
逆に「良いコード」とは、これらの逆――変更に強く、意図が伝わるコードだと整理できます。ただ、この2つを実現する方法は無数にあり、本書ではその中でも再現性が高く実務に落とし込みやすい手法が多く紹介されています。今回はその中から、特に印象に残った3つの手法を紹介します。
手法1: クラスがバラバラ → 単体で動く設計
レコードショップの在庫管理を考えてみます。1枚のレコードには「タイトル」「価格」「在庫数」といった情報があり、それを「売る」「値引きする」といった操作をする場面があります。
このとき、ありがちな失敗が 「情報を持つだけのクラス」と「その情報を操作する処理」を別々に用意してしまうことです。一見自然な分け方に見えますが、これをやると、在庫が0未満にならないようにするチェックや、値引きの上限といった 「ルール」が、操作する側のコードのあちこちに書かれることになります。同じチェックを別の画面や別のバッチ処理でも書くことになれば、修正のたびに全ての箇所を探して直す必要が出てきます。
コードにすると、以下のようなイメージです。
Before: データと処理が分離している
class VinylRecord {
public String title;
public int price;
public int stock;
}
class InventoryService {
public void sell(VinylRecord record) {
if (record.stock <= 0) {
throw new IllegalStateException("在庫がありません: " + record.title);
}
record.stock -= 1;
}
public void discount(VinylRecord record, int percent) {
record.price = record.price - (record.price * percent / 100);
}
}
VinylRecord はただのデータの箱で、値は誰からでも自由に書き換えられます。在庫チェックや値引きのルールは InventoryService 側に書かれていますが、他の場所でも同じような処理が書かれる可能性があり、ルールが分散していきます。
これに対する解決策として本書が紹介していたのが、 「データを持つクラス自身に、そのデータに関するルールも一緒に持たせる」 という考え方です。在庫を減らす処理も、値引きの計算も、VinylRecord というクラスの外に出さず、クラス自身のメソッドとして定義してしまいます。
After: 自分のデータは自分で守る
class VinylRecord {
private final String title;
private int price;
private int stock;
public VinylRecord(String title, int price, int stock) {
this.title = title;
this.price = price;
this.stock = stock;
}
public void sell() {
if (stock <= 0) {
throw new IllegalStateException("在庫がありません: " + title);
}
stock -= 1;
}
public void discount(int percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("割引率は0〜100で指定してください");
}
price = price - (price * percent / 100);
}
}
在庫を減らす処理や値引きのルールを VinylRecord 自身のメソッドにしたことで、「在庫は0未満にならない」「割引率は0〜100の範囲」というルールが、そのクラスの中だけで完結します。呼び出し側は record.sell() と書くだけでよく、ルールを意識する必要がなくなります。
なぜこれが「変更に強い」のか
Before の書き方だと、例えば「セール中は値引き上限を50%までにしたい」という仕様変更が入ったとき、InventoryService だけでなく、値引き処理を呼んでいる他の箇所にも同じチェックを追加する必要が出てきます。処理が呼び出し側に散らばっている分だけ、修正漏れの可能性が増えるということです。
After の書き方であれば、ルールの変更は VinylRecord.discount() の中身を直すだけで完結します。この 「変更箇所が1箇所に閉じている」 状態を、本書では繰り返し「凝集度が高い」という言葉で表現していました。実務でクラス設計をレビューする際も、 「このルールを直したいとき、何ファイルを開く必要があるか」 は良い判断基準になると感じています。
手法2: 増え続けるif/elif → ストラテジパターン
中古レコードには「盤質(コンディション)」というランクがあります。「ほぼ新品(MINT)」「多少の使用感あり(VG+)」「傷や汚れがある(GOOD)」といった具合に、状態によって買取価格の計算方法が変わります。
これを最初に実装するとき、多くの人は「盤質によってif文で分岐すればいい」と考えます。実際、ランクが3〜4種類のうちはそれで問題なく動きます。ただ、後から「限定盤は査定額を上げる」「傷ありランクを2段階に分ける」といった要望が来るたびに、このif文はどんどん長くなっていきます。しかも、同じような分岐が「価格計算」だけでなく「表示用のラベル生成」など別の場所にもコピーされていくと、片方だけ直して片方を直し忘れる、という事故が起きやすくなります。
コードで見てみます。
Before: 盤の状態ごとに分岐が増えていく
class PriceCalculator {
public int calculate(String condition, int basePrice) {
if (condition.equals("MINT")) {
return basePrice;
} else if (condition.equals("VG_PLUS")) {
return (int) (basePrice * 0.8);
} else if (condition.equals("GOOD")) {
return (int) (basePrice * 0.5);
} else {
return (int) (basePrice * 0.2);
}
}
}
新しい盤質のランクを追加するたびに、この if-else を直接修正する必要があります。同じような分岐が別の場所(表示用のラベル生成など)にも存在すると、修正漏れが起きやすくなります。
この問題への対処法として本書が紹介していたのが「ストラテジパターン」です。考え方はシンプルで、「盤質ごとの計算方法」をそれぞれ別のクラスとして切り出し、呼び出す側は「どのクラスを使うか」を選ぶだけにする、というものです。こうすることで、ランクが増えても既存の分岐を触らずに、新しいクラスを1つ追加するだけで対応できるようになります。
After: 状態ごとの振る舞いをクラスに閉じ込める(ストラテジパターン)
interface ConditionPricing {
int apply(int basePrice);
}
class MintPricing implements ConditionPricing {
public int apply(int basePrice) {
return basePrice;
}
}
class VgPlusPricing implements ConditionPricing {
public int apply(int basePrice) {
return (int) (basePrice * 0.8);
}
}
class GoodPricing implements ConditionPricing {
public int apply(int basePrice) {
return (int) (basePrice * 0.5);
}
}
class PoorPricing implements ConditionPricing {
public int apply(int basePrice) {
return (int) (basePrice * 0.2);
}
}
class PriceCalculator {
public int calculate(ConditionPricing pricing, int basePrice) {
return pricing.apply(basePrice);
}
}
呼び出し側は calculate の中身を意識せず、盤質に対応する ConditionPricing を渡すだけになります。
状態が増えたときの挙動を比較する
例えば「限定盤(LIMITED)」という新しい査定ランクを追加したいとします。
Before の実装では、PriceCalculator の if-else を直接開いて、条件を1つ追加する必要があります。既存の条件分岐の間に新しい条件を差し込む形になるため、既存のロジックを壊さないよう注意深く読む必要があります。
After の実装であれば、既存クラスには一切手を触れず、新しいクラスを追加するだけで完結します。
class LimitedEditionPricing implements ConditionPricing {
public int apply(int basePrice) {
return (int) (basePrice * 1.5);
}
}
MintPricing や VgPlusPricing のコードを読み返す必要も、修正する必要もありません。既存のクラスに影響を与えずに機能を追加できる、という状態が「オープン・クローズドの原則」と呼ばれるものだと理解しました。 「分岐そのものをなくす」 という発想が、この本を通して一番印象に残った部分でした。
手法3: 曖昧な名前 → 目的ベースの命名
在庫が切れているかどうかを表す変数や、レコードの種類を表す変数を作るとき、つい flag や type のような汎用的な名前を付けてしまうことがあります。書いている本人はその瞬間「意味」を分かっていますが、半年後の自分や、初めてそのコードを読む人にとっては、名前だけでは何を表しているのか分かりません。 結局、使われている箇所を1つずつ追いかけて意味を推測する、という無駄な時間が発生します。
具体的なコードで比較してみます。
Before: 何のための値か分からない
boolean flag = record.getStock() == 0;
int type = 2; // 1: 新品, 2: 中古, 3: 廃盤
flag や type という名前からは、それが何を表しているのか読み取れません。コメントを読むか、使われ方を追わないと意味が分からず、レビュー時に質問が増える原因になります。
After: 名前に目的を持たせる
boolean isOutOfStock = record.getStock() == 0;
enum RecordCategory {
NEW,
USED,
OUT_OF_PRINT
}
isOutOfStock であれば、コードを読んだ瞬間に「在庫切れかどうかを表すフラグ」だと分かります。また type を数値の int にするのではなく enum にすることで、「1, 2, 3以外の値が入ってしまう」という不正な状態そのものを防げます。名前だけでなく、型で意図を表現するという考え方も本書から学んだポイントでした。
メソッド名にも同じ考え方が当てはまる
命名の話は変数だけでなく、メソッドにも当てはまります。
// Before: 何をどう更新するのか分からない
void update(VinylRecord record, int value);
// After: 何を、どう変えるのかが名前だけで伝わる
void restock(VinylRecord record, int addedQuantity);
void markAsOutOfPrint(VinylRecord record);
update という名前は汎用的すぎて、呼び出し側のコードを読まないと「何が更新されるのか」が分かりません。restock や markAsOutOfPrint であれば、メソッド名だけで目的が伝わり、呼び出し側のコードも自然と読みやすくなります。本書では、こうし 「目的が一目で伝わる名前」 を徹底することが、コメントに頼らない可読性につながると説明されていました。
まとめ: 駆け出しのいま、意識したいこと
3つの手法に共通していたのは、 「ルールや判断を、使う側ではなく“データを持つ側”に寄せる」 という考え方でした。
- クラスは、データと一緒にそのデータに関するルールも持つ
- 分岐が増えそうな箇所は、最初から増える前提でクラスを分けておく
- 名前は「型」であり、間違った使い方を型やコンパイラに防いでもらう
駆け出しのいまは、まず「動くコード」を書けるようになることを優先しがちですが、レビューで指摘をいただく箇所の多くは、今回紹介したような設計の考え方に関わるものでした。
実際にコードを書きながら気づいたのは、「これらのテクニック自体は難しくないが、日々のコーディングの中で気づくのが難しい」ということです。分岐やクラスは、書いているその瞬間は自然に見えてしまい、後から見返して初めて「増えすぎている」と気づくことが多いからだと思います。
そのため、次にコードを書くときは、以下のような問いを自分に投げかけるようにしたいと考えています。
- このクラスは、単体で見て意味が完結しているか
- このif/elseは、今後もう1つ条件が増えたときにどうなるか
- この名前だけを見て、他の人(未来の自分含む)が意図を誤解しないか
設計は一度覚えて終わりではなく、コードを書くたびに問い直す習慣だと感じています。この本で得た視点を、実際のプロダクトコードの中で少しずつ実践していきたいです。
終わりに
本記事では、本書の中から特に印象に残った3つの考え方を紹介しました。本書には、このほかにも設計を実践するための考え方やテクニックが数多く紹介されています。設計を体系的に学びたい方は、ぜひ「良いコード/悪いコードで学ぶ設計入門」を読んでみてください。