Java | オブジェクト指向:リッチドメインモデル

Java Java
スポンサーリンク

リッチドメインモデルとは何か

リッチドメインモデルは
「ドメイン(業務)の知識とルールを、“それっぽい名前だけの入れ物”ではなく、クラスの中身としてちゃんと持たせよう」
という考え方です。

もっとストレートに言うと、

注文なら「注文として成立する条件」「合計金額の計算」
ユーザなら「メール変更のルール」「ポイント加算のルール」
口座なら「残高の増減ルール」「引き出し条件」

みたいな“ビジネスの話”を、Service クラスの if 文に散らばせるのではなく、
Order や User、Account といった「それを表すクラス自身」に持たせるスタイルです。

貧血モデルが「フィールド+getter/setterだけで、中身がスカスカ」なのに対して、
リッチドメインモデルは「状態(フィールド)と振る舞い(メソッド)の両方をしっかり持ったクラス」です。


まずは対比から:貧血モデルの User とリッチな User

貧血モデルの User(やりがちパターン)

よくある「貧血 User」はこんな感じです。

final class User {

    private Long id;
    private String name;
    private String email;
    private int point;

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    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; }

    public int getPoint() { return point; }
    public void setPoint(int point) { this.point = point; }
}
Java

見た目は「ちゃんとカプセル化してるっぽい」ですよね。
でも、この User は「自分がどうあるべきか」を何も知りません。

名前は空文字でも null でもいいのか。
メールはどんな形式でもいいのか。
ポイントはマイナスも OK なのか、上限はあるのか。

こういうルールは全部「どこか他のクラスに書かれている」前提になっています。

例えばポイント加算のルールがこういう Service に追い出されます。

final class UserService {

    void addPoint(User user, int added) {
        if (added < 0) {
            throw new IllegalArgumentException("マイナスはだめ");
        }
        int newPoint = user.getPoint() + added;
        if (newPoint > 10_000) {
            newPoint = 10_000;
        }
        user.setPoint(newPoint);
    }
}
Java

ポイントに関する大事なビジネスルールが、
User ではなく UserService にベタっと入っています。
User クラスは「Userっぽい名前の DTO」に過ぎません。

これが貧血モデルです。

リッチドメインモデルの User(自分で自分を守る)

同じ User を、リッチドメインモデル寄りに書くとこうなります。

final class User {

    private final Long id;
    private String name;
    private String email;
    private int point;

    User(Long id, String name, String email, int point) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("名前は必須");
        }
        if (!email.contains("@")) {
            throw new IllegalArgumentException("メール形式がおかしい");
        }
        if (point < 0) {
            throw new IllegalArgumentException("ポイントは0以上");
        }
        this.id = id;
        this.name = name;
        this.email = email;
        this.point = point;
    }

    void changeEmail(String newEmail) {
        if (!newEmail.contains("@")) {
            throw new IllegalArgumentException("メール形式がおかしい");
        }
        this.email = newEmail;
    }

    void addPoint(int added) {
        if (added < 0) {
            throw new IllegalArgumentException("マイナスは不可");
        }
        int newPoint = this.point + added;
        if (newPoint > 10_000) {
            newPoint = 10_000;
        }
        this.point = newPoint;
    }

    Long id() { return id; }
    String name() { return name; }
    String email() { return email; }
    int point() { return point; }
}
Java

User 自身が

生成時に「おかしな状態」を弾く
メール変更時のルールを知っている
ポイント加算のルールを知っている

という形です。

ポイント仕様を変えたくなったら、とりあえず見るべき場所は User#addPoint だ、と一発で分かります。
「User のポイントに関するルール」が散らばっていないからです。

Service 側は薄くなります。

final class UserService {

    void register(User user) {
        // リポジトリに保存するなど
        // ポイントロジックやメールの妥当性チェックは User に任せる
    }
}
Java

リッチドメインモデルの本質は、
「そのクラスが表す概念に関するルールは、そのクラスの中に書く」
という一点です。


リッチドメインモデルの考え方をもう少し分解する

「ドメインの言葉」と「ドメインのルール」をクラスに写す

リッチドメインモデルでは、

注文、注文行、商品、金額、在庫、顧客、会員ランク、クーポン

のような“業務の言葉”を、そのままクラス名・メソッド名にします。

例えば「注文」が持つルールは何かを考えると、

明細が 1 件以上ないと注文として成立しない
合計金額は全明細の合計
キャンセル可能なのは「出荷前」のみ

みたいな話が出てきます。
これをコードにするとこうなります。

final class OrderLine {

    private final Item item;
    private final int quantity;

    OrderLine(Item item, int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量は1以上");
        }
        this.item = item;
        this.quantity = quantity;
    }

    Money lineTotal() {
        return item.price().multiply(quantity);
    }
}
Java
enum OrderStatus {
    NEW, SHIPPED, CANCELED
}
Java
final class Order {

    private final OrderId id;
    private final List<OrderLine> lines;
    private OrderStatus status;

    Order(OrderId id, List<OrderLine> lines) {
        if (lines == null || lines.isEmpty()) {
            throw new IllegalArgumentException("明細が1件もない注文は無効");
        }
        this.id = id;
        this.lines = List.copyOf(lines);
        this.status = OrderStatus.NEW;
    }

    Money totalPrice() {
        Money total = Money.zero();
        for (OrderLine line : lines) {
            total = total.add(line.lineTotal());
        }
        return total;
    }

    void cancel() {
        if (status == OrderStatus.SHIPPED) {
            throw new IllegalStateException("出荷後はキャンセル不可");
        }
        if (status == OrderStatus.CANCELED) {
            return;
        }
        this.status = OrderStatus.CANCELED;
    }

    OrderId id() { return id; }
    OrderStatus status() { return status; }
}
Java

「明細 0 件禁止」「出荷後キャンセル不可」「合計金額の計算」など、
“注文としてのルール”が Order の中に集まっています。

どこか別の Service クラスで

if (order.getLines().isEmpty()) { ... }
if (order.getStatus() == SHIPPED) { ... }
Java

とやり始めると、ルールが散らばり、貧血寄りになります。

値オブジェクトで「細かいルール」を閉じ込める

リッチドメインモデルでは、「int で十分でしょ」を一歩進めて、
「意味のある値」はクラスにしてしまいます。

金額(Money)、メールアドレス(EmailAddress)、数量(Quantity)、在庫数、パーセントなどです。

例えば Money。

final class Money {

    private final int amount;

    Money(int amount) {
        if (amount < 0) {
            throw new IllegalArgumentException("マイナス金額は禁止");
        }
        this.amount = amount;
    }

    static Money zero() {
        return new Money(0);
    }

    Money add(Money other) {
        return new Money(this.amount + other.amount);
    }

    Money multiply(int factor) {
        if (factor < 0) {
            throw new IllegalArgumentException("マイナスは不可");
        }
        return new Money(this.amount * factor);
    }

    int amount() {
        return amount;
    }
}
Java

これによって

マイナスを作らせない
金額の足し算・掛け算を安全にできる
「これって何の単位の int だっけ?」を考えなくていい

という状態になります。

リッチドメインモデルは、「ルールを if 文の散在ではなく、
“名前のついたクラス”にまとめて閉じ込めていく作業」と捉えるとイメージしやすいです。


サービスとの役割分担:全部ドメインオブジェクトに押し込まない

ドメインサービスに置くべき処理

何でもかんでもエンティティに押し込めばいいわけではありません。
「誰のものでもないロジック」は、ドメインサービスに置きます。

例えば「在庫引当」は、Order だけの話でも、Item だけの話でもなかったりします。
倉庫、在庫数、注文など複数のエンティティにまたがる処理です。

そういうものは、専用のサービスに切り出します。

final class InventoryService {

    void allocate(Order order, Warehouse warehouse) {
        // order の明細と warehouse の在庫を見て、在庫を引き当てる
        // 「在庫引当」という業務知識を書く
    }
}
Java

大事なのは、「業務の中心となるルール」が
どこかのクラスに名前付きで置かれていることです。

Order が持つべきルールは Order に
在庫引当のルールは InventoryService に
金額の基本ルールは Money に

というふうに、「どこを見れば何が分かるか」がはっきりしている状態がリッチです。

アプリケーションサービスは「オーケストラの指揮者」

UI や外部からの入力を受けて、
どのドメインオブジェクトをどう呼び出すかを決める層はアプリケーションサービスです。

そこには、ドメインルールそのものはできるだけ書かず、
「順番」「組み合わせ」「トランザクションの境界」を書くイメージです。

final class PlaceOrderUseCase {

    private final OrderRepository orderRepository;
    private final InventoryService inventoryService;

    PlaceOrderUseCase(OrderRepository orderRepository,
                      InventoryService inventoryService) {
        this.orderRepository = orderRepository;
        this.inventoryService = inventoryService;
    }

    void place(Order order, Warehouse warehouse) {
        inventoryService.allocate(order, warehouse);
        orderRepository.save(order);
    }
}
Java

ここには「明細が 0 件ならダメ」といった注文のルールは出てこない。
それは Order 側がすでに保証しているからです。

こうやって層ごとに責務を整理すると、
ドメイン層(エンティティ・値オブジェクト・ドメインサービス)が
「業務ロジックの居場所」になります。


リッチドメインモデルのメリットと、現実的な付き合い方(重要)

リッチドメインモデルのメリットは、ざっくり言うと次のようなものです。

業務ルールの「居場所」がはっきりする
どこを読めば何が分かるかが明確になる
不正な状態のオブジェクトを作りにくくなる
仕様変更のときに、修正すべきクラスを見つけやすい
ドメインオブジェクト単体でテストしやすくなる

一方で、いきなりシステム全体をフルフルでリッチにするのは現実的ではありません。
現実的な付き合い方としては、

まず「明らかにビジネスルールが重い領域」からリッチにしていく
値オブジェクト(Money、Email、Quantity など)を導入してみる
貧血なエンティティに、少しずつ「それっぽいメソッド」を足していく

といった「局所リッチ化」から始めるのが良いです。


どこから手を付けるか:あなたのコードに引き寄せて考える

リッチドメインモデルは、抽象的な概念より「自分のコードに当てはめたとき」に一気に理解が進みます。

今のプロジェクトで、こんなクラスがいたら要注意です。

User や Order など“ドメインっぽい名前”なのに、ほぼ getter/setter だけ
Service クラスが if や計算式でパンパンになっている
int や String で「意味のある値」をあちこちに持っている

こういうところが「貧血ゾーン」です。

そこから一つ選んで、

どんなルールをそのクラスに戻せそうか
どんな値オブジェクトに切り出せそうか
どんなメソッド名にすれば“業務の日本語”になるか

を考えてみると、リッチドメインモデルの感覚がぐっと掴めます。

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