はじめに
株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。
Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
▶コーポレートサイト
この記事は、新人〜2年目のJavaエンジニア向けに 「良いコードと悪いコードの違い」 を、現場でよく見る具体例とともに解説してきたシリーズの 最終回(第10回) です。
| 回 | テーマ |
|---|---|
| #1 | 命名 |
| #2 | コメントの書き方 |
| #3 | マジックナンバー・定数化 |
| #4 | Null処理 |
| #5 | 早期リターン |
| #6 | メソッド分割 |
| #7 | ループ処理 |
| #8 | 例外処理 |
| #9 | ログ出力 |
| #10(本記事・最終回) | クラス設計 |
最終回のテーマは クラス設計 です。これまでの9回で扱った「メソッドレベルの良いコード」を、さらに 「クラスレベルの良いコード」 へと昇華させていきます。
この記事のゴール
この記事を読み終わると、以下ができるようになります。
- 「1クラス1責務」の判断ができる
- 値オブジェクトを不変クラスとして設計できる
- 「getter/setterの塊」を脱却して、振る舞いを持つクラスを設計できる
「悪いクラス設計」の本当のコスト
新人〜2年目のコードによく見られるのが、こんなクラスです。
public class Order {
private long id;
private String customerName;
private String customerEmail;
private int unitPrice;
private int quantity;
private double taxRate;
private int discountAmount;
private String status;
private String shippingAddress;
private String paymentMethod;
private boolean isCanceled;
private boolean isPaid;
// ... 30個のフィールド
// 全フィールドのgetterとsetter(60個のメソッド)
public long getId() { return id; }
public void setId(long id) { this.id = id; }
public String getCustomerName() { return customerName; }
public void setCustomerName(String customerName) { this.customerName = customerName; }
// ...
}
そしてこれを使う側は、こんなコードを書きます。
public class OrderService {
public int calculateFinalPrice(Order order) {
int subtotal = order.getUnitPrice() * order.getQuantity();
int taxed = (int) (subtotal * (1 + order.getTaxRate()));
int finalPrice = taxed - order.getDiscountAmount();
return finalPrice;
}
}
書いた本人は「データを保持するクラス」と「処理を行うクラス」を分離したつもりです。
ですが、これは典型的な 「貧血ドメインモデル(Anemic Domain Model)」 と呼ばれるアンチパターンです。
このような設計には3つのコストがあります。
- データの整合性が壊れやすい:どこからでも setter で書き換えられる
- 業務ロジックの居場所が散在する:複数のサービスクラスで同じ計算が書かれる
- 読みづらい:データと振る舞いが別々の場所にあるため、コードを追うのが困難
押さえるべきは3原則
新人〜2年目がまず身につけるべきクラス設計の原則は、以下の3つです。
- 1クラス1責務に保つ
- 値オブジェクトは不変クラスにする
- 振る舞いを持つクラスにする
順番に見ていきます。
原則① 1クラス1責務に保つ
クラスもメソッド(#6参照)と同じく、1つの責務だけを持つ のが原則です。
これは 単一責任原則(Single Responsibility Principle, SRP) と呼ばれる、オブジェクト指向設計の最も基本的なルールです。
「1つの責務」とは
クラスを説明するとき、「と」「および」 が含まれていたら複数責務の合図です。
| クラス説明 | 1責務? |
|---|---|
| ユーザーを永続化する | ✅ 1つ |
| ユーザーをバリデーションする | ✅ 1つ |
| ユーザーに通知を送る | ✅ 1つ |
| ユーザーを バリデーション・永続化・通知 する | ❌ 3つ |
悪い例(神クラス)
public class UserService {
public void registerUser(User user) {
// バリデーション
if (user.getName() == null) {
throw new IllegalArgumentException("名前がnull");
}
if (user.getEmail() == null) {
throw new IllegalArgumentException("メールがnull");
}
// DB保存
Connection conn = DriverManager.getConnection(jdbcUrl, dbUser, dbPassword);
PreparedStatement stmt = conn.prepareStatement("INSERT INTO users (name, email) VALUES (?, ?)");
stmt.setString(1, user.getName());
stmt.setString(2, user.getEmail());
stmt.executeUpdate();
// メール送信
Session session = Session.getInstance(mailProperties);
Message message = new MimeMessage(session);
message.setRecipient(Message.RecipientType.TO, new InternetAddress(user.getEmail()));
message.setSubject("ようこそ");
Transport.send(message);
// 監査ログ
File log = new File("audit.log");
try (FileWriter writer = new FileWriter(log, true)) {
writer.write("ユーザー登録: " + user.getEmail() + "\n");
}
}
}
このクラスは 「ユーザー登録のすべて」 を抱えています。
バリデーション・DB・メール・ファイル操作という 4つの異なる責務 が同居していて、テストも修正も非常に困難です。
良い例(責務ごとに分割)
public class UserRegistrationService {
private final UserValidator validator;
private final UserRepository repository;
private final EmailNotifier notifier;
public UserRegistrationService(UserValidator validator, UserRepository repository, EmailNotifier notifier) {
this.validator = validator;
this.repository = repository;
this.notifier = notifier;
}
public void register(User user) {
validator.validate(user);
repository.save(user);
notifier.sendWelcomeEmail(user);
}
}
public class UserValidator { /* バリデーション専用 */ }
public class UserRepository { /* DB操作専用 */ }
public class EmailNotifier { /* メール送信専用 */ }
各クラスが 1つの責務だけ を持ち、コンストラクタで依存を受け取る という構造になっています。
分割によるメリット
| メリット | 説明 |
|---|---|
| テストしやすい | 各クラスを独立して単体テストできる |
| モックしやすい | テスト時に依存をモックに差し替えられる |
| 理解しやすい | クラス名を見るだけで何をするか分かる |
| 再利用しやすい |
EmailNotifier は別の処理からも呼べる |
原則② 値オブジェクトは不変クラスにする
「金額」「日付」「住所」のような 値を表すクラス は、不変(immutable) にします。
不変クラスの定義
不変クラスとは、インスタンス生成後に状態が変わらないクラス のことです。
具体的には以下の特徴を持ちます。
- すべてのフィールドが
private final - setter を持たない
- 状態を変える操作は、新しいインスタンスを返す
悪い例(可変な値クラス)
public class Money {
private int amount;
public Money(int amount) {
this.amount = amount;
}
public void setAmount(int amount) {
this.amount = amount; // ← どこからでも書き換え可能
}
public int getAmount() {
return amount;
}
public void applyTax(double rate) {
this.amount = (int) (this.amount * (1 + rate)); // ← 自分自身を書き換え
}
}
このクラスは、applyTax を呼ぶたびに 元の金額が変わってしまいます。
Money price = new Money(1000);
price.applyTax(0.10);
System.out.println(price.getAmount()); // → 1100(元の1000が失われた)
price.applyTax(0.10); // 再適用すると...
System.out.println(price.getAmount()); // → 1210(二重課税!)
「元の価格を取っておきたい」と思っても、変更されてしまいます。
良い例(不変クラス)
public final class Money {
private final int amountJpy;
public Money(int amountJpy) {
if (amountJpy < 0) {
throw new IllegalArgumentException("金額は0以上を指定: " + amountJpy);
}
this.amountJpy = amountJpy;
}
public Money applyTax(double taxRate) {
return new Money((int) (this.amountJpy * (1 + taxRate))); // ← 新しいインスタンスを返す
}
public Money add(Money other) {
return new Money(this.amountJpy + other.amountJpy);
}
public int getAmount() {
return amountJpy;
}
}
Money price = new Money(1000);
Money taxed = price.applyTax(0.10);
System.out.println(price.getAmount()); // → 1000(元の値は変わらない)
System.out.println(taxed.getAmount()); // → 1100(新しいインスタンス)
price は 絶対に変わりません。安心して他のメソッドに渡せます。
不変クラスのメリット
| メリット | 説明 |
|---|---|
| 安全 | どこかで書き換えられる心配がない |
| スレッドセーフ | 複数スレッドからアクセスしても安全 |
| コンストラクタで不変条件をチェック | 例:金額は0以上、と一度チェックすれば常に保証 |
| キャッシュ可能 | 同じ値なら使い回せる(Integer.valueOf のように) |
record で簡潔に書く(Java 16以降)
Java 16以降は、不変クラスを record で簡潔に書けます。
public record Money(int amountJpy) {
public Money {
if (amountJpy < 0) {
throw new IllegalArgumentException("金額は0以上: " + amountJpy);
}
}
public Money applyTax(double rate) {
return new Money((int) (amountJpy * (1 + rate)));
}
}
record は自動で以下を生成します:
- すべてのフィールドが
final - コンストラクタ
- アクセサメソッド(
amountJpy()のような形) -
equals、hashCode、toString
新規案件で Java 16以降 が使えるなら、値オブジェクトは record で書きましょう。
原則③ 振る舞いを持つクラスにする
「getter/setterの塊」をやめて、業務ロジックをクラスの内側に置きます。
悪い例(貧血モデル)
public class BankAccount {
private int balance;
public int getBalance() { return balance; }
public void setBalance(int balance) { this.balance = balance; }
}
// 使う側
public class BankService {
public void withdraw(BankAccount account, int amount) {
if (account.getBalance() < amount) {
throw new IllegalStateException("残高不足");
}
account.setBalance(account.getBalance() - amount);
}
}
問題点:
-
BankAccountは データを保持するだけ のクラス - 「残高不足チェック」「引き出し処理」が外部の
BankServiceにある - どこからでも
setBalance(-1000000)のように不正な値を設定できる - 同じ業務ロジックが複数のサービスに書かれる可能性
良い例(振る舞いを持つクラス)
public class BankAccount {
private Money balance;
public BankAccount(int initialBalance) {
this.balance = new Money(initialBalance);
}
public void deposit(Money amount) {
this.balance = balance.add(amount);
}
public void withdraw(Money amount) throws InsufficientBalanceException {
if (!balance.isGreaterThanOrEqual(amount)) {
throw new InsufficientBalanceException("残高不足: 残高=" + balance);
}
this.balance = new Money(balance.getAmount() - amount.getAmount());
}
public Money getBalance() {
return balance;
}
}
このクラスは:
- 業務ロジック(残高不足チェック、引き出し)が内側にある
- setter を公開していないので、不正な状態にできない
-
BankAccountを使う側は、ただaccount.withdraw(amount)と呼ぶだけ
これが 「振る舞いを持つクラス」 の姿です。
setterの代わりに業務メソッドを公開する
| 悪い例 | 良い例 |
|---|---|
account.setBalance(newBalance) |
account.deposit(amount) / account.withdraw(amount)
|
user.setActive(true) |
user.activate() / user.deactivate()
|
order.setStatus(STATUS_PAID) |
order.markAsPaid() |
cart.setItems(newItems) |
cart.addItem(item) / cart.removeItem(item)
|
公開するのは「状態の変更」ではなく「業務的な操作」です。
動作確認:3原則を全部適用したサンプル
3つの原則をすべて適用したコード例です。コピペでそのまま動かせます。
import java.util.List;
import java.util.ArrayList;
import java.util.Collections;
public class ClassDemo {
public static void main(String[] args) {
// 原則①:1クラス1責務
Cart cart = new Cart();
cart.addItem(new CartItem("Java本", new Money(2500), 2));
cart.addItem(new CartItem("SQL本", new Money(3000), 1));
Money total = cart.calculateTotal();
System.out.println("カート合計: " + total);
// 原則②:不変クラス
Money price = new Money(1000);
Money taxed = price.applyTax(0.10);
System.out.println("元の価格(不変): " + price);
System.out.println("税込: " + taxed);
// 原則③:振る舞いを持つクラス
BankAccount account = new BankAccount(10000);
account.deposit(new Money(5000));
try {
account.withdraw(new Money(3000));
System.out.println("残高: " + account.getBalance());
} catch (InsufficientBalanceException e) {
System.out.println("エラー: " + e.getMessage());
}
}
}
// 不変クラス(Money)
final class Money {
private final int amountJpy;
Money(int amountJpy) {
if (amountJpy < 0) {
throw new IllegalArgumentException("金額は0以上を指定: " + amountJpy);
}
this.amountJpy = amountJpy;
}
int getAmount() { return amountJpy; }
Money add(Money other) {
return new Money(this.amountJpy + other.amountJpy);
}
Money multiply(int times) {
return new Money(this.amountJpy * times);
}
Money applyTax(double taxRate) {
return new Money((int) (this.amountJpy * (1 + taxRate)));
}
boolean isGreaterThanOrEqual(Money other) {
return this.amountJpy >= other.amountJpy;
}
@Override
public String toString() {
return amountJpy + "円";
}
}
// 不変クラス(CartItem)
final class CartItem {
private final String name;
private final Money unitPrice;
private final int quantity;
CartItem(String name, Money unitPrice, int quantity) {
this.name = name;
this.unitPrice = unitPrice;
this.quantity = quantity;
}
Money subtotal() {
return unitPrice.multiply(quantity);
}
String getName() { return name; }
}
// 振る舞いを持つクラス(Cart)
class Cart {
private final List<CartItem> items = new ArrayList<>();
void addItem(CartItem item) {
items.add(item);
}
Money calculateTotal() {
Money total = new Money(0);
for (CartItem item : items) {
total = total.add(item.subtotal());
}
return total;
}
List<CartItem> getItems() {
return Collections.unmodifiableList(items);
}
}
// 振る舞いを持つクラス(BankAccount)
class BankAccount {
private Money balance;
BankAccount(int initialBalance) {
this.balance = new Money(initialBalance);
}
void deposit(Money amount) {
this.balance = balance.add(amount);
}
void withdraw(Money amount) throws InsufficientBalanceException {
if (!balance.isGreaterThanOrEqual(amount)) {
throw new InsufficientBalanceException("残高不足: 残高=" + balance + " 引き出し=" + amount);
}
this.balance = new Money(balance.getAmount() - amount.getAmount());
}
Money getBalance() {
return balance;
}
}
class InsufficientBalanceException extends Exception {
public InsufficientBalanceException(String message) { super(message); }
}
期待する出力
カート合計: 8000円
元の価格(不変): 1000円
税込: 1100円
残高: 12000円
半日溶かした実話:直接フィールドを書き換えるバグ
「会員のポイントが、特定の操作後にマイナスになってしまう」という不可解なバグ報告から始まった話です。
調査してみると、Member クラスは典型的な貧血モデルでした。
public class Member {
private int points;
public int getPoints() { return points; }
public void setPoints(int points) { this.points = points; }
}
そして、ポイントを消費するコードはこんな具合:
// あるサービスクラス
member.setPoints(member.getPoints() - usedPoints);
問題点:
-
setPointsで 負の値もそのまま設定できる - ポイント消費ロジックが 複数のサービスクラスに散在
- どのサービスでチェック漏れがあるのか、特定に半日以上かかった
修正後はこうしました:
public class Member {
private int points;
public void usePoints(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("使用ポイントは正の値: " + amount);
}
if (this.points < amount) {
throw new InsufficientPointsException("ポイント不足: 残=" + this.points + " 使用=" + amount);
}
this.points -= amount;
}
public void addPoints(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("追加ポイントは正の値: " + amount);
}
this.points += amount;
}
public int getPoints() { return points; }
}
ポイントを変更するロジックを Member クラスに集約しました。
「setterを公開しない」「業務メソッドだけを公開する」 ことで、不正な状態にできなくなりました。
そして全サービスを書き換えました:
// Before
member.setPoints(member.getPoints() - usedPoints);
// After
member.usePoints(usedPoints);
この修正後、同じ種類のバグは 二度と発生していません。
「正しい状態」をクラスの内側で守れば、外側のコードは間違えようがありません。
演習問題
難易度の見方
| マーク | 難易度 | 目安 |
|---|---|---|
| ⭐ | 基本 | 原則を覚えれば解ける |
| ⭐⭐ | 応用 | 複数の原則を組み合わせる |
まずは自分で考えてから、模範解答を見てください!
問題1:神クラスを分割する ⭐
次の UserService は「バリデーション」「DB保存」「メール送信」の3つの責務を持っています。
3つのクラスに分割してください(UserValidator、UserRepository、EmailNotifier)。
public class Sample {
public static void main(String[] args) {
UserService service = new UserService();
service.register(new User("田中", "tanaka@example.com"));
}
}
class UserService {
void register(User user) {
// バリデーション
if (user.getName() == null || user.getName().isBlank()) {
throw new IllegalArgumentException("名前が不正");
}
System.out.println("[Validator] バリデーションOK: " + user.getName());
// DB保存
System.out.println("[Repository] DB保存: " + user.getName());
// メール送信
System.out.println("[Notifier] メール送信: " + user.getEmail());
}
}
class User {
private final String name;
private final String email;
User(String name, String email) { this.name = name; this.email = email; }
String getName() { return name; }
String getEmail() { return email; }
}
模範解答
public class Exercise01 {
public static void main(String[] args) {
UserRegistrationService service = new UserRegistrationService(
new UserValidator(),
new UserRepository(),
new EmailNotifier()
);
service.register(new User("田中", "tanaka@example.com"));
}
}
class UserRegistrationService {
private final UserValidator validator;
private final UserRepository repository;
private final EmailNotifier notifier;
UserRegistrationService(UserValidator validator, UserRepository repository, EmailNotifier notifier) {
this.validator = validator;
this.repository = repository;
this.notifier = notifier;
}
void register(User user) {
validator.validate(user);
repository.save(user);
notifier.sendWelcomeEmail(user);
}
}
class User {
private final String name;
private final String email;
User(String name, String email) { this.name = name; this.email = email; }
String getName() { return name; }
String getEmail() { return email; }
}
class UserValidator {
void validate(User user) {
if (user.getName() == null || user.getName().isBlank()) {
throw new IllegalArgumentException("名前が不正");
}
System.out.println("[Validator] バリデーションOK: " + user.getName());
}
}
class UserRepository {
void save(User user) {
System.out.println("[Repository] DB保存: " + user.getName());
}
}
class EmailNotifier {
void sendWelcomeEmail(User user) {
System.out.println("[Notifier] メール送信: " + user.getEmail());
}
}
ポイント:
- 各クラスが1つの責務だけを持つ
-
UserRegistrationServiceは他のクラスを コンストラクタで受け取る(依存性注入) - テスト時には各クラスを個別にテストできる、またはモックに差し替えられる
問題2:不変な Price クラスを作る ⭐
「価格」を表す不変クラス Price を作ってください。
- 金額(円)を保持
- 0未満は例外
-
applyTax(double)で税込価格を新しいPriceとして返す -
discount(double)で割引適用後のPriceを新しいインスタンスとして返す
public class Sample {
public static void main(String[] args) {
Price price = new Price(1000);
Price taxed = price.applyTax(0.10);
Price discounted = taxed.discount(0.05);
System.out.println("元の価格: " + price);
System.out.println("税込: " + taxed);
System.out.println("税込・割引後: " + discounted);
}
}
// ここに Price クラスを書く
模範解答
public class Exercise02 {
public static void main(String[] args) {
Price price = new Price(1000);
Price taxed = price.applyTax(0.10);
Price discounted = taxed.discount(0.05);
System.out.println("元の価格: " + price);
System.out.println("税込: " + taxed);
System.out.println("税込・割引後: " + discounted);
}
}
final class Price {
private final int amountJpy;
Price(int amountJpy) {
if (amountJpy < 0) {
throw new IllegalArgumentException("価格は0以上を指定: " + amountJpy);
}
this.amountJpy = amountJpy;
}
Price applyTax(double taxRate) {
return new Price((int) (this.amountJpy * (1 + taxRate)));
}
Price discount(double rate) {
return new Price((int) (this.amountJpy * (1 - rate)));
}
int getAmount() { return amountJpy; }
@Override
public String toString() { return amountJpy + "円"; }
}
期待する出力
元の価格: 1000円
税込: 1100円
税込・割引後: 1045円
ポイント:
- クラス自体に
finalを付けて継承不可に - フィールドに
final、setterなし - 状態を変える操作は新しいインスタンスを返す
- コンストラクタで不変条件(0以上)をチェック
問題3:振る舞いを持つ Stock クラスを作る ⭐
「在庫」を表すクラス Stock を作ってください。
- 数量を保持
-
add(int)で入庫(負の値は例外) -
remove(int)で出庫(在庫不足は例外) - setter は公開しない
public class Sample {
public static void main(String[] args) {
Stock stock = new Stock(10);
stock.add(5);
System.out.println("入庫後: " + stock.getQuantity());
stock.remove(3);
System.out.println("出庫後: " + stock.getQuantity());
try {
stock.remove(100);
} catch (IllegalStateException e) {
System.out.println("エラー: " + e.getMessage());
}
}
}
// ここに Stock クラスを書く
模範解答
public class Exercise03 {
public static void main(String[] args) {
Stock stock = new Stock(10);
stock.add(5);
System.out.println("入庫後: " + stock.getQuantity());
stock.remove(3);
System.out.println("出庫後: " + stock.getQuantity());
try {
stock.remove(100);
} catch (IllegalStateException e) {
System.out.println("エラー: " + e.getMessage());
}
}
}
class Stock {
private int quantity;
Stock(int quantity) {
if (quantity < 0) {
throw new IllegalArgumentException("初期在庫は0以上: " + quantity);
}
this.quantity = quantity;
}
void add(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("入庫数は正の値: " + amount);
}
this.quantity += amount;
}
void remove(int amount) {
if (amount <= 0) {
throw new IllegalArgumentException("出庫数は正の値: " + amount);
}
if (amount > quantity) {
throw new IllegalStateException("在庫不足: 在庫=" + quantity + " 出庫=" + amount);
}
this.quantity -= amount;
}
int getQuantity() {
return quantity;
}
}
期待する出力
入庫後: 15
出庫後: 12
エラー: 在庫不足: 在庫=12 出庫=100
ポイント:
-
setQuantityを公開しない - 業務操作(
add、remove)だけを公開 - 不変条件(数量 >= 0、在庫不足にしない)をクラス内部で守る
-
IllegalArgumentException(引数不正)とIllegalStateException(状態不正)を使い分ける
問題4:3原則を全部使った業務クラス設計 ⭐⭐
次の貧血モデルを、3原則を踏まえて全面的に書き直してください。
仕様:
- 注文(Order)には顧客(Customer)と小計(Money)がある
- OrderProcessor で「バリデーション → 税込価格計算 → 保存」を行う
- 各責務を別クラスに分割
- Customer、Money、Order、OrderResult は不変クラス
- OrderProcessor、OrderValidator、PriceCalculator、OrderRepository を分割
public class Sample {
public static void main(String[] args) {
Customer customer = new Customer();
customer.setName("田中");
customer.setEmail("tanaka@example.com");
Order order = new Order();
order.setCustomer(customer);
order.setSubtotal(5000);
OrderProcessor processor = new OrderProcessor();
String result = processor.process(order);
System.out.println(result);
}
}
class Customer {
private String name;
private String email;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
class Order {
private Customer customer;
private int subtotal;
public Customer getCustomer() { return customer; }
public void setCustomer(Customer customer) { this.customer = customer; }
public int getSubtotal() { return subtotal; }
public void setSubtotal(int subtotal) { this.subtotal = subtotal; }
}
class OrderProcessor {
String process(Order order) {
if (order.getCustomer() == null) throw new IllegalArgumentException("顧客がnull");
int taxed = (int) (order.getSubtotal() * 1.10);
System.out.println("[DB保存] 顧客=" + order.getCustomer().getName() + " 金額=" + taxed + "円");
return "注文完了: 顧客=" + order.getCustomer().getName() + " 金額=" + taxed + "円";
}
}
模範解答
public class Exercise04 {
public static void main(String[] args) {
OrderProcessor processor = new OrderProcessor(
new OrderValidator(),
new PriceCalculator(),
new OrderRepository()
);
Order order = new Order(new Customer("田中", "tanaka@example.com"), new Money(5000));
OrderResult result = processor.process(order);
System.out.println(result);
}
}
class OrderProcessor {
private final OrderValidator validator;
private final PriceCalculator calculator;
private final OrderRepository repository;
OrderProcessor(OrderValidator validator, PriceCalculator calculator, OrderRepository repository) {
this.validator = validator;
this.calculator = calculator;
this.repository = repository;
}
OrderResult process(Order order) {
validator.validate(order);
Money finalPrice = calculator.applyTax(order.getSubtotal());
repository.save(order, finalPrice);
return new OrderResult(order.getCustomer().getName(), finalPrice);
}
}
class OrderValidator {
void validate(Order order) {
if (order.getCustomer() == null) {
throw new IllegalArgumentException("顧客がnull");
}
}
}
class PriceCalculator {
private static final double TAX_RATE = 0.10;
Money applyTax(Money subtotal) {
return subtotal.applyTax(TAX_RATE);
}
}
class OrderRepository {
void save(Order order, Money finalPrice) {
System.out.println("[DB保存] 顧客=" + order.getCustomer().getName() + " 金額=" + finalPrice);
}
}
final class Customer {
private final String name;
private final String email;
Customer(String name, String email) { this.name = name; this.email = email; }
String getName() { return name; }
String getEmail() { return email; }
}
final class Money {
private final int amountJpy;
Money(int amountJpy) {
if (amountJpy < 0) throw new IllegalArgumentException("金額は0以上: " + amountJpy);
this.amountJpy = amountJpy;
}
Money applyTax(double rate) { return new Money((int) (amountJpy * (1 + rate))); }
int getAmount() { return amountJpy; }
@Override public String toString() { return amountJpy + "円"; }
}
final class Order {
private final Customer customer;
private final Money subtotal;
Order(Customer customer, Money subtotal) {
this.customer = customer; this.subtotal = subtotal;
}
Customer getCustomer() { return customer; }
Money getSubtotal() { return subtotal; }
}
final class OrderResult {
private final String customerName;
private final Money finalPrice;
OrderResult(String customerName, Money finalPrice) {
this.customerName = customerName; this.finalPrice = finalPrice;
}
@Override public String toString() {
return "注文完了: 顧客=" + customerName + " 金額=" + finalPrice;
}
}
期待する出力
[DB保存] 顧客=田中 金額=5500円
注文完了: 顧客=田中 金額=5500円
改善ポイント
| 観点 | 元のコード | 改善後 |
|---|---|---|
| クラス責務 | OrderProcessor が3つの責務 | Validator / Calculator / Repository に分割 |
| 値オブジェクト | int で金額を扱う | Money(不変)でラップ |
| データ可変性 | Customer / Order がsetter持ち | すべてfinal、コンストラクタで設定 |
| 不変条件 | チェックなし | Money は0以上をコンストラクタで保証 |
| 業務ロジックの居場所 | OrderProcessor の中に税率がハードコード | PriceCalculator に集約 |
ポイント:
- 「責務の分離」と「不変クラス」と「振る舞いを持たせる」が組み合わさると、設計が驚くほどシンプルになる
-
recordが使える環境なら、CustomerやMoneyはrecordで書くとさらに簡潔 - 業務ロジック(税率の適用)は 計算する責務を持つクラス に置く
まとめ
新人〜2年目が押さえるべきクラス設計の3原則は、以下の3つです。
- 1クラス1責務に保つ:単一責任原則、「と」「および」が含まれたら分割
- 値オブジェクトは不変クラスにする:すべてfinal、setterなし、状態変更は新インスタンス
- 振る舞いを持つクラスにする:setterではなく業務メソッドを公開
クラス設計の本質は 「正しい状態をクラスの内側で守る」 ことです。
外側に「正しい使い方」を要求するのではなく、クラスの内側で「間違った使い方を禁止する」 のが良い設計です。
全10回のまとめ
本シリーズで扱った10の原則を、まとめて振り返ります。
| 回 | テーマ | 重要原則 |
|---|---|---|
| #1 | 命名 | 役割で名付ける/単位を含める/Booleanは状態 |
| #2 | コメント | コードで分かることは書かない/WHYを書く/Javadocは契約 |
| #3 | マジックナンバー | 数値は定数化/関連定数はenum/文字列も定数化 |
| #4 | Null処理 | 早期リターン/nullを返さない/Optionalの正しい使い方 |
| #5 | 早期リターン | ガード節/elseを消す/ループはcontinue |
| #6 | メソッド分割 | 1メソッド1責務/抽象度を揃える/意図が分かる名前 |
| #7 | ループ処理 | 拡張for優先/1ループ1責務/Stream API |
| #8 | 例外処理 | 握り潰さない/業務例外を分ける/try-with-resources |
| #9 | ログ出力 | Loggerを使う/ログレベルを使い分ける/PIIを漏らさない |
| #10 | クラス設計 | 1クラス1責務/不変クラス/振る舞いを持つ |
これらに共通するのは、「未来の自分とチームへの配慮」 です。
書く瞬間の楽さより、保守する瞬間の楽さを優先する。これがプロのコードと、新人のコードを分ける本質です。
10回分の原則を すべて完璧に実践する必要はありません。
まずは1つのコードを書くときに、「3原則のうち1つでも意識する」 ことから始めましょう。
半年後には、書くコードの質が劇的に変わっているはずです。
おわりに:シリーズを終えて
新人〜2年目のJavaエンジニア向けに「良いコード・悪いコード」を10回にわたって解説してきました。
シリーズを書き始めた動機は、私自身がSESで4年Javaを書いてきて、「同じ種類の悪いコード」を何百回も見てきた からです。
新人が悪いコードを書くのは、能力の問題ではありません。「悪いコードがなぜ悪いか」を体系的に学ぶ機会がない からです。
この10記事が、その学びの機会の一助になれば嬉しいです。
質問・感想・「うちの現場ではこうしている」というフィードバックがあれば、Xでお気軽にどうぞ。
参考
- Robert C. Martin - Clean Code
- Effective Java 第3版(Joshua Bloch)
- リーダブルコード(オライリー・ジャパン)
- Java言語仕様 - Records
@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!