きっかけ
ドメイン駆動開発について社内で少し議論した際に、曖昧な部分が残っていること、自分なりにリポジトリ構成がどうあるべきかをちゃんと考えられていなかったことに気付いたため、自分なりに納得のいく構成を整理してみようと思った。
全体概要
クリーンアーキテクチャの考え方に則る。
が、円を上下並びに置き換えて捉えると、下記理解。
シンプルなアプリケーション構成の場合
全体構成
全体概要をもう少し実装ベースで整理すると、下記想定。
リポジトリ構成
middleware/
controller/
usecase/
domain/
┣xxxDomain個別ドメイン/
┃┣xxxDomain
┃┗xxxDomainRepository(IF)
infrastructure/
┗┣repository/
┃┗xxxDomainRepositoryImpl
┣database/
┗client/
各クラスの役割
- middleware
- 認可処理やログ設定など
- controller
- ルーティング
- 入力必須項目・型のチェック(ユースケースが実行可能かどうかだけチェック。それ以上のチェックはユースケース側で行うので、コントローラ側では行わない)
- ユースケース実行
- 出力データ編集(ユースケースの結果を必要に応じてカスタマイズ)
- usecase
- 業務処理を実行
- データ参照・更新が必要な場合、リポジトリを介して行う
- ドメイン操作において業務ルールに違反している場合、エラーを返す
- トランザクション管理
- 業務処理を実行
- domain
- 各domain
- 業務モデルを定義
- 業務ルールを定義
- 各repository(IF)
- 紐づくdomainのデータ永続化(CRUD)を抽象化
- 各domain
- infrastructure
- repository
- repository(IF)を実装する、具体的なデータ永続化実装
- database
- DB接続関連実装
- client
- 各外部APIの送受信関連実装
- repository
システムが複雑化してきた時に発生する課題
repositoryがデータ永続化を全て担う場合、システムが複雑化してくるとデータ読み取り関連のパターンが増えてきたり、特定項目のデータ参照のためだけにドメイン全体の取得が必要になって通信コストが肥大化するなどの問題が発生するようになる。
<例① データ参照関連のパターンが増加してrepositoryIFが汚くなっていく>
save
delete
findActiveUsers
findInactiveUsers
findUsersByRole
findUsersByRoleAndStatus
findUsersByRoleAndStatusAndArea
find...
……または……
findBy( <- ひとつのメソッドにまとめると、今度は引数が肥大化
isActive,
role,
status,
area,
……
)
<例② 特定項目のデータ参照のためだけにドメイン全体の取得が必要になって通信コストが肥大>
id
name <- この項目だけ参照したい場合でも、他の項目も取得対象になる
createdAt
updatedAt
condition <- 例えばこの項目がDBでは別テーブル管理や外部API経由だと、通信コストが増加する
解決方法として、Specificationを活用するパターンやCQRSを導入するパターンがある。
複雑なアプリケーション構成の場合
Specificationを活用するパターンとCQRSを導入するパターン、どちらでもやりたいことは変わらず、データ読み取り関連の処理最適化。
ただ、ardalis氏のSpecificationの考え方の方がCQRSよりも、よりDDDに寄っている認識(Evans氏が提唱するSpecificationとは別の考え方なので、あくまで"寄っている"もの)。
思想としての統一感を優先し、自分としてはSpecificationを使った実装方式を採用したい。
が、このパターンはC#のLINQありきのようなので、それ以外の言語だとおとなしくCQRS採用する方がシンプルかもしれない。
リポジトリ構成
middleware/
controller/
usecase/
┣usecaseName(GetUserとか)/
┃┣xxxUsecase
┃┣xxxDto
┃┗xxxSpec
domain/
┣xxxDomain個別ドメイン/
┃┣xxxDomain
┃┗xxxDomainRepository(IF)
infrastructure/
┣repository/
┃┗xxxDomainRepositoryImpl
┣database/
┗client/
各クラスの役割
基本はシンプルなアプリケーション構成の時と同じ。
usecaseの部分で追加しているSpecやDtoの定義、使い方はardalis氏のSpecificationのドキュメント参照。
Specificationを採用することにより、Repositoryの読み取りメソッドはSpecを引数とするのみとなり、IFの肥大化を防ぐことができる。
public interface IReadRepository<T>
{
Task<T?> FirstOrDefaultAsync(
ISpecification<T> specification,
CancellationToken cancellationToken = default);
Task<List<T>> ListAsync(
ISpecification<T> specification,
CancellationToken cancellationToken = default);
// DTO投影対応
Task<TResult?> FirstOrDefaultAsync<TResult>(
ISpecification<T, TResult> specification,
CancellationToken cancellationToken = default);
Task<List<TResult>> ListAsync<TResult>(
ISpecification<T, TResult> specification,
CancellationToken cancellationToken = default);
}
public class CustomerSpec : Specification<Customer, CustomerDto>
{
public CustomerSpec(int age)
{
Query.Where(x => x.Age > age)
.OrderBy(x => x.FirstName)
.Select(x => new CustomerDto(x.Id, x.Name));
}
}
ドメインイベントを採用したいアプリケーション構成の場合
ドメインイベントを定義し、イベントを購読する形を採用したい場合、ドメインイベント・イベントハンドラ・イベントバス(イベントハンドラが複数ある場合のまとめクラス)などを追加する必要がある。
また、同一アプリケーション内でのイベント購読のほか、SQSなどを通じた外部サービスに対するイベント配信の形もある。
リポジトリ構成
middleware/
controller/
usecase/
┣usecaseName(GetUserとか)/
┃┣xxxUsecase
┃┣xxxDto
┃┣xxxSpec
┃┣xxxEventHandler
┃┗xxxEventBus(まとめが必要なら)
domain/
┣xxxDomain個別ドメイン/
┃┣xxxDomain
┃┣xxxDomainEvent
┃┗xxxDomainRepository(IF)
infrastructure/
┣repository/
┃┗xxxDomainRepositoryImpl
┣database/
┣client/
┗event/
┣externalEventHandler
┗externalEventBus(まとめが必要なら)
モノレポ構成
モノレポ構成にするなら、下記のような構成が良さそう。
各構成要素がCoreに対して依存関係を持つ形で、全体概要の依存関係をきちんと維持できる。
<Application>
middleware/
controller/
<Batch>※Coreを共有利用するなら
entrypoint
process/
<Core>
usecase/
┣usecaseName(GetUserとか)/
┃┣xxxUsecase
┃┣xxxDto
┃┣xxxSpec
┃┣xxxEventHandler
┃┗xxxEventBus(まとめが必要なら)
domain/
┣xxxDomain個別ドメイン/
┃┣xxxDomain
┃┣xxxDomainEvent
┃┗xxxDomainRepository(IF)
<Infrastructure>
repository/
┃┗xxxDomainRepositoryImpl
┣database/
┣client/
┗event/
┣externalEventHandler
┗externalEventBus(まとめが必要なら)

