#1. はじめに
前編:ドメイン駆動設計+クリーンアーキテクチャ解説【DDD編】
本記事は前回の続きとなります。クリーンアーキテクチャとはRobertMartinが提唱するアーキテクチャであり(※参考文献参照)本記事でも採用しています。元々本記事ではドメイン駆動設計を利用しアプリケーションの構築を目的としており、クリーンアーキテクチャについては結果として必要になりました。クリーンアーキテクチャは下記のような問題を解決します。
・フレームワーク独立
・テスト可能
・データベースからの独立
・外部機能からの独立
実際にドメイン駆動設計に関する思想・概念を実際のソースコードに落とし込む際に、いくつかの問題にぶち当たりその解決法の1つとしてクリーンアーキテクチャを採用する形になりました。本記事ではどういった問題に直面し、なぜクリーンアーキテクチャを採用することになったかについて記載できればと思います。
なお、本記事ではFacebookのメッセンジャーアプリを想定してサンプルAPIを作成しており、ルーム、メッセージと行った昨日はメッセンジャーにおけるメッセージルームやメッセージ情報を意味します。
#2. 背景
アプリケーションを構築する上で、理想を持って挑んだとしても現実のプロジェクト開発においては様々な問題が発生します。 その要因は様々で、大きな仕様変更、フレームワークの問題や外部インターフェースの問題など様々な事情で解決すべき現実的問題が発生します。
立ちはだかる現実
- 外部API・ストアの変更に影響を受ける
(例)仮に三層モデルでストレージをRDSからDynamoDBに変更すれば、当然ロジックにも修正が入ってしまう。 - インピーダンスミスマッチ
- 外部ストアのデータ構造に依存してしまう。
(例)例えばIDがInt型であれば、ロジック層で扱う際 にIntとして扱う必要があり変更があった場合、ロジック層に影響が出てしまう。 - ドメインモデル貧血症
機能を拡張するにつれて、ロジックを扱う層がいつのまにかドメインモデルの上位に居座り、ドメインモデルの境界が侵食される。 - レイヤーの修正が、前後の層の実装に影響を与えてしまう。
そして上記を解決すべく、ドメイン駆動設計+レイヤードアーキテクチャを採用してみました。
データの処理方向としては下記のようになり、ビジネスロジック層とインフラストラクチャ層の間にドメインモデルが存在します。基本的にデータの永続化・取得などはドメインモデルに存在する集約ルートを経由して行います。
しかし実装に落とし込む上での問題点が幾つか発生しました。そちらについて次章で挙げていきたいと思います。
#3. 使用技術
本記事ではメッセンジャーアプリを想定してサンプルアプリケーションを実装します。以下に本記事で使用する技術周りの資料を記載します。
###3.1 RDS-ER図
###3.2 使用言語&フレームワーク
- Java8
- Spring4
- JPA2.1
- EclipseCollections
- Lombok
###3.3 使用ミドルウェア
- RDS(5.6) …永続化用①
- DynamodDB …永続化用②
- Redis …ユーザのセッション情報管理用
- tomcat8 … API
#4. 課題
下記はドメインモデルであるRoomエンティティ例です。
Mysql、DynamoDb等のテーブルを表現するオブジェクトをエンティティと表現する場合もありますが、今回のエンティティとは別物です。エンティティはデータストアの構成には依存しません。
(※曖昧さ回避のためテーブルを表現するオブジェクトを、この記事ではテーブルオブジェクトと表現します。 )
public class Room {
private final RoomId roomId;
private final LastMessageAt lastMessageAt;
private final String name;
private final UserId userId;
private final ImmutableList<RoomUser> roomUsers;
/**
* メッセージルームを新規作成します。
* @param userId
* @param roomName
* @param joinUserIds
* @return
*/
@Transactional(rollbackFor = Exception.class)
public static Room registerWithRoomUser(RoomRepository roomRepository, RoomUserRepository roomUserRepository, //
Integer userId, String roomName, ImmutableList<Integer> joinUserIds) {
val entity = Room.create(userId, roomName, Dates.now());
val result = roomRepository.save(entity);
val userIds = joinUserIds.newWith(userId).toSet().toImmutable();
RoomUser.register(roomUserRepository, result.getId(), userIds);
return result;
}
/**
* 以下省略
*/
}
このRoomはメッセンジャーアプリを想定しており、メッセンジャー上のメッセージルームのようなものになります。Roomエンティティ自体は集約ルートとなり配下にRoomUserエンティティが存在します。これはRoomの作成・削除等の永続化に関するライフサイクルの中にRoomUserが存在し、このエンティティの集合には同一性が存在するからです。
つまりRoomUserに関する操作、例えばユーザの追加、削除等もこの集約ルートを経由する必要があります。
このような集約ルートなるエンティティを経由してデータの操作などを行います。ビジネスロジック層とドメインモデルの関係は「依頼者」と「供給者」の契約関係になります。ビジネスロジックではドメインに関する処理(利用者の関心事)以外の処理をユースケースとして管理します。
具体例を挙げると、メッセージルームを作成する際に、トラッキング用ログを送信する場合、トラッキングログ送信は利用者の関心事ではありません。そこでビジネスロジック層からドメインモデルの「registerWithRoomUser()」を呼び出し後、トラッキングログ送信処理をビジネスロジック側で実施する必要があります。
そしてドメインモデル内で行う処理については、作成・削除などの永続化に関する処理が発生する場合インフラストラクチャ層を呼び出す形になります。インフラストラクチャ層については使用するO/Rマッパーに実装は依存しますが、今回はJPAを使用しているためテーブルを表現するオブジェクトが別途存在します。つまりドメインモデルのRoomエンティティはDBで持っているRoomとは別に存在することになります。
では、実際にこのような構成でアプリケーションを構築するとどういった課題が発生したか以下に記載します。
###4.1 技術的関心事の流入
今回はドメインモデル(集約ルートエンティティ)内に、ドメインに関する操作メソッドを実装しています。
(※前述のドメインモデル - Roomエンティティ参照)
そして今回の例に挙げたのはサンプルアプリケーションのため、集約ルートとなるRoomエンティティ以下のエンティティ・値オブジェクトは一つだけでしたが、実際のアプリケーションでは3つ、4つとなることも珍しくありません。
そうなると、「集約ルート内のグローバルエンティティに関する操作はすべて集約ルートを経由して実施する」という原則に基づき実装すると、あっという間に集約ルートエンティティで管理するメソッドは肥大化します。
またドメインモデル内で使用する、インフラストラクチャ層呼び出しのためのインスタンスをどうするか。DIパターンを採用している場合、インフラストラクチャ層呼び出しためのインスタンスを集約ルートエンティティ内に渡す必要が出てきます。
インフラストラクチャ層のインターフェースを◯◯Repositoryとすると、下記のように各種DIしたインスタンスを引数として渡す必要があり、結果レポジトリの受け渡しにメソッドの引数を消費することになります。
これでは見通しがいいとはいえません。本来渡すべきはuserId情報やroomNameであり、repositoryインスタンスを渡すのはO/Rマッパーの技術的事情によるもので、利用者の関心事とは言えません。結果的に利用者の関心事を集約するという目的に反してしまいました。
// repositoryインスタンスは技術的関心事でドメインモデルに流入すべきではない。
// また今回はrepositoryインスタンスは2つだが、大きなアプリケーションではより肥大化していく可能性がある。
public static Room registerWithRoomUser(RoomRepository roomRepository, RoomUserRepository roomUserRepository,
Integer userId, String roomName, ImmutableList<Integer> joinUserIds) {
//処理省略
}
###4.2 ドメインモデル貧血症
4.1では集約ルート内の肥大化と、ドメインモデルに対する技術的関心事の流入が発生していしまいました。
では上記の技術的関心事をロジック層に移してみましょう。移行例を下記RoomService(ロジック層)に記します。これにより技術的関心事をドメインモデルから引き離すことには成功しました。しかし一方で今度は利用者の関心事が業務の関心事を管理するロジック層に流入してしまいました。ドメインモデルはただドメインを表現するオブジェクトとしてのみ機能し、それらに関する振る舞い・操作は実質的にロジック層が担っている状態となっています。
この形で実装すると、ドメインモデルに残るメソッドは永続化・取得を行わない、ドメインモデルで持つ値に対する操作のみとなるでしょう。
これをドメインモデル貧血症といいます。これもアンチパターンの1つで、一見ドメインモデルに見えますが、オブジェクトは振る舞いを持たず、ドメインのロジックをドメインモデルが持たないため、ドメインモデルが貧血を起こす状態となっています。
public Room registerWithRoomUser() {
val entity = Room.create(userId, roomName, Dates.now());
val result = roomRepository.save(entity);
val userIds = joinUserIds.newWith(userId).toSet().toImmutable();
roomUserRepository.save(result.getId(), userIds);
return result;
}
public class Room {
private final RoomId roomId;
private final LastMessageAt lastMessageAt;
private final String name;
private final UserId userId;
private final ImmutableList<RoomUser> roomUsers;
public Room updateLastMessageAt(Date date){
/**
* 処理省略
*/
}
}
###4.3 インピーダンスミスマッチ
インピーダンスミスマッチとは、O/Rマッパーなどでよく使用されますが、今回は特にドメインモデルとストアのデータ構造の違いから発生する静的インピーダンスミスマッチを指しています。
4.1ではドメインモデルに関する振る舞い・操作をドメインモデル内に、3.2ではロジック層に実装してそれぞれ問題が発生しました。そもそもなぜこういった問題が発生するかを考えると、そもそもドメインモデルのデータ構造とストアで使用するテーブルオブジェクトのデータ構造が異なるからといえます。
Roomエンティティという集約ルートに対して永続化処理を実施する場合、ストアではRoomテーブルとRoomUserテーブルにそれぞれ永続化する必要があります。仮にRoom用背景写真を作成時にアップロードできるとしたら別途S3等のAPIに対してのアップロード処理も必要になってきます。
いくらドメインモデルで利用者の関心事を集約しようとしても、実際の永続化の処理においてストアで使用している技術の技術の関心事が流入することを防ぐのは難しく、結果的にドメインの関心事(利用者の関心事)が流出してしまいます。
※余談ですがJPAの@Entityや@Embeddableを利用し、無理やりテーブルオブジェクトとドメインモデルを共通化させるような実装もありますが、あまりお勧めしません。そもそもO/Rマッパーの特性に依存していますし、どうしてもテーブルオブジェクトで持つ、作成日時などのメタ情報など、利用者の関心事ではない技術的関心事が流入していしまいます。
#5. クリーンアーキテクチャ
今回は4章に記載しました課題の解決策としてクリーンアーキテクチャを採用します。**ただし上記問題の解決手法は他にもありクリーンアーキテクチャの採用が唯一の解決方法ではありません。**またレイヤードアーキテクチャを採用したとしても上記問題を解決する方法もありますが、今回は最もシンプルに解決できると考えクリーンアーキテクチャを解決手法として選択しています。
5.1 なぜクリーンアーキテクチャなのか
4章では、「技術的関心事の流入」や「ドメインモデル貧血症」、「インピーダンスミスマッチ」が発生してしまいました。これらの問題はアプリケーションが肥大化するほど顕著になりうる問題で、これらを防ぐためには、「業務の関心事」、「ドメインの関心事(利用者の関心事)」、「インピーダンスミスマッチ」に関する処理を扱う層それぞれ分けた上で依存関係を単方向にする必要がありました。
なぜ依存関係を単方向にする必要があるか。ただ層を増やすだけでも、取り急ぎの問題は解決できそうです。しかし互いが互いに依存するような関係になった場合、依存関係の管理は難しくなります。この依存関係を単方向に管理した上で、各層に処理を集約する事に適した設計手法がクリーンアーキテクチャになります。
5.2 クリーンアーキテクチャとは
クリーンアーキテクチャとは関心の分離を目的としており下記のような特徴をもっています。
-
フレームワークからの独立
アーキテクチャは、機能満載のソフトウェアのには依存しません。これによりフレームワークを道具として使うことを可能にし、システムをフレームワークの限定された制約から開放されます。 -
テストの容易性
UI、データベース、ウェブサーバー、その他外部の要素なしにテストできます。 -
UIからの独立
UIは、容易に変更できる。システムの残りの部分を変更する必要はない。たとえば、ウェブUIは、ビジネスルールの変更なしに、コンソールUIと置き換える事が可能です。 -
データベースからの独立
OracleあるいはSQL Serverを、Mongo, BigTable, CoucheDBあるいは他のものと容易に交換することができます。ビジネスルールは、各種データベースの制約を受けません。 -
外部機能からの独立
外界の事情には一切関与しません。
下記の図を参考に実装に落とし込んでみましょう。 下記のEntitiesとはドメインモデルにおけるエンティティ、つまりドメインの関心事(利用者の)を集約します。UseCasesでは業務の関心事を集約します。InterfaceAdaptersではデータの変換を管理しApiにおける入力に関する処理、あるいはレスポンスに関する処理、またドメインモデルのデータ構造とストアのデータ構造を集約します。
5.3 用語解説
下記は基本となる円における層の用語解説になります。層の数は明確に決められているわけではなくアプリケーションによって増えることも減ることもあります。
外側になるほど低レベルで具体的な詳細を表現し、内側に行くほど抽象化されより高レベルになっていきます。何層用意するかについてはアプリケーションの事情との相談になります。しかし5.4で記載している依存ルールは何層であっても常に適用されます。
5.3.1 External Interfaces(Frameworks & Drivers)
ここは外界との境界であり、内界と外界の結合部分になります。ここにはストア関連で言えばフレームワークやO/Rマッパー等、UI関連で言えば静的なHTMLファイルなどがが存在します。
外部ストアに関するインターフェスが多くない場合省略することも可能です。
5.3.2 Interface Adapters
こちらは外界と接続及び変換を担います。具体的には外部からのリクエストを受け取るAPIのためのController、レスポンスを返却するためのPresenter、あるいは外部ストア、外部APIを利用するためのデータの変換などを管理します。
コントローラを例にすれば、Controllerでリクエストを受取り、Usecaseに値を渡し、戻り値をPresenterを経由して返却します。
5.3.3 Usecases
この層ではアプリケーション固有のビジネスルールを含むユースケースをカプセル化し実装します。MVCにおけるServiceクラスを連想するかもしれませんが、この層ではAPIにおいてUIから呼び出されるUsecaseだけでなく、ドメインモデルから外部ストアへの接続に関するUsecaseも存在します。
5.3.4 Entities(DomainModel)
中心部となるエンティティはドメインモデルにおけるエンティティを表現します。構造化されたドメインモデルと、各種ドメインモデルに関する振る舞い・操作を行う集約ルート用のRepositoryが存在する。
集約ルートの数とそれらの操作を行うRepositoryの数は同数となる。
5.4 依存ルール
図4の円はソフトウェアの領域を表しており、外側はメカニズムであり内側の円は方針となります。クリーンアーキテクチャを利用する上で重要な事は依存ルールであり、常に外側は内側に依存します。内側の円は外側の円にいっさい関与しません。例えば関数やクラス、変数等を内側から外側に言及してはいけません。
しかしこれらを実装しようとすると1つ問題が発生します。例えばInterfaceAdapterにある「Controller」から内側の円である「Usecase」、中心の「ドメインモデル(エンティティ)」への方向の処理であれば依存関係は常に外側から内側に向いています。しかし「ドメインモデル(エンティティ)」から「UseCase」、そして外界にあるストアインターフェースへの接続を担うInterfaceAdapterにある「Gateway」へ向かう時、依存関係は内から外に向いてしまいます。
このケースでは**DIP(依存関係逆転の原則)**を用いて解決します。図5はDIPの一例で処理方向は「DomainModel」から「Usecase」、「InterfaceAdapters」ですが、依存方向は逆になっています。こうすることで処理方向にかかわらず依存関係を外側から内側に向けることが可能になります。
この手法はクリーンアーキテクチャにおいて様々なケースで使用し、例えばコントローラーからユースケース、プレゼンター等も同様にDIPを用いて解決します。
クリーンアーキテクチャにおいて重要なのは、常に外側から内側に依存関係を維持し、内側は外側に一切の関与をしないことです。
図5 DIP(依存関係逆転の原則)
DIP(依存関係逆転の原則)まとめ
- 内側は外側に依存しない。
- DIを利用することで実装の詳細に依存しない・関与しない。
- 内側も外側も全ては抽象に依存する。
5.5 パッケージ構成例
下記は本記事で扱っているメッセンジャーアプリに関するAPIのパッケージ構成例になります。
外側から見ていくと、外端はadapterパッケージになります。adapter以下のgatewayでは外部ストアに関する永続化を管理し、webでは入力用のcontroller、presenterを管理します。いずれも実装クラスが展開されています。
次にusecaseパッケージに見ていくと、web、gateway、aggregateが存在し、webはcontorller、presenterに関するinputport及びoutputportを管理しています。cotrollerからの入力はusercase.web.inputportのインターフェースを経由し、usercase.web.outputportを経由し返却されます。interactorはinpurportに存在するインターフェースの実装クラスでweb利用するusecaseを集約しています。
aggregateはdomainのrepositoryで宣言されたインターフェースの実装クラスになり、ドメインモデルに関する振る舞い・操作はaggregateに集約されます。gatewayについてはストア等の外界と接続するためのインターフェースが定義され、実態はadapter.gateway.rdsやadapter.gateway.redisに存在します。
図6 パッケージ構成例
5.6 処理フロー例(外部インターフェースが少数の場合)
下記は外部インターフェースが少数を想定した処理フロー例でExternalInterfaceはAdapterにマージされています。オレンジ色の矢印が実際の処理のフローを表現しており、常に外側から内側に依存関係が向くようPresenterやRdsに関する処理ではDIP(依存関係逆転の原則)を利用しています。
5.7 クリーンアーキテクチャ処理フロー例(外部インターフェースが多数の場合)
下記は外部インターフェースが多数の場合想定した処理フロー例でExternalInterfaceはAdapterにマージせず独立して存在しています。多数存在する外界とのインピーダンスミスマッチを解決するためにGatewayに関する実装クラスを宣言しており、ここで解決しています。
図8 クリーンアーキテクチャ処理フロー例(外部インターフェースが多数の場合)
#6. まとめ&サンプルアプリケーション
このようにクリーンアーキテクチャを使用することで、業務の関心事、ドメインの関心事(利用者の関心事)、技術的関心事がきれいに分離されました。とくに全ては抽象に依存する、外側から内側に依存するという原則のお陰で、各種関心事が流入したり、流出したりすることを防いでいます。
実際にクリーンアーキテクチャを用いて作成したサンプルアプリケーションを下記に公開しています。(突貫で作っているため動作は保証できません。。)
また実際に外界の影響を受けないのか検証のためDynamoDBに移行したものも用意しています。
DDD+クリーンアーキテクチャ(RDS使用)
DDD+クリーンアーキテクチャ(DynamoDb移行版)
#7. 参考文献
エリック・エヴァンスのドメイン駆動設計
ドメイン駆動設計の基本を理解する
The Clean Architecture
The Clean Architecture(翻訳)
持続可能な開発を目指す ~ ドメイン・ユースケース駆動(クリーンアーキテクチャ) + 単方向に制限した処理 + FRP