全体アーキテクチャ
業務ロジックを書き始めた開発者が最初に迷うのは、データベースを読み書きするコードをどのクラスに置くかです。 テーブルを引く処理を API の入口に直接書いても、動くものは動きます。 それでも JavaEE 開発モデルでは、入口から SQL の実行までを四つの層に分け、層をまたぐ呼び出しの向きを一方向に固定します。
レイヤの構成
プレゼンテーション層 … REST API の入口。入力検証と DTO の変換
↓
アプリケーション層 … ユースケースの実行。バッチジョブの入口
↓
ドメイン層 … 業務ルール。ドメインモデルとインタフェースの定義
↓
インフラストラクチャ層 … 永続化と外部連携。ドメインインタフェースの実装
↓
Database
矢印は呼び出しの向きです。 プレゼンテーション層はアプリケーション層のユースケースを呼び、ユースケースはドメイン層のサービスを呼びます。 エンドポイントがドメインモデルを直接操作したり、DAO を直に呼んだりする実装は、層を飛び越えるため認められません。
| レイヤ | 責務 | 主なクラス |
|---|---|---|
| プレゼンテーション層 | 外部インタフェース、リクエストとレスポンスの変換、入力検証、認証と認可 | Endpoint、Request DTO、Response DTO、Validator |
| アプリケーション層 | ユースケースの実行、ドメインサービスの調停 | UseCase、Job |
| ドメイン層 | 業務ルール、ドメインモデルとインタフェースの定義 | Model、Service インタフェース、Repository インタフェース、Exception |
| インフラストラクチャ層 | データの永続化、外部システム連携、ドメインインタフェースの実装 | Entity、DAO、Standard実装クラス |
以降は、発注情報を保持する foo_order テーブルと、その周りに並ぶ Order 系のクラスを例に説明します。
依存が内向きになる仕掛け
呼び出しの順序では、ドメイン層のあとにインフラストラクチャ層が来ます。 依存の向きはその逆です。
ドメイン層に置くのは OrderRepository や OrderService といったインタフェースだけです。
StandardOrderRepository のような実装クラスはインフラストラクチャ層に置き、ドメイン層で宣言されたインタフェースを実装します。
サービスがリポジトリを呼ぶとき、参照しているのはドメイン層のインタフェースであり、その先で im_mirage が動いていることをサービスは知りません。
同心円で描けば、業務ルールを持つドメイン層が中心にあり、プレゼンテーション層もインフラストラクチャ層もその外側に並びます。 依存が内向きだというのは、外側の層は中心を知っているが、中心は外側を知らない、という意味です。
依存の向きが守れているかどうかは、import 文を見れば分かります。
ドメイン層のクラスが jp.co.intra_mart.mirage.ext.session.SessionTemplate や jp.co.intra_mart.mirage.ext.dao.DAOFactory を import していたら、DBアクセスの手段がドメイン層に入り込んでいます。
パッケージ構成
パッケージはレイヤ単位で分割し、レイヤの下に役割ごとのパッケージを置きます。
jp.co.example.foo
├── presentation
│ ├── endpoint // REST API エンドポイント
│ ├── request // リクエスト DTO
│ ├── response // レスポンス DTO
│ └── validator // 入力検証
├── application
│ ├── usecase // ユースケース
│ ├── job // バッチジョブ
│ └── exception // アプリケーション例外
├── domain
│ ├── model // ドメインモデル
│ ├── service // サービスインタフェースとファクトリ
│ ├── repository // リポジトリインタフェースとファクトリ
│ └── exception // ドメイン例外
└── infrastructure
├── entity // エンティティ(DBマッピング)
├── dao // DAO(im_mirage の呼び出し)
├── model // インフラストラクチャ層の内部で使うモデル
├── repository // リポジトリの実装と既定のファクトリ
└── service // サービスの実装と既定のファクトリ
クラス名の付け方
クラス名のサフィックスは、そのクラスがどの層に属するかを示します。
| レイヤ | クラス | 命名 |
|---|---|---|
| プレゼンテーション層 | エンドポイント | {リソース名}Endpoint |
| リクエスト DTO | {操作名}Request | |
| レスポンス DTO | {操作名}Response | |
| バリデータ | {操作名}Validator | |
| アプリケーション層 | ユースケース | {操作名}UseCase |
| バッチジョブ | {ジョブ名}Job | |
| ドメイン層 | ドメインモデル | ドメイン名そのまま(Order) |
| サービスインタフェース | {ドメイン名}Service | |
| リポジトリインタフェース | {ドメイン名}Repository | |
| ファクトリ | {ドメイン名}ServiceFactory / {ドメイン名}RepositoryFactory | |
| インフラストラクチャ層 | エンティティ | {ドメイン名}Entity |
| DAO | {ドメイン名}DAO | |
| 実装クラス | Standard{ドメイン名}Service / Standard{ドメイン名}Repository | |
| 既定のファクトリ | Standard{ドメイン名}ServiceFactory / Standard{ドメイン名}RepositoryFactory |
インタフェース名に I プレフィックスは付けません。
実装クラスにも Impl を機械的には付けず、既定の実装であることを示す Standard を接頭辞に置きます。
メソッド名も役割ごとに揃えます。
検索は findBy{条件}、findAll{エンティティ名}s、findLatest{エンティティ名}、業務操作は process{操作} と calculate{指標}、検証は validate{ルール} と is{状態}、変換は convertTo{型} と transformTo{形式} です。
ユースケースとジョブの入口は、どちらも execute で統一します。
エンティティとドメインモデルを分ける理由
同じ「発注」を表すクラスが、infrastructure.entity と domain.model の二か所に現れることがあります。
重複に見えますが、この二つは所属する層も、満たすべき制約も違います。
| 観点 | エンティティ | ドメインモデル |
|---|---|---|
| 所属レイヤ | インフラストラクチャ層 | ドメイン層 |
| 目的 | DBテーブルとの1対1マッピング | ビジネスロジックの表現 |
| フィールド | public フィールド(マッピングの要件) | private フィールド + getter |
| 可変性 | ミュータブル(マッピング時に値が設定される) | イミュータブル推奨 |
| バリデーション | なし(DB制約に依存) | ビジネスルールを実装 |
| 変換 | リポジトリ層で相互変換 | リポジトリ層で相互変換 |
エンティティが public フィールドを持つのは、それが読みやすいからではありません。 オブジェクト関係マッピング(ORM)を担う im_mirage が public フィールドへ直接値をマッピングする設計であり、この制約はインフラストラクチャ層の都合です。 業務ルールをエンティティに書くと、DBアクセス基盤の都合がドメインの表現に染み出します。 業務ルールはドメインモデルに実装し、両者の変換はリポジトリの実装クラスに閉じ込めます。
同じ理由で、エンティティを API のレスポンスとして返すこともできません。 カラム名と監査項目がそのままクライアントに見えるうえ、テーブル定義の変更が API の互換性を壊します。 外部に返すのはレスポンス DTO です。
エンティティの詳細な規約はエンティティと DAO の作成で扱います。
例外の階層
例外は層をまたぐたびに、その層の語彙に変換します。
| レイヤ | 例外クラス | 種別 | 用途 |
|---|---|---|---|
| プレゼンテーション層 | ValidationException | 非検査例外 | 入力形式の不備 |
| アプリケーション層 | {アプリ名}Exception | 検査例外 | ユースケースの失敗 |
| ドメイン層 | {ドメイン名}ServiceException、RepositoryException | 検査例外 | 業務ルール違反、永続化の失敗 |
| インフラストラクチャ層・ファクトリ | {ドメイン名}RuntimeException | 非検査例外 | ファクトリのロード失敗など、実装の誤り |
インフラストラクチャ層のリポジトリは、im_mirage の SQLRuntimeException を RepositoryException に変換してからドメイン層へ渡します。
変換のたびに、元の例外を cause として渡します。
cause を落とすと、スタックトレースから原因の層が消えます。
例外クラスの定義とメッセージの規約はドメイン層の実装ルール、変換の規則はインフラストラクチャ層の実装ルールで扱います。
生成をファクトリに集める
サービスとリポジトリのインスタンスは、new ではなくファクトリから取得します。
final OrderService orderService = OrderServiceFactory.getInstance().getOrderService();
new StandardOrderService() と書くと、呼び出し側が既定の実装に固定されます。
ファクトリを挟めば、ServiceLoaderUtil による実装の差し替えができ、テストではモックを注入できます。
ファクトリは抽象クラスとして、インタフェースと同じドメイン層のパッケージに置きます。
既定の実装を返す StandardOrderServiceFactory はインフラストラクチャ層に置き、差し替えがないときだけ抽象ファクトリがこれを使います。
既定の実装との結び付きを、この一点に閉じ込めるためです。
ファクトリの実装はドメイン層の実装ルールで扱います。
トランザクション境界はサービスとリポジトリで張る
DBへのアクセスは、更新か参照かを問わず SessionTemplate.execute(SessionCallback) の中で実行します。
この境界は、ドメインサービスの実装クラスとリポジトリの実装クラスの両方で張ります。
SessionTemplate は入れ子にでき、内側の境界は外側の境界に合流します。
サービスから呼ばれたリポジトリはサービスのトランザクションに加わるため、コミットの単位を決めるのはサービスです。
どのリポジトリを何回呼ぶかを知っているのはサービスだけであり、複数のリポジトリにまたがる更新を一つの単位にまとめられるのもこの層だけです。
リポジトリ側にも境界を置いておくと、リポジトリを単独で呼んだときにも境界の外で DB にアクセスすることがありません。
逆にエンドポイントやユースケースに SessionTemplate を書くと、im_mirage への依存が外側の層まで広がり、リポジトリをインタフェースで抽象化した意味が薄れます。
境界の張り方はインフラストラクチャ層の実装ルール、入れ子になったときの挙動はトランザクション制御で扱います。
関連ドキュメント
- エンティティと DAO の作成:
@Table/@Column/@PrimaryKeyによるマッピング、対応するカラム型、自動設定される監査項目 - 2WaySQL:SQLファイルによる動的クエリの記述
- トランザクション制御:
SessionTemplateとネストしたトランザクション - ドメイン層の実装ルール:ドメインモデル、リポジトリとサービスのインタフェース、ファクトリ、例外クラス
- インフラストラクチャ層の実装ルール:実装クラス、トランザクション境界、例外の変換
- プレゼンテーション層の実装ルール:エンドポイント、DTO、入力検証
- アプリケーション層の実装ルール:ユースケースとバッチジョブ
- アンチパターン集:レイヤ違反の典型例と修正
- 全レイヤ縦断の実装例:DDL からエンドポイントまでの一式
ソースツリーの配置規約は、モジュールプロジェクトの構成で扱っています。
ここで扱うのは JavaEE 開発モデルの実装です。
スクリプト開発モデル(JSSP)のDBアクセスは TenantDatabase と SharedDatabase の API を使い、SQLの書き方も呼び出し方も別物になります。
開発モデルの異なる実装を、そのまま流用しないよう注意してください。