1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Javaの良いコード・悪いコード #10・最終回】貧血ドメインを卒業する「クラス設計」の3原則

1
Posted at

はじめに

株式会社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つのコストがあります。

  1. データの整合性が壊れやすい:どこからでも setter で書き換えられる
  2. 業務ロジックの居場所が散在する:複数のサービスクラスで同じ計算が書かれる
  3. 読みづらい:データと振る舞いが別々の場所にあるため、コードを追うのが困難

押さえるべきは3原則

新人〜2年目がまず身につけるべきクラス設計の原則は、以下の3つです。

  1. 1クラス1責務に保つ
  2. 値オブジェクトは不変クラスにする
  3. 振る舞いを持つクラスにする

順番に見ていきます。


原則① 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() のような形)
  • equalshashCodetoString

新規案件で 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つのクラスに分割してください(UserValidatorUserRepositoryEmailNotifier)。

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 を公開しない
  • 業務操作(addremove)だけを公開
  • 不変条件(数量 >= 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 が使える環境なら、CustomerMoneyrecord で書くとさらに簡潔
  • 業務ロジック(税率の適用)は 計算する責務を持つクラス に置く

まとめ

新人〜2年目が押さえるべきクラス設計の3原則は、以下の3つです。

  1. 1クラス1責務に保つ:単一責任原則、「と」「および」が含まれたら分割
  2. 値オブジェクトは不変クラスにする:すべてfinal、setterなし、状態変更は新インスタンス
  3. 振る舞いを持つクラスにする: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でお気軽にどうぞ。


参考


@kotaro_ai_lab
AI活用や開発効率化について発信しています。フォローお気軽にどうぞ!

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?