ドメイン層の実装ルール
ドメイン層に置くのは、業務そのものを表すドメインモデルと、リポジトリやサービスのインタフェースです。 実装クラスはここにはありません。
| パッケージ | クラス | 役割 |
|---|---|---|
domain.model | Order、OrderStatus | 業務ルールを持つドメインモデル |
domain.repository | OrderRepository、OrderRepositoryFactory | 永続化のインタフェースと取得口 |
domain.service | OrderService、OrderServiceFactory | 業務ロジックのインタフェースと取得口 |
domain.exception | OrderServiceException、RepositoryException、OrderRuntimeException | この層が送出する例外 |
この層が持たない依存
ドメイン層は、DBアクセスの手段を知りません。
SessionTemplate も DAOFactory も、この層には現れず、import 文にも出てきません。
プレゼンテーション層への参照も同じく持ちません。
ドメインモデルに toResponse() を生やすと、業務の型が API の都合に縛られます。
この原則の抜け道はファクトリだけです。
既定の実装を返すために、ファクトリはインフラストラクチャ層の Standard ファクトリを参照します。
その一点に集めることで、他のクラスからの参照を残しません。
ドメインモデル
ドメインモデルは、業務ルールを持つイミュータブルなクラスです。
package jp.co.example.foo.domain.model;
import java.math.BigDecimal;
/**
* 発注を表すドメインモデルです。
*/
public class Order {
private final String orderId;
private final String customerName;
private final BigDecimal amount;
private final OrderStatus status;
public Order(final String orderId, final String customerName,
final BigDecimal amount, final OrderStatus status) {
this.orderId = orderId;
this.customerName = customerName;
this.amount = amount;
this.status = status;
}
public String getOrderId() {
return orderId;
}
public String getCustomerName() {
return customerName;
}
public BigDecimal getAmount() {
return amount;
}
public OrderStatus getStatus() {
return status;
}
/**
* 編集できる状態かどうかを判定します。
* @return 編集できる場合は true
*/
public boolean isEditable() {
return status == OrderStatus.DRAFT;
}
}
フィールドは private にして getter だけを公開します。 setter を持たせないため、値を変えたい場合は新しいインスタンスを作ります。
エンティティが status を String で保持するのに対し、ドメインモデルは OrderStatus という列挙型で保持します。
文字列から列挙型への変換はリポジトリの実装が行い、この層には解釈済みの値だけが届きます。
エンティティとの違いを整理した表は全体アーキテクチャにあります。
業務ルールをモデルとサービスのどちらに置くか
判定に必要な材料で決めます。
- モデル自身の状態だけで決まるルールはモデルに置く:
isEditable()は自分のstatusを見れば判定できます。 - 複数のモデルや外部の状態を突き合わせるルールはサービスに置く:在庫数と発注数量の比較のように、別のリポジトリから取り出した値が要るものはサービスの仕事です。
モデルに置けるルールをサービスに書くと、同じ判定が複数のサービスに複製されます。 逆に、リポジトリを必要とするルールをモデルに書こうとすると、モデルがリポジトリを参照することになり、モデルを単体でテストできなくなります。
リポジトリインタフェース
インタフェースには、永続化の操作だけを並べます。 引数と戻り値にはドメインモデルを使います。
package jp.co.example.foo.domain.repository;
import java.util.List;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;
/**
* 発注情報の永続化を担う Repository インタフェースです。
*/
public interface OrderRepository {
Order findByOrderId(String orderId) throws RepositoryException;
List<Order> findByStatus(String status) throws RepositoryException;
void save(Order order) throws RepositoryException;
}
ここで OrderEntity を使うことはできません。
エンティティはインフラストラクチャ層のクラスであり、この層のインタフェースに現れると依存が外を向きます。
書き込み系のメソッドは、ドメインモデル1件を単位にします。 複数件を扱いたい場合は、サービスでループするか、DAO のバッチ系メソッドを使います。 取得系は、条件に一致した全件をリストで返してかまいません。
メソッド名は役割ごとに揃えます。
検索は findBy{条件} と findAll(対象を名前に含める場合は findAll{エンティティ名}s)、最新の1件は findLatest{エンティティ名}、保存は save、削除は remove です。
サービスインタフェース
サービスは、ユースケースから呼ばれる業務ロジックの入口です。
package jp.co.example.foo.domain.service;
import java.util.List;
import jp.co.example.foo.domain.exception.OrderServiceException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.model.OrderItem;
/**
* 発注情報のビジネスロジックを提供する Service インタフェースです。
*/
public interface OrderService {
void register(Order order, List<OrderItem> items) throws OrderServiceException;
List<Order> findByStatus(String status) throws OrderServiceException;
}
名前は OrderService の形にし、I プレフィックスは使いません。
業務操作は process{操作} や calculate{指標}、検証は validate{ルール} や is{状態} と名付けます。
送出する例外は {ドメイン名}ServiceException に揃えます。
リポジトリが投げる RepositoryException をそのまま通すと、呼び出し側が永続化の失敗と業務の失敗を区別できません。
ファクトリ
インスタンスの取得口はファクトリに集めます。 ファクトリは抽象クラスとして、インタフェースと同じパッケージに置きます。
package jp.co.example.foo.domain.repository;
import jp.co.example.foo.domain.exception.OrderRuntimeException;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.infrastructure.repository.StandardOrderRepositoryFactory;
import jp.co.intra_mart.common.aid.jdk.java.util.ServiceLoaderUtil;
import jp.co.intra_mart.common.platform.log.Logger;
/**
* {@link OrderRepository} を取得するファクトリクラスです。
*/
public abstract class OrderRepositoryFactory {
private static final Logger LOGGER = Logger.getLogger(OrderRepositoryFactory.class);
private static final class LazyHolder {
private static final OrderRepositoryFactory INSTANCE = loadOrderRepositoryFactory();
private static OrderRepositoryFactory loadOrderRepositoryFactory() {
try {
final OrderRepositoryFactory factory = ServiceLoaderUtil.loadFirst(OrderRepositoryFactory.class);
if (factory != null) {
return factory;
}
return new StandardOrderRepositoryFactory();
} catch (final Exception e) {
LOGGER.error("Failed to load OrderRepositoryFactory", e);
throw new OrderRuntimeException("Failed to load OrderRepositoryFactory", e);
}
}
}
protected OrderRepositoryFactory() {
super();
}
/**
* {@link OrderRepository} のインスタンスを取得します。
* @return Repository インスタンス
* @throws RepositoryException 取得に失敗した場合
*/
public abstract OrderRepository getOrderRepository() throws RepositoryException;
/**
* ファクトリのインスタンスを取得します。
* @return ファクトリ
*/
public static OrderRepositoryFactory getInstance() {
return LazyHolder.INSTANCE;
}
}
既定の実装を返すファクトリは、インフラストラクチャ層に置きます。
package jp.co.example.foo.infrastructure.repository;
import jp.co.example.foo.domain.repository.OrderRepository;
import jp.co.example.foo.domain.repository.OrderRepositoryFactory;
/**
* {@link OrderRepositoryFactory} の標準実装クラスです。
*/
public class StandardOrderRepositoryFactory extends OrderRepositoryFactory {
@Override
public OrderRepository getOrderRepository() {
return new StandardOrderRepository();
}
}
呼び出し側は、getInstance() でファクトリを取り出し、getOrderRepository() でリポジトリを受け取ります。
final OrderRepository orderRepository = OrderRepositoryFactory.getInstance().getOrderRepository();
サービスのファクトリも同じ形で書きます。
抽象クラスの OrderServiceFactory と、既定の実装を返す StandardOrderServiceFactory の組になり、取得のメソッドは getOrderService() です。
ServiceLoaderUtil はプラットフォーム共通のユーティリティで、im_jdk_assist モジュールが提供します。
im_mirage の API ではないため、ドメイン層から呼んでもこの層の制約には触れません。
差し替えの仕組みは Java 標準の ServiceLoader に乗っています。
META-INF/services/jp.co.example.foo.domain.repository.OrderRepositoryFactory というファイルに、OrderRepositoryFactory を継承したファクトリクラスの完全修飾名を1行書くと、そのファクトリが検出されます。
loadFirst は検出したファクトリのうち最初の一つを返し、登録がなければ null を返します。
登録がない場合は、既定の StandardOrderRepositoryFactory が使われます。
これにより、他モジュールがファクトリを登録するだけで、呼び出し側のコードに手を入れずに振る舞いを差し替えられます。
ファクトリのロードに失敗したときは、非検査例外の OrderRuntimeException を投げます。
ロードの失敗は起動時点で壊れている状況であり、呼び出し側に対処の余地がないためです。
例外クラス
domain.exception に置く例外は三つです。
このうち OrderRuntimeException は、ファクトリがロードに失敗したときに使います。
// 検査例外。業務ルール違反、入力不正、対象データの不在
public class OrderServiceException extends Exception {
public OrderServiceException(final String message) {
super(message);
}
public OrderServiceException(final String message, final Throwable cause) {
super(message, cause);
}
}
// 検査例外。永続化の失敗
public class RepositoryException extends Exception {
public RepositoryException(final String message, final Throwable cause) {
super(message, cause);
}
}
// 非検査例外。ファクトリのロード失敗など、実装の誤り
public class OrderRuntimeException extends RuntimeException {
public OrderRuntimeException(final String message, final Throwable cause) {
super(message, cause);
}
}
呼び出し側に対処の余地がある失敗は検査例外に、対処の余地がない失敗は非検査例外にします。 発注が見つからないのは呼び出し側が扱える状況であり、ファクトリが実装をロードできないのは起動時点で壊れている状況です。
メッセージは日本語で書き、原因の特定に必要な変数値を含めます。 運用の担当者や利用者の目に触れるためです。 一方、ログメッセージは英語で書きます。
関連ドキュメント
- インフラストラクチャ層の実装ルール:ここで宣言したインタフェースの実装
- アプリケーション層の実装ルール:サービスを呼び出すユースケース
- 全レイヤ縦断の実装例:エンドポイントから DB までの一式
- 全体アーキテクチャ:レイヤの責務、依存の向き、例外の階層