前回はメソッドチェーンとビルダーパターンの実践的な実装を学びました。
今回は「ドメインモデル」について解説します。 これまで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(土) ぱんまる


コメント