QuarkusでDDD
本資料はquarkusを用いてドメイン駆動設計(DDD)実践する場合の実装例を紹介するものである。
モチベーション
ある程度の規模のあるプロダクトを開発すると、徐々にコードは秩序を失い、大きな泥団子のようなカオスが出来上がります。
増え続ける不具合、不明確な仕様、仕様を把握するために読まないといけないのは無秩序なコード、仕様はドラゴンボールのように砕け散り、様々な箇所に統一感なく存在する。
エンジニアはかすかな期待をもとにわずかに文書化されたドキュメントを読むが、リリース圧力1に負けたそれをため息とともに閉じ、代わりにエディタを開いてドラゴンボールを探す思考の旅に出るのである。
こんな物語が何十年もこの業界のオフィスで繰り返されてきたことに胸を痛めつつ、ドラゴンレーダーとは言わないまでも、せめて羅針盤のようなものがないかとたどりつたのがドメイン駆動設計(DDD)でした。
ここから書くのは私の理解と愚痴です。現実と異なることもありますが、つらすぎる現実から目を背けなければ精神の健康は保てないのです。
ユビキタス言語
DDDでは同一の概念を同一の言葉で表現するためにユビキタス言語というものを作成する。
今回はシンプルなユーザーとグループの関係を題材とする。
単語 | 英字 | 意味 | 備考 |
---|---|---|---|
ユーザー | User | サービス利用者のこと | |
グループ | Group | グループを表現する組織 | |
メンバー | Member | グループに所属するユーザー | |
オーナー | Owner | グループを作成・管理するユーザー |
アーキテクチャ
クリーンアーキテクチャの考え方を取り入れた3層レイヤーアーキテクチャを採用する
参考までにディレクトリ構成は下記の構造にしている。
src
├── main
│ ├── java
│ │ └── sample
│ │ └── usermanagement
│ │ ├── config
│ │ │ └── AwsCognitoConfig.java
│ │ ├── domain
│ │ │ ├── event
│ │ │ │ └── UserRemovedEvent.java
│ │ │ └── model
│ │ │ ├── EmailAddress.java
│ │ │ ├── Group.java
│ │ │ ├── GroupId.java
│ │ │ ├── GroupName.java
│ │ │ ├── User.java
│ │ │ ├── UserId.java
│ │ │ └── UserName.java
│ │ ├── infrastructure
│ │ │ ├── client
│ │ │ │ └── AwsCognitoClient.java
│ │ │ ├── entity
│ │ │ │ ├── GroupEntity.java
│ │ │ │ └── UserEntity.java
│ │ │ └── repository
│ │ │ ├── GroupRepository.java
│ │ │ └── UserRepository.java
│ │ ├── presentation
│ │ │ ├── controller
│ │ │ │ ├── GroupController.java
│ │ │ │ └── UserController.java
│ │ │ ├── dto
│ │ │ │ ├── AddGroupUserDto.java
│ │ │ │ ├── CreateGroupDto.java
│ │ │ │ ├── CreateUserDto.java
│ │ │ │ ├── EmptyDto.java
│ │ │ │ ├── GetGroupMembersDto.java
│ │ │ │ └── GetUserDto.java
│ │ │ └── input
│ │ │ ├── CreateGroupInput.java
│ │ │ └── CreateUserInput.java
│ │ ├── usecase
│ │ │ ├── command
│ │ │ │ ├── AddGroupUserCommand.java
│ │ │ │ ├── AddUserToGroupCommand.java
│ │ │ │ ├── ChangeUserNameCommand.java
│ │ │ │ ├── CreateGroupCommand.java
│ │ │ │ ├── CreateRoleCommand.java
│ │ │ │ ├── CreateUserCommand.java
│ │ │ │ ├── RemoveUserCommand.java
│ │ │ │ └── RemoveUserFromGroupCommand.java
│ │ │ └── query
│ │ │ ├── GetGroupMembersQuery.java
│ │ │ ├── GetUserQuery.java
│ │ │ ├── ListGroupQuery.java
│ │ │ └── ListUserQuery.java
│ │ └── util
│ │ └── ResponseBuilder.java
│ └── resources
│ └── application.properties
└── test
└── java
└── sample
└── usermanagement
└── presentation
└── controller
└── UserControllerTest.java
ドメインレイヤ
バリューオブジェクト
今回、バリューオブジェクトの実装にはrecordを使用する。
Stringなどからの生成はスタティックメソッドで実施している。
public record UserId(UUID uuid) {
public static UserId fromString(String rawUuid){
return new UserId(UUID.fromString(rawUuid));
}
@Override
public String toString() {
return uuid.toString();
}
}
TIPS ランダムなUUIDとユニットテスト
ユニットテストにおいてUUIDの生成を固定値で行いたい場合がる。
public record UserId(UUID uuid) {
public static UserId createUserId(){
return new UserId(UUID.randomUUID());
}
}
この実装にはユニットテストにおいて課題がある。
Mockito.mockStatic()にはcurrentスレッドだけで動作するという制約がある。
ワーカースレッド切り替えるQuarkusにおいては正しく動作しない。
このため 、インフラストラクチャレイヤーのRepositoryで下記のように実装する。
@ApplicationScoped
public class UserRepository {
public UserId createUserId(){
return new UserId(UUID.randomUUID());
}
}
@QuarkusTest
class UserControllerTest {
@InjectSpy
UserRepository userRepository;
@Test
public void createUser() {
UserId userId = UserId.fromString("00000000-0000-0000-0000-000000000000");
doReturn(userId).when(userRepository).createUserId();
given()
.when()
.body("{\"username\": \"name\",\"email\": \"testmail@test.dummy\",\"password\": \"P@ssw0rd\"}")
.contentType(ContentType.JSON)
.post("/v1/users")
.then()
.statusCode(200)
.body(is("{\"userId\":\"00000000-0000-0000-0000-000000000000\",\"userName\":\"name\",\"emailAddress\":\"testmail@test.dummy\",\"roleIds\":[],\"groupIds\":[]}"));
}
}
ドメインモデル
今回のドメインモデルはユーザとグループというシンプルなモデルを対象とする。
この程度のモデルであればDDDを用いるメリットは希薄であるが、要点を理解するためのサンプルということで納得してほしい。
集約
DDDにおいて集約の範囲を選択することはとても重要な意味を持つ。
整合性を維持する範囲であり、データベースにおいてはトランザクションの範囲となる。
大きすぎると、トランザクションの範囲が大きくなり、性能が劣化する。
集約は業務要件が許す限り、小さいほうが望ましい。
そこで集約を下記のように分離する。
集約は次のように実装されなければならない。
- 参照にはIdを使用する
- 集約に対する操作はすべて集約ルートであるルートエンティティを介して行われる
Group集約をUser集約に内包する下記のような実装もあり得る。
このようにした場合、Groupに対する操作を集約ルートであるUserエンティティを介して行う必要があるため、表現したいドメインと感覚的に合わないという判断で採用しなかった。
GroupはUserが作成したリソースで、それを指し示すために常にユーザー配下で管理されるべきと考えるのであれば、この実装を採用するという判断もあり得る。
このような決定はドメインを適切に読み取り、慎重に判断しなければならない。
プレゼンテーションレイヤ
プレゼンテーション層の役割は、ユーザーとアプリケーションレイヤとのやり取りの入出力を翻訳し、表現形式の違いを補間することだ。
例えばHTTPに伴う制約や表現をアプリケーションレイヤに伝えてはいけない。ヘッダーやエンコーディングなどの情報である。
もしもこれらをアプリケーションレイヤを伝えると、アプリケーションレイヤの実装がHTTPに依存してしまい、将来なんらかの都合で別のプロトコルに置き換えることができなくなってしまう。
注意点としては、アプリケーションレイヤの入力にCreateUserInputを使用してはいけない。
CreateUserInputは入力を受け取るため、フレームワークにより作成することが求められたクラスである。
このため、Validationの実装などフレームワークに依存した実装を多く引き受けることになる。
アプリケーションレイヤとの入出力に同一のクラスを用いると単体テスト時にフレームワークに依存したコードが必要になってしまったり、余計なことを考慮する必要が出てしまう。
アプリケーションレイヤはフレームワークへの依存もなるべく減らした方が良い。
@Path("v1/users")
public class UserController {
private static final Logger LOG = Logger.getLogger(UserController.class);
@Inject
RemoveUserCommand removeUserCommand;
@Inject
CreateUserCommand createUserCommand;
@POST
@Path("/")
@Transactional
@Consumes(APPLICATION_JSON)
@Produces(APPLICATION_JSON)
public Uni<Response> createUser(CreateUserInput createUserInput) {
return Uni.createFrom().item(createUserInput)
.map(cui -> createUserCommand.apply(cui.username(), cui.email(), cui.password()))
.map(createUserDto -> Response.ok(createUserDto).build())
.onFailure().invoke(throwable -> LOG.error(throwable.toString()))
.onFailure().recoverWithItem(Response.serverError().build());
}
}
アプリケーションレイヤ
本実装ではアプリケーションではサービスクラスではなくユースケースとして実装している。
ユースケース
ユーザーに対するユースケース
ユースケース | 意味 | 備考 |
---|---|---|
作成 | ユーザーを作成する | |
名前の変更 | ユーザー名を変更する | |
メールアドレスの変更 | ユーザーのメールアドレスを変更する | |
削除 | ユーザーを削除する |
グループに対するユースケース
ユースケース | 意味 | 備考 |
---|---|---|
作成 | グループを作成する | |
名前の変更 | グループ名を変更する | |
ユーザーの追加 | グループにユーザーを追加する | |
削除 | グループを削除する |
本実装のユースケースの役割はアプリケーションレイヤーにおけるサービスクラスとほとんど同じである。
- Repositoryからドメインモデルを復元する処理
- Repositoryにドメインモデルを反映する処理
- その他、集約をまたぐ処理などの調停
サービスクラスと本実装におけるユースケースの違いは、単一のユースケースの処理に特化していることである。
サービスクラスでは下記のような実装が多い
@ApplicationScoped
public class UserService {
@Inject
UserRepository userRepository;
@Inject
EmailSender emailSender;
public UserDto create(String username, String emailAddress, String password) {
User user = new User(userRepository.createUserId(), UserName.of(username), EmailAddress.of(emailAddress));
emailSender.sendEmail(emailAddress);
return UserDto.fromUser(user);
}
public UserDto changeUsername(String rawUserId, String rawUserName) {
UserId userId = UserId.fromString(rawUserId);
UserName userName = UserName.of(rawUserName);
User user = userRepository.find(userId).orElseThrow();
user.changeName(userName);
userRepository.save(user);
return UserDto.fromUser(user);
}
}
サービスクラスは同種の処理を一つのクラスにまとめているが、そこに論理的な必然性はない。
例えば単体テストを実装する場合、createをテストする際にはemailSenderが必要であるが、changeUsernameをテストする際にはemailSenderは不要である。
しかし、UserServiceはemailSenderに依存するため、本来必要のないemailSenderを準備しなくてはならない。
もしもemailSenderの生成ロジックに変更があった場合、changeUsernameのテストも変更しなくてはならなくなる。
この変更はプログラムを維持管理する人間にとって直感的ではなく、ミスが発生しやすい。
このような課題が生じるのは、サービスクラスは人間にとって処理をカテゴライズするために実装されており、単一責務の原則に準じていないためだ。
今回のユースケースの実装では、人間のために同種の処理をクラスにカテゴライズしたりはしない。
シンプルにユースケース単体の処理を実装し、依存するクラスもその処理に必要なリソースに限定する。
これによりユースケースレイヤーでの依存関係が明瞭になり、ユニットテストの実装やコードのメンテナンスが容易になる。
また、集約間の調停もこのレイヤーの役割である。
下記はユーザーを削除する操作であるが、ユーザーを削除した際に、Groupからもユーザーを削除する操作を実現している。
@ApplicationScoped
public class RemoveUserCommand {
private static final Logger LOG = Logger.getLogger(RemoveUserCommandBk.class);
@Inject
UserRepository userRepository;
@Inject
GroupRepository groupRepository;
@Transactional
public EmptyDto apply(String rawExecutionUserId, String rawRemovedUserId) {
UserId executionUserId = UserId.fromString(rawExecutionUserId);
UserId removedUserId = UserId.fromString(rawRemovedUserId);
User user = userRepository.find(removedUserId).orElseThrow();
userRepository.remove(removedUserId);
user.getGroupIds().stream()
.map(groupRepository::find)
.map(opg -> opg.map(group -> group.removeMember(removedUserId)))
.forEach(opg -> opg.ifPresent(groupRepository::save));
return new EmptyDto();
}
}
インフラストラクチャレイヤ
Repository
リポジトリの役割は、メモリに永続的に保持できないドメインモデルを一時的に保存することである。
もしも、ドメインモデルをメモリに永続的に保持できればRepositoryは不要であるが、現代のコンピュータのメモリは有限であり、電源断のリスクは常にある。
ドメインモデルの整合性を常に維持できるように、Repositoryにより保存される。
Repositoryはドメインモデルを保存するため、メソッドにはドメインモデルを受け取る。
内部処理ではデータベースや、外部のAPIと連携することで、メモリ上にあるドメインモデルを永続的な形に変換していく。
このとき発生する表現形式の差異、インピーダンスミスマッチを解消することがRepositoryの役割である。
Repositoryの実装として、ORMを使用する戦略もあるが、多くの場合DDDではうまく行かない。
ドメインモデルの永続先としてデータベースは一つの選択肢であり、常にデータベースが選択されるわけではないからだ。
ORMはデータベース用途に限定されており、これをRepositoryとしてしまうと、データベース以外の場所に値を保存する際にその手続を隠蔽するレイヤーを失うことになる。
@ApplicationScoped
public class UserRepository {
@Inject
AwsCognitoClient awsCognitoClient;
public UserId createUserId(){
return new UserId(UUID.randomUUID());
}
public Optional<User> find(UserId userId) {
return UserEntity.<UserEntity>findByIdOptional(userId.toString())
.map(UserEntity::toDomainModel);
}
public void save(User user) {
UserEntity.persist(UserEntity.getEntityManager().merge(UserEntity.fromDomainModel(user)));
}
public boolean remove(UserId userId) {
return UserEntity.deleteById(userId.toString());
}
}
応用
ここからは応用編となる
ドメインイベント
実装例 ユーザーの削除
ユーザーを削除する際のコードを例に見ていく。
変更前のコードは下記である。
@ApplicationScoped
public class RemoveUserCommand {
private static final Logger LOG = Logger.getLogger(RemoveUserCommand.class);
@Inject
UserRepository userRepository;
@Inject
GroupRepository groupRepository;
@Transactional
public EmptyDto handle(String rawExecutionUserId, String rawRemovedUserId) {
UserId executionUserId = UserId.fromString(rawExecutionUserId);
UserId removedUserId = UserId.fromString(rawRemovedUserId);
User user = userRepository.find(removedUserId).orElseThrow();
userRepository.remove(removedUserId);
user.getGroupIds().stream()
.map(groupRepository::find)
.map(opg -> opg.map(group -> group.removeMember(removedUserId)))
.forEach(opg -> opg.ifPresent(groupRepository::save));
return new EmptyDto();
}
}
UserとGroupの2つの集約はトランザクションによる強い整合性を持っている。
強い整合性が保証される反面、不利益も存在する。
そこで今回はquarkus-messaging-kafkaを使用してイベント駆動に置き換えていく。
参考 https://ja.quarkus.io/guides/kafka
quarkus-messaging-kafkaではEmitterを使用してイベントを発行する。
@ApplicationScoped
public class RemoveUserCommand {
private static final Logger LOG = Logger.getLogger(RemoveUserCommand.class);
@Inject
@Channel("remove-user-out")
Emitter<UserRemovedEvent> removeUserRequestEmitter;
@Inject
UserRepository userRepository;
@Transactional
public EmptyDto apply(String rawExecutionUserId, String rawRemovedUserId) {
UserId executionUserId = UserId.fromString(rawExecutionUserId);
UserId removedUserId = UserId.fromString(rawRemovedUserId);
User user = userRepository.find(removedUserId).orElseThrow();
userRepository.remove(removedUserId);
user.getGroupIds().stream()
.map(groupId -> new UserRemovedEvent(executionUserId, groupId, removedUserId))
.forEach(userRemovedEvent -> removeUserRequestEmitter.send(Message.of(userRemovedEvent)));
return new EmptyDto();
}
}
public record UserRemovedEvent(UserId executionUserId, GroupId groupId, UserId removedUserId) {
}
イベントの受取は@Incoming("remove-user-in")とすることで受け取ることができる。
@Path("v1/groups")
public class GroupController {
@Inject
RemoveUserFromGroupCommand removeUserFromGroupCommand;
@Incoming("remove-user-in")
public Uni<Response> removeUser(Message<UserRemovedEvent> userRemovedEventMessage) {
return Uni.createFrom().item(userRemovedEventMessage.getPayload())
.map(userRemovedEvent -> removeUserFromGroupCommand.apply(
userRemovedEvent.executionUserId().toString(),
userRemovedEvent.groupId().toString(),
userRemovedEvent.removedUserId().toString()))
.map(addGroupUserDto -> Response.ok(addGroupUserDto).build())
.onFailure().recoverWithItem(throwable -> Response.ok(throwable).build());
}
}
上記のように実装することで、イベントを介してユーザが削除されたという事象をGroup集約に伝えることができる。
イベントという疎結合な形で連携するため、負荷に応じてGroupを別のサービスとして分離することも容易に実現できる。
TIP DDDはいつ適用すべきか?
我々はDDDやクリーンアーキテクチャを学び、その有効性を確認して是非実践したいと考えるが、現実はそう簡単ではない。仕事に戻れば、そこにはリリース圧力に敗北したコードが延々と横たわる。このコードを打ち捨てて、一から書き直すための工数はどうやっても捻出できない。
テストコードが整備されていれば、リファクタリングというそれなりに工数はかかる作業で実現できるかもしれないが、現実にはそれすらも難しい状況が多い。リリース圧力に敗北したコードはテスタビリティが考慮されていない。レイヤー毎の役割の分離ができておらず、テスト観点が定まらず、実装も無理矢理に成ってしまっている。このためリファクタリングという選択肢もないというのが、それなりの規模と金を投入されたプロダクトにあったとしても多いのだ。
すべてのエンジニアはコード品質を良い状態にしたいと願っている。にも関わらず、現実にそれが叶わないのはリリース圧力の力がそれだけ強大だからだ。一説によると地殻変動の圧力よりも強い2とされている。エンジニアはスプリントの中で機能実装を優先し、機能分離やテスタビリティの優先度を下げてしまう。どうにか一週間で片付いたチケットを次は3日で終わらせることになる。我々は地球の重力から逃れられないのと同じように、リリース圧力の呪縛から逃れることはできない。
さて、もともとの議題はDDDやクリーンアーキテクチャをいつ適用するべきか?という話だが、理想はプロダクト開発初期に適用することが望ましい。アーキテクチャというのはあとから変更すことが極めて難しいからだ。しかし、リリース圧力が存在する世界ではこれも難しい。競争社会では機能実装こそが競争優位性であり、素早く使用できるプロダクトを開発し、機能実装していくことが最優先の戦略だからだ。このため、アーキテクチャやDDDによるモデル駆動開発というのは優先されない。ある程度の知識を持っていると、これを適用したほうが最終的な開発スピードは上がるという意見を持つだろうが、それは所詮エンジニアの意見だ。マネージャーは新しい技術をリスクと考えるし、そうしたアーキテクチャの学習コストを天秤にかけたとき、採用しないという選択肢を取る。機能実装にリスクのある選択は取ることができない。
多くのプロダクトでは、開発初期からDDDやクリーンアーキテクチャが採用されることはない。プロダクトの初期では成長が優先され、ある程度の顧客をかかえてから、仕様の整理やリファクタリングが行われる。私の知っている事例だと、その段階まで5年程度かかる。それまでに打ち捨てられるプロダクトも多いが、安定的な顧客を獲得してプロダクトの経済合理性が安定するまでにそれだけの歳月が必要なのだ。理想は開発初期からDDDやアーキテクチャを適用することだが、現実的な第2プランはこのタイミングでDDDを適用する形式だろう。このときスムーズにリファクタリングを実践するためには、テストが十分に整備されている必要があり、これを実現するには次の点が満たされている必要がある。
- レイヤードアーキテクチャの厳格な適用による役割の分離
- ユニットテストの整備
プロダクトの開発初期ではこの2点を譲らないように実装していくことが望ましい。もし、これを満たせていない場合は、5年後に深い後悔をすることになる。手に負えなくなったコードが次第に機能実装のスピードを低下させ、不具合の発生が顕著に多くなる。不具合の修正にも時間を要するようになり、5年かけて築いた顧客からの信頼を失うことになる。品質の低下により、市場から撤退していったプロダクトは少なくない。