はじめに
アソビュー! Advent Calendar 2023の17日目(B面)です。
アソビューでバックエンドエンジニアをしている長友です。
アソビューのバックエンド開発ではドメイン駆動設計(DDD)*1を実践しています。
これはアソビュー内の新旧様々なプロダクトに適用されており、ここ1年かけて行われてきた新規開発のプロダクトでもDDDを適用しています。
ただし、プロジェクト内の各メンバーにおいてはDDDに対する理解や経験にグラデーションがあり、実際のプロダクトコードはメンバーの経験を通して徐々に育っていきました。
そこで今回は新規開発のプロダクトにおいてDDDをベースとした実装を進めていく上で、当初簡易的だった実装がどのように複雑化し、それらにどのように対応してドメイン周りが育っていったか、その際にどのようなことをポイントとして意識したかを簡単に紹介したいと思います。
また、DDDの各登場概念の説明*2についてこの場で行うと非常にボリューミーとなってしまうため、ここではその実践にスコープを絞り取り上げていきたいと思います。
それでは見ていきましょう。
利用技術
Java11
Spring Boot
gRPC
Cloud Spanner
パッケージ構成
DDDの実装においてはレイヤードアーキテクチャを採用し、加えてCQRSを導入しています。
今回登場するものは以下になります。
uiパッケージ
- gRPCサービス
- Protocol Buffersのリクエストとドメインの変換を行うFactoryクラス
uiパッケージではgRPCを導入しているため、リクエストはProtocol Buffersで生成されたものを受け取り、それらをドメインに変換する役割を果たします。
Applicationパッケージ
- CommandService
CQRSを導入しているためApplicationパッケージにはCommandServiceが配置されます。各CommandServiceのpublicメソッドがトランザクション境界になります。
Queryパッケージ
- QueryService
Domainパッケージ
ドメイン
Repositoryインターフェース
Domainパッケージにはドメインとそれらを取得・永続化するためのRepositoryインターフェースが配置されます。
Infrastructureパッケージ
Repositoryを実装したDatasourceクラス
Dao
Daoについては本プロダクトではDoma2を導入しています。
以上の構成をパッケージ図にすると以下のようなイメージになります。
それではこの構成において、実装がどのように変遷していったか見ていきたいと思います。
プロジェクト初期の実装
プロジェクト初期では各ユースケースにおいて受け取ったリクエストを登録するような簡単な機能からスタートしていきました。
このような場合、下記のような処理の流れになります。
アプリケーションは受け取ったProtocol Buffersのリクエストをそのままドメインに変換 ↓ CommandServiceへ渡す ↓ CommandServiceはBean ValidationでドメインのValidationとデータ整合性チェックを行ない、Repositoryのsaveメソッドに渡す ↓ Repositoryの実装クラスであるDatasourceはいったん登録のみを考慮しデータベースにinsertする
例えばエンターテイメントのコンテキストで「公演」を登録する場合、以下のようなコードになります。
CommandService
@Validated @Transactional @Service @AllArgsConstructor public class EventService { private final EventRepository eventRepository; private final VenueRepository venueRepository; public void register(@Valid Event event) { // 公演が開催される会場が登録ずみかどうか検証 checkVenueExists(event.venueId()); // 公演を登録 eventRepository.save(event); } }
Domainパッケージ
/** * 公演 */ @Value @Accessors(fluent = true) public class Event { @NotNull @Valid EventId eventId; // 公演ID @NotNull @Valid EventName eventName; // 公演名 @NotNull @Valid VenueId venueId; // 公演が利用する会場のID @NotNull EventType eventType; // 公演種別 @NotNull EventPublicStatus eventPublishStatus; // 公演の公開状態 public Event( EventName eventName, VenueId venueId, EventType eventType ) { this.eventId = new EventId(); this.eventName = eventName; this.venueId = venueId; this.eventType = eventType; this.eventPublishStatus = EventPublicStatus.UNPUBLISHED; // 公演の初回作成時は非公開 } } /** * 公演ID */ @Value @Accessors(fluent = true) public class EventId { @NotBlank String value; public EventId() { // UUID v4を生成できる。 this.value = new IdStringBuilder().uuid().build(); } public EventId(String value) { this.value = value; } } // 公演を永続化するRepository public interface EventRepository { void save(Event event); }
Infrastructureパッケージのdatasource
// repositoryをimplements @Repository @AllArgsConstructor public class EventDataSource implements EventRepository { private final EventDao eventDao; @Override public void save(Event event) { // 各種テーブルにinsertのみ } }
ポイント
CommandServiceの引数であるドメインのValidation
ドメインのValidationはBean Validationとorg.springframework.validation.annotation.Validatedを組み合わせて実現しています。
Value ObjectやEntityにBean Validationのアノテーションを付与し、@Validatedが付与されたCommandServiceの@Valid付きのpublicメソッドを通すことで簡単にValidationができます。
特にValue ObjectのValidationにアノテーションを利用することで定義したフィールドと一緒に制約が宣言されているような表現できて可読性が高いと感じています。
特定パラメータのみを更新したいケースが出てきた場合の実装
開発が進むと下記のような更新要件が出てきます。
Entityの一部のみを更新したい
Entityの一部を更新する処理をそのまま1機能として他と分離したい
そういった場合はCommandServiceには更新用のメソッドを定義し、更新用のパラメータのみを保持するRequestEntityを受けるようにしました。
以下の例のようなコードとなります。
CommandService
public void updateEvent(@Valid UpdateEventRequest updateEventRequest) { // 登録済みの公演をeventRepositoryから取得 var event = getExistingEvent(updateEventRequest.eventId()); // 公演が開催される会場が登録ずみかどうか検証 checkVenueExists(event.venueId()); // 公演を更新 // ドメインはイミュータブルであるため、更新した結果を受け取る var updatedEvent = event.update( updateEventRequest.eventName(), updateEventRequest.venueId() ); // 更新した公演をeventRepositoryに保存 eventRepository.save(updatedEvent); } public void publishEvent(@Valid EventId eventId) { // 登録済みの公演をeventRepositoryから取得 var event = getExistingEvent(eventId); // 公演を公開できるかの各種チェック処理 checkEventPublishable(event); // 公演を公開 var publishedEvent = event.publish(); // 公演をeventRepositoryに保存 eventRepository.save(publishedEvent); }
Domainパッケージ
/** * 公演更新リクエスト */ @Value @Accessors(fluent = true) public class UpdateEventRequest { @NotNull @Valid EventId eventId; @NotNull @Valid EventName eventName; @NotNull @Valid VenueId venueId; } /** * 公演 */ @Value @Accessors(fluent = true) public class Event { @NotNull @Valid EventId eventId; // 公演ID @NotNull @Valid EventName eventName; // 公演名 @NotNull @Valid VenueId venueId; // 公演が利用する会場のID @NotNull EventType eventType; // 公演種別 @NotNull EventPublicStatus eventPublishStatus; // 公演の公開状態 public Event( EventName eventName, VenueId venueId, EventType eventType ) { this.eventId = new EventId(); this.eventName = eventName; this.venueId = venueId; this.eventType = eventType; this.eventPublishStatus = EventPublicStatus.UNPUBLISHED; // 公演の初回作成時は非公開 } public Event( EventId eventId, EventName eventName, VenueId venueId, EventType eventType, EventPublicStatus eventPublishStatus ) { this.eventId = eventId; this.eventName = eventName; this.venueId = venueId; this.eventType = eventType; this.eventPublishStatus = eventPublishStatus; } // 公演の更新 public Event update( EventName updateEventName, VenueId updateVenueId ) { // 会場の変更は公開済みの公演ではできない if (!this.venueId.equals(updateVenueId)) { if (isPublished()) { throw new IllegalArgumentException("公開済みの公演の会場は変更できません。"); } } return new Event( this.eventId, updateEventName, updateVenueId, this.eventType, this.eventPublishStatus ); } // 公演を公開 public Event publish() { if (isPublished()) { throw new IllegalArgumentException("すでに公開済みのため公開できません。"); } return new Event( this.eventId, this.eventName, this.venueId, this.eventType, EventPublicStatus.PUBLISHED ); } private boolean isPublished() { return this.eventPublishStatus == EventPublicStatus.PUBLISHED; } }
InfrastructureパッケージのDatasource
@Override public void save(Event event) { // すでに存在すれば更新、そうでなければ新規登録 eventDao.selectEvent(event.eventId().value()).ifPresentOrElse( (selectedEvent) -> update(event, selectedEvent), () -> insert(event) ); }
ポイント
更新対象であるEntityにも更新用のメソッドを定義
これによってそのドメインが責務を持つフィールドの更新処理自体も自身内に閉じるようにできます。
また、その時ドメイン内で完結できるチェック処理も自身に記述することで凝集度を高めることができます。
Repositoryはあたかもオンメモリにあるデータコレクションとやり取りしているような振る舞いを見せること
これはDDDにおけるRepositoryの振る舞いとして提唱されていることを守ろうということです。
そのため本プロジェクトのRepositoryには業務要件を意識させないためにget/list/saveの3種のメソッドのみを定義して良いと定め進めました。
登録・更新においてはそれ用に分かれたメソッドはなく、単に「保存」という意味のsaveメソッドで一括で永続化を引き受けます。
実装クラスであるDatasourceでは既にデータベースにデータがあれば更新、無ければ新規登録とします。
より"集約"を意識した実装への移行
前段の実装例では公演を公開する際にCommandServiceに「checkEventPublishable」を定義し、そこで公演の公開に必要なチェック処理を行なっていました。このようなEntity間の整合性を保つ処理はどうしてもServiceに書かざるを得ない場合もありますが、それを許し続けていると次第にEntity間の整合性を保つメソッドがCommandServiceの各所に必要となり、その中には似たような処理も見られるようになっていきます。
このため、本来「集約=Aggregation」として見れるドメインにはその役割を持たせていくことにしました。
ドメインのコード例としては以下のようになります。
Domainパッケージ
ここでは公演=Eventを集約と見做して、本来トランザクションを別に扱っていた公演日程(EventSchedules)や公演で扱う席種(SeatTypes)を公演で管理するようにしました。
/** * 公演 */ @Value @Accessors(fluent = true) public class Event { @NotNull @Valid EventId eventId; // 公演ID @NotNull @Valid EventName eventName; // 公演名 @NotNull @Valid VenueId venueId; // 公演が利用する会場のID @NotNull EventType eventType; // 公演種別 @NotNull EventPublicStatus eventPublishStatus; // 公演の公開状態 // ★ ↓ Eventを集約として捉え、それに付属する他のEntityも含める @NotNull @Valid EventSchedules eventSchedules; // 公演の開催日時 @NotNull @Valid SeatTypes seatTypes; // 公演で扱う席種 public Event( EventName eventName, VenueId venueId, EventType eventType ) { this.eventId = new EventId(); this.eventName = eventName; this.venueId = venueId; this.eventType = eventType; this.eventPublishStatus = EventPublicStatus.UNPUBLISHED; // 公演の初回作成時は非公開 this.eventSchedules = EventScheules.empty(); // 公演の初回作成時は開催日時は未設定 this.seatTypes = SeatTypes.empty(); // 公演の初回作成時は席種は未設定 } // 中略 /** * ★ 公演日程を追加する */ public Event addEventSchedule(EventSchedule eventSchedule) { // 公演が公開済みの場合は開催日時を追加できない if (isPublished()) { throw new IllegalArgumentException("公開済みの公演には開催日時を追加できません。"); } // 公演の開催日時を追加 var updatedEventSchedules = this.eventSchedules.add(eventSchedule); return new Event( this.eventId, this.eventName, this.venueId, this.eventType, this.eventPublishStatus, updatedEventSchedules, this.seatTypes ); } /** * ★ 席種を追加する */ public Event addSeatType(SeatType seatType) { // 公演が公開済みの場合は席種を追加できない if (isPublished()) { throw new IllegalArgumentException("公開済みの公演には席種を追加できません。"); } // 公演で扱う席種を追加 var updatedSeatTypes = this.seatTypes.add(seatType); return new Event( this.eventId, this.eventName, this.venueId, this.eventType, this.eventPublishStatus, this.eventSchedules, updatedSeatTypes ); } /** * 公演を公開する */ public Event publish() { // 公演が集約として傘下のドメインを収めているため、ここで公開できるかのチェックができる if (isPublished()) { throw new IllegalArgumentException("すでに公開済みのため公開できません。"); } if (eventSchedules.isEmpty()) { throw new IllegalArgumentException("公開できる公演日程がありません。"); } if (seatTypes.isEmpty()) { throw new IllegalArgumentException("公開できる席種がありません。"); } return new Event( this.eventId, this.eventName, this.venueId, this.eventType, EventPublicStatus.PUBLISHED, this.eventSchedules, this.seatTypes ); } private boolean isPublished() { return this.eventPublishStatus == EventPublicStatus.PUBLISHED; } }
ポイント
集約がトランザクション境界として機能し、安定したデータ整合性の担保ができるようになる
CommandServiceに書いてきたようなドメインを跨ぐ処理が集約に書けるようになり凝集度が上がる
ただし、反面下記のようなデメリットもあります
Cloud Spannerを用いた開発においては集約の取得により共有ロックの範囲が広がる
集約の用途によっては無駄なI/Oが増える
集約がEntitiyのコレクションを持つケースではメモリ展開量が非常に多くなってしまうことがある
この選択は上に記載したようにメリット/デメリットがあり、場合によってはデメリットが非常に大きくなる可能性もあります。
また、「何を集約と捉えるのか」は当初から想定していない場合では中々判断が難しいこともあります。
そのため、この集約の実装を取り入れる場所は機能やユースケース、ロジックの複雑さなどを様々考慮し慎重に進めていく必要があります。
おわりに
以上のようにアソビューの新規プロダクト開発におけるDDDの実践とドメインの育ち方を見てきましたが如何だったでしょうか。
ここで紹介してきた実装含めてまだまだ改善の余地があり、そして変化のたびにまた新しい壁にあたるかと思います。
私はDDDを本格的に実践していきたいという志望動機でアソビューへ入社した経緯があり、経験不足の中でもメンバーと相談しつつDDDの実践に頭を悩ませてきた1年は非常に良い経験となったと感じています。
より良いプロダクトにするため、継続的リファクタリングの実践とDDDへの理解を深めていきたいと思います。
アソビューでは一緒に働くメンバーを募集しています!
DDDに関心のある方・実践していきたい方、大歓迎です!
カジュアル面談もやっておりますので、お気軽にエントリーください!
www.asoview.com
*2:簡単な用語説明についてはこちらなどをご参照ください。
DDDの頻出用語をなんとなく理解する #設計 - Qiita