インフラストラクチャ層の実装ルール
ドメイン層が並べたインタフェースを実際に動かすのがこの層です。 テーブルを引き、SQL を実行し、トランザクションの範囲を決めます。 im_mirage の作法がコードに現れてよいのは、この層の中だけです。
| パッケージ | クラス | 役割 |
|---|---|---|
infrastructure.entity | OrderEntity | テーブルとの1対1マッピング |
infrastructure.dao | OrderDAO | im_mirage によるテーブル操作 |
infrastructure.repository | StandardOrderRepository、StandardOrderRepositoryFactory | リポジトリインタフェースの実装と、それを返す既定のファクトリ |
infrastructure.service | StandardOrderService、StandardOrderServiceFactory | サービスインタフェースの実装と、それを返す既定のファクトリ |
実装クラスの名前には Impl を機械的には付けず、既定の実装であることを示す Standard を接頭辞に置きます。
プラットフォーム標準機能もこの形をとっています。
エンティティと DAO の書き方はエンティティと DAO の作成、SQLファイルの構文は2WaySQL、SessionTemplate の挙動はトランザクション制御で扱います。
このページでは、その上に載るリポジトリとサービスの実装を扱います。
リポジトリの実装
リポジトリの実装は、DAO を呼び出し、エンティティとドメインモデルを相互に変換します。
package jp.co.example.foo.infrastructure.repository;
import jp.co.example.foo.domain.exception.RepositoryException;
import jp.co.example.foo.domain.model.Order;
import jp.co.example.foo.domain.model.OrderStatus;
import jp.co.example.foo.domain.repository.OrderRepository;
import jp.co.example.foo.infrastructure.dao.OrderDAO;
import jp.co.example.foo.infrastructure.entity.OrderEntity;
import jp.co.intra_mart.mirage.ext.dao.DAOFactory;
import jp.co.intra_mart.mirage.ext.session.SessionTemplate;
/**
* {@link OrderRepository} の標準実装クラスです。
*/
public class StandardOrderRepository implements OrderRepository {
@Override
public Order findByOrderId(final String orderId) throws RepositoryException {
try {
return SessionTemplate.execute(s -> {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
final OrderEntity entity = dao.findByOrderId(orderId);
return entity != null ? convertToModel(entity) : null;
});
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の検索に失敗しました: orderId=" + orderId, e);
}
}
@Override
public void save(final Order order) throws RepositoryException {
try {
SessionTemplate.execute(s -> {
final OrderDAO dao = DAOFactory.getTenantDatabaseDAO(OrderDAO.class);
final OrderEntity existing = dao.find(order.getOrderId());
if (existing != null) {
// update は主キー以外の全カラムを更新するため、既存のエンティティに変更点だけを反映する
applyChanges(existing, order);
dao.update(existing);
} else {
dao.insert(convertToEntity(order));
}
return null;
});
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の保存に失敗しました: orderId=" + order.getOrderId(), e);
}
}
private Order convertToModel(final OrderEntity entity) {
return new Order(entity.orderId, entity.customerName, entity.amount,
OrderStatus.valueOf(entity.status));
}
private void applyChanges(final OrderEntity entity, final Order order) {
entity.customerName = order.getCustomerName();
entity.amount = order.getAmount();
entity.status = order.getStatus().name();
}
private OrderEntity convertToEntity(final Order order) {
final OrderEntity entity = new OrderEntity();
entity.orderId = order.getOrderId();
applyChanges(entity, order);
return entity;
}
}
DAO の呼び出しは、参照も含めて SessionTemplate で囲みます。
サービスの境界の中で呼ばれたときは、その境界に合流します。
save で更新するときは、ドメインモデルから新しく組み立てたエンティティを update に渡しません。
update は主キー以外の全カラムを更新するため、値を設定していないフィールドが null で上書きされます。
find で取得した既存のエンティティに変更点を反映してから渡します。
詳しくはエンティティと DAO の作成で扱います。
変換メソッドは private にします。 外に出すと、エンティティがこの層の外へ持ち出される経路ができます。
DAOFactory から取得した DAO は、メソッドごとに取り直します。
フィールドに保持すると、セッションをまたいだインスタンスを使い回すことになります。
リポジトリが担うのはデータアクセスと変換だけです。 「金額が0以上か」のような業務ルールをここに書くと、リポジトリを差し替えたときにルールも一緒に消えます。
サービスの実装
サービスの実装は、検証、トランザクション、業務ルールの適用、結果の組み立ての順で並びます。
@Override
public OrderResult processRegistration(final OrderInput input) throws OrderServiceException {
validateInput(input);
return SessionTemplate.execute(s -> {
final Order order;
try {
order = orderRepository.findByOrderId(input.getOrderId());
} catch (final RepositoryException e) {
LOGGER.error("Failed to find order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の検索に失敗しました: orderId=" + input.getOrderId(), e);
}
if (order == null) {
throw new OrderServiceException("発注が見つかりません: orderId=" + input.getOrderId());
}
final Order processed = applyBusinessRules(order, input);
try {
orderRepository.save(processed);
} catch (final RepositoryException e) {
LOGGER.error("Failed to save order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + input.getOrderId(), e);
}
return buildResult(processed);
});
}
コールバックが送出できる検査例外は一種類に限られます。
業務ルール違反の OrderServiceException と永続化の失敗の RepositoryException を両方そのまま投げると、呼び出し側でどちらも捕捉できなくなります。
そのため、RepositoryException はリポジトリを呼んだ直後にコールバックの中で OrderServiceException に置き換えます。
validateInput が SessionTemplate.execute の外にあるのは、書き間違いではありません。
入力の不備は DB を見なくても判定できます。
接続を取得してから弾くと、必ず失敗する処理のためにコネクションを一つ占有することになります。
判定は二種類あり、置き場所が違います。
validateInput は、引数だけを見て決まる不備を弾きます。
private void validateInput(final OrderInput input) throws OrderServiceException {
if (input == null) {
throw new OrderServiceException("入力が null です");
}
if (input.getOrderId() == null || input.getOrderId().isEmpty()) {
throw new OrderServiceException("orderId は必須です");
}
if (input.getAmount() != null && input.getAmount().compareTo(BigDecimal.ZERO) < 0) {
throw new OrderServiceException("金額は0以上である必要があります: amount=" + input.getAmount());
}
}
applyBusinessRules は、DB から取り出した現在の状態と突き合わせて判定します。
現在の状態を必要とするため、トランザクションの中で呼びます。
private Order applyBusinessRules(final Order order, final OrderInput input) throws OrderServiceException {
if (!order.isEditable()) {
throw new OrderServiceException("編集できない状態です: status=" + order.getStatus());
}
return order.updateFrom(input);
}
プレゼンテーション層の Validator と役割が重なって見えるかもしれません。
Validator が見るのはリクエストの形式であり、外部から届いた文字列が数値として解釈できるか、必須項目が欠けていないかを判定します。
サービスが見るのは業務の成立条件です。
形式が正しいリクエストでも、金額が負であれば業務としては成立しません。
プレゼンテーション層を通らないバッチジョブからサービスが呼ばれることもあるため、サービス側の検証を省くわけにはいきません。
テストのための依存性注入
実装クラスは、既定のコンストラクタでファクトリからリポジトリを受け取ります。 これとは別に、リポジトリを引数で受け取るコンストラクタを用意します。
public class StandardOrderService implements OrderService {
private final OrderRepository orderRepository;
public StandardOrderService() {
try {
this.orderRepository = OrderRepositoryFactory.getInstance().getOrderRepository();
} catch (final RepositoryException e) {
throw new RuntimeException("OrderRepository の初期化に失敗しました", e);
}
}
/**
* テスト用にリポジトリを差し替えるコンストラクタです。
* @param orderRepository 発注リポジトリ
*/
StandardOrderService(final OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
}
引数ありのコンストラクタはパッケージプライベートにします。 本番のコードからはファクトリ経由でしか取得できず、同じパッケージに置いたテストからはモックを渡せます。
トランザクション境界を張る
SessionTemplate.execute はどの層に書いても動きます。
だからこそ、境界を張る層を決めておかないと、同じモジュールの中で二つの流儀が混ざります。
境界は、サービスの実装とリポジトリの実装の両方で張ります。 コミットの単位を決めるのはサービスです。
public class StandardOrderService implements OrderService {
private static final Logger LOGGER = Logger.getLogger(StandardOrderService.class);
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
// コンストラクタは省略
@Override
public void register(final Order order, final List<OrderItem> items) throws OrderServiceException {
validateInput(order, items);
SessionTemplate.execute(s -> {
try {
orderRepository.save(order);
for (final OrderItem item : items) {
orderItemRepository.save(item);
}
} catch (final RepositoryException e) {
LOGGER.error("Failed to register order: orderId=" + order.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + order.getOrderId(), e);
}
return null;
});
}
}
発注ヘッダと発注明細は、片方だけが登録された状態を作れません。 二つのリポジトリを一つの単位にまとめられるのは、両者を呼ぶサービスだけです。
リポジトリの save もそれぞれ SessionTemplate で境界を張っていますが、更新の単位は壊れません。
SessionTemplate が入れ子を検知して外側に合流し、コミットとロールバックは外側の境界が終わるときに決まるためです。
その挙動はトランザクション制御で扱います。
リポジトリをサービスを経由せず単独で呼んだときは、リポジトリの境界がそのままトランザクションになります。
例外の変換
この層は、im_mirage が投げる例外をドメイン層の語彙に置き換えてから上へ渡します。
} catch (final SQLRuntimeException e) {
throw new RepositoryException("発注情報の保存に失敗しました: orderId=" + order.getOrderId(), e);
}
try {
orderRepository.save(processed);
} catch (final RepositoryException e) {
LOGGER.error("Failed to save order: orderId=" + input.getOrderId(), e);
throw new OrderServiceException("発注の登録に失敗しました: orderId=" + input.getOrderId(), e);
}
規則は三つです。
causeを保持する:第二引数に元の例外を渡します。ここを落とすと、SQL の失敗がスタックトレースから消えます。- 予期しない
RuntimeExceptionは捕捉しない:NullPointerExceptionのような実装の誤りは、業務例外に包むと原因が業務の失敗のように見えます。そのまま上へ通します。 - 握り潰さない:
catchして何もしないと、更新が一部だけ適用された状態が検知されないまま処理が続きます。
例外クラスの定義はドメイン層の実装ルールにあります。
関連ドキュメント
- エンティティと DAO の作成:
@Table/@Column/@PrimaryKeyによるマッピング、対応するカラム型、自動設定される監査項目 - 2WaySQL:SQLファイルによる動的クエリの記述
- トランザクション制御:
SessionTemplateの挙動とネストしたトランザクション - ドメイン層の実装ルール:ここで実装するインタフェースと例外クラスの宣言
- 全レイヤ縦断の実装例:エンドポイントから DB までの一式
- アンチパターン集:レイヤ違反の典型例と修正