【プログラミングしてみよう60】Javaでデータクラスを設計する(ドメインモデル入門)

Java

前回はメソッドチェーンとビルダーパターンの実践的な実装を学びました。

今回は「ドメインモデル」について解説します。 これまでString・int・doubleで値を表してきましたが、ビジネスのルールを持った「値オブジェクト」としてクラスを設計することで、バグを未然に防ぎコードの意図が明確になります。

ドメインモデルとは

「ドメイン」とはビジネスの問題領域のことです。たとえばECサイトなら「商品」「注文」「顧客」「金額」「数量」などがドメインの概念です。

これをJavaのクラスとして設計するとき、よくある書き方は以下のようなものです。

// プリミティブ型で表現する場合
String email = "panmaru@example.com";
int price = -100; // 負の値が入れられてしまう
int quantity = 0; // 0個の注文が作れてしまう

しかし「メールアドレス」「価格」「数量」はそれぞれビジネスのルールを持っています。このルールをクラスに閉じ込めた「値オブジェクト(Value Object)」として設計することで、不正な状態のオブジェクトが生まれることを防げます。

値オブジェクトの設計

値オブジェクトの特徴は3つです。

1. イミュータブル(一度作ったら変更できない)
2. バリデーションをコンストラクタで行う
3. 値が同じなら同じオブジェクトとみなす(equals/hashCode)

以前学んだ record は値オブジェクトの実装に最適です。

サンプルコード(値オブジェクトの実装)

ファイル作成

「Java48」フォルダに Money.java、Quantity.java、ProductId.java、ProductName.java、ValueObjectTest.java という名前でファイルを作成します

コード入力

Money.java に以下のコードを入力します。

import java.util.Objects;

public record Money(int amount, String currency) {
    public Money {
        if (amount < 0) {
            throw new IllegalArgumentException("金額は0以上でなければなりません: " + amount);
        }
        Objects.requireNonNull(currency, "通貨は必須です");
        if (currency.isBlank()) {
            throw new IllegalArgumentException("通貨は空にできません");
        }
        currency = currency.toUpperCase();
    }

    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("異なる通貨は加算できません: " + this.currency + " + " + other.currency);
        }
        return new Money(this.amount + other.amount, this.currency);
    }

    public Money multiply(int factor) {
        if (factor < 0) {
            throw new IllegalArgumentException("乗数は0以上でなければなりません");
        }
        return new Money(this.amount * factor, this.currency);
    }

    public boolean isGreaterThan(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("異なる通貨は比較できません");
        }
        return this.amount > other.amount;
    }

    @Override
    public String toString() {
        return amount + " " + currency;
    }
}

Quantity.java に以下のコードを入力します。

public record Quantity(int value) {
    public Quantity {
        if (value <= 0) {
            throw new IllegalArgumentException("数量は1以上でなければなりません: " + value);
        }
    }

    public Quantity add(Quantity other) {
        return new Quantity(this.value + other.value);
    }

    public boolean isGreaterThan(Quantity other) {
        return this.value > other.value;
    }

    @Override
    public String toString() {
        return value + "個";
    }
}

ProductId.java に以下のコードを入力します。

import java.util.Objects;

public record ProductId(String value) {
    public ProductId {
        Objects.requireNonNull(value, "商品IDは必須です");
        if (!value.matches("^[A-Z]{3}\\d{4}$")) {
            throw new IllegalArgumentException("商品IDの形式が正しくありません(例: ABC1234): " + value);
        }
    }

    @Override
    public String toString() {
        return value;
    }
}

ProductName.java に以下のコードを入力します。

import java.util.Objects;

public record ProductName(String value) {
    public ProductName {
        Objects.requireNonNull(value, "商品名は必須です");
        value = value.trim();
        if (value.isBlank()) {
            throw new IllegalArgumentException("商品名は空にできません");
        }
        if (value.length() > 50) {
            throw new IllegalArgumentException("商品名は50文字以内でなければなりません");
        }
    }

    @Override
    public String toString() {
        return value;
    }
}

ValueObjectTest.java に以下のコードを入力します。

public class ValueObjectTest {
    public static void main(String[] args) {
        // Moneyの使い方
        System.out.println("--- Money ---");
        Money price1 = new Money(1000, "JPY");
        Money price2 = new Money(500, "JPY");

        System.out.println("price1: " + price1);
        System.out.println("price2: " + price2);
        System.out.println("合計: " + price1.add(price2));
        System.out.println("3倍: " + price1.multiply(3));
        System.out.println("price1 > price2: " + price1.isGreaterThan(price2));

        // Quantityの使い方
        System.out.println("\n--- Quantity ---");
        Quantity qty1 = new Quantity(3);
        Quantity qty2 = new Quantity(2);
        System.out.println("qty1: " + qty1);
        System.out.println("合計: " + qty1.add(qty2));

        // ProductIdのバリデーション
        System.out.println("\n--- ProductId ---");
        try {
            ProductId id1 = new ProductId("ABC1234");
            System.out.println("有効なID: " + id1);
            new ProductId("invalid"); // 形式不正
        } catch (IllegalArgumentException e) {
            System.out.println("エラー: " + e.getMessage());
        }

        // バリデーションエラー
        System.out.println("\n--- バリデーションエラー ---");
        try {
            new Money(-100, "JPY");
        } catch (IllegalArgumentException e) {
            System.out.println("エラー: " + e.getMessage());
        }

        try {
            new Quantity(0);
        } catch (IllegalArgumentException e) {
            System.out.println("エラー: " + e.getMessage());
        }
    }
}

コンパイル

javac Money.java Quantity.java ProductId.java ProductName.java ValueObjectTest.java

実行

java ValueObjectTest

実行結果は次のようになります。

--- Money ---
price1: 1000 JPY
price2: 500 JPY
合計: 1500 JPY
3倍: 3000 JPY
price1 > price2: true

--- Quantity ---
qty1: 3個
合計: 5個

--- ProductId ---
有効なID: ABC1234
エラー: 商品IDの形式が正しくありません(例: ABC1234): invalid

--- バリデーションエラー ---
エラー: 金額は0以上でなければなりません: -100
エラー: 数量は1以上でなければなりません: 0

サンプルコード(エンティティとドメインロジック)

ファイル作成

「Java48」フォルダに Product.java、OrderItem.java、Order.java、DomainTest.java という名前でファイルを作成します

コード入力

Product.java に以下のコードを入力します。

public class Product {
    private final ProductId id;
    private final ProductName name;
    private final Money price;
    private Quantity stock;

    public Product(ProductId id, ProductName name, Money price, Quantity stock) {
        this.id    = id;
        this.name  = name;
        this.price = price;
        this.stock = stock;
    }

    public ProductId id()     { return id; }
    public ProductName name() { return name; }
    public Money price()      { return price; }
    public Quantity stock()   { return stock; }

    public boolean hasStock(Quantity required) {
        return !required.isGreaterThan(stock);
    }

    public void decreaseStock(Quantity quantity) {
        if (!hasStock(quantity)) {
            throw new IllegalStateException("在庫が不足しています: 要求=" + quantity + ", 在庫=" + stock);
        }
        stock = new Quantity(stock.value() - quantity.value());
    }

    @Override
    public String toString() {
        return String.format("Product{id=%s, name=%s, price=%s, stock=%s}", id, name, price, stock);
    }
}

OrderItem.java に以下のコードを入力します。

public record OrderItem(Product product, Quantity quantity) {
    public Money subtotal() {
        return product.price().multiply(quantity.value());
    }

    @Override
    public String toString() {
        return String.format("%s x %s = %s", product.name(), quantity, subtotal());
    }
}

Order.java に以下のコードを入力します。

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Order {
    private final List<OrderItem> items = new ArrayList<>();

    public void addItem(Product product, Quantity quantity) {
        if (!product.hasStock(quantity)) {
            throw new IllegalStateException(
                product.name() + "の在庫が不足しています");
        }
        product.decreaseStock(quantity);
        items.add(new OrderItem(product, quantity));
    }

    public Money total() {
        return items.stream()
                    .map(OrderItem::subtotal)
                    .reduce(new Money(0, "JPY"), Money::add);
    }

    public List<OrderItem> items() {
        return Collections.unmodifiableList(items);
    }
}

DomainTest.java に以下のコードを入力します。

public class DomainTest {
    public static void main(String[] args) {
        // 商品を作成
        Product coffee = new Product(
            new ProductId("DRK0001"),
            new ProductName("コーヒー"),
            new Money(350, "JPY"),
            new Quantity(10)
        );
        Product cake = new Product(
            new ProductId("SWT0001"),
            new ProductName("ケーキ"),
            new Money(500, "JPY"),
            new Quantity(3)
        );

        System.out.println("--- 商品 ---");
        System.out.println(coffee);
        System.out.println(cake);

        // 注文を作成
        Order order = new Order();
        order.addItem(coffee, new Quantity(2));
        order.addItem(cake,   new Quantity(1));

        System.out.println("\n--- 注文内容 ---");
        order.items().forEach(System.out::println);
        System.out.println("合計: " + order.total());

        System.out.println("\n--- 在庫更新後 ---");
        System.out.println(coffee);
        System.out.println(cake);

        // 在庫不足のテスト
        System.out.println("\n--- 在庫不足エラー ---");
        try {
            order.addItem(cake, new Quantity(5)); // 残り2個しかない
        } catch (IllegalStateException e) {
            System.out.println("エラー: " + e.getMessage());
        }
    }
}

コンパイル

javac Money.java Quantity.java ProductId.java ProductName.java Product.java OrderItem.java Order.java DomainTest.java

実行

java DomainTest

実行結果は次のようになります。

--- 商品 ---
Product{id=DRK0001, name=コーヒー, price=350 JPY, stock=10個}
Product{id=SWT0001, name=ケーキ, price=500 JPY, stock=3個}

--- 注文内容 ---
コーヒー x 2個 = 700 JPY
ケーキ x 1個 = 500 JPY
合計: 1200 JPY

--- 在庫更新後 ---
Product{id=DRK0001, name=コーヒー, price=350 JPY, stock=8個}
Product{id=SWT0001, name=ケーキ, price=500 JPY, stock=2個}

--- 在庫不足エラー ---
エラー: ケーキの在庫が不足しています

コードの解説

Money・Quantity・ProductId・ProductName はすべて値オブジェクトです。recordを使うことでイミュータブルな性質とequals・hashCodeが自動生成されます。コンパクトコンストラクタでバリデーションを行い、不正な値のオブジェクトが生まれることを防いでいます。

Money.add() や Money.multiply() は自分自身を変更せず新しい Money を返します。これがイミュータブルな値オブジェクトの操作の基本です。異なる通貨同士の加算を弾くように、ビジネスのルール(「異なる通貨は加算できない」)をクラスの中に閉じ込めています。

Product はエンティティです。値オブジェクトと異なり状態が変化します(stock は在庫の変化で更新される)。decreaseStock() メソッドは在庫のチェックとデクリメントをまとめて行い、呼び出す側でチェックを忘れることを防いでいます。

int price = -100 のようにプリミティブ型で値を扱っていたときは、不正な値が混入しても気づきにくかったです。値オブジェクトとして設計することで、「Money を受け取るメソッドには必ず有効な金額が渡される」という保証が型レベルで表現されます。

まとめ

ドメインモデルの設計では、ビジネスのルールをクラスの中に閉じ込めた「値オブジェクト」を使うことで、不正な状態のオブジェクトが生まれることをコンパイル時・実行時に防げます。recordはイミュータブルな値オブジェクトの実装に最適で、コンパクトコンストラクタでバリデーションを追加できます。これまで学んできたrecord・イミュータブルクラス・例外処理・コレクション・Stream APIをすべて組み合わせた、実践的な設計パターンです。ぜひ自分のプロジェクトにも取り入れてみてください。

2026/08/08(土) ぱんまる

コメント

タイトルとURLをコピーしました