はじめに
Kotlin にて Web アプリケーションを開発していましたが、諸事情によりアプリケーションの開発は中断されてしまいました。
アプリケーション開発はそれなりに熱を持ってしっかりと考えを持ってやっていたつもりだったので、残念な気持ちが高かったです。
今後どうしたものか、という気持ちがありましたが、一方でアプリケーションの開発の中断が決まると、少し客観的に自分たちの作業を振り返るマインド・期間を得ることができました。
私が関わっていたアプリケーションは、私が参画する以前から動作していましたが、副作用を前提としたトランザクションスクリプトで構築されていたので、どこでどう値が変わったかが追いずらいといった問題がありました。
この問題に対応するため私たちは、DDD (ドメイン駆動設計) の手法を適用してリファクタ・新規機能開発を継続することにしました。
DDD の手法を適用することによってアプリケーションの可読性・メンテナビリティが向上したと感じていたので、自分たちが正しく DDD を実践していると考えていました。
しかし、新規機能やリファクタ箇所のリリース直後は理解していたと思われたソースコードを、調査依頼や追記機能の開発で 1 年後に見返すと、不明瞭に感じられる部分がでてくることに気づきました。
我々はきちんと DDD を実践しているのになぜ意味が不明瞭になるのだろうかと考えていましたが、時間が経つと記憶が薄くなってしまうので仕方がないとか、自分の知識不足・経験不足、といった個人の責任に属するものであろうと考えていました。しかし、落ち着いた今よく考えて冷静に振り返って見るとを新規プロジェクト参画者にこのロジックは通じるのか?と思うようになりました。
DDD を適切に実践していれば、ビジネス上の関心ごとをドメインモデルに適切に反映できるはずなので、プログラムを見ればビジネスの大枠を理解できるはずですが、ドメインモデルの設計の背景を知らない新規参画者の方の反応をみると、どうもうまくドメインモデルが設計できていないので、長く参画している方は理解できるけれど、そうでない方に見ていただいてすぐにキャッチアップできるものであったかがはなだなだあやしいと思うになった次第です。
このドキュメントでその原因について自分が思い至った点を振り返りたいと思います。
ドキュメントの立ち位置
これまでに私たちが実施してきた DDD による改善点を振り返りながら、思い至らなかった点を、私の備忘録的に明確にしたいと考えています。
プロジェクト中にはまったく気づくことができませんでしたが、メンバーでヴォーン・ヴァーノン.『実践ドメイン駆動設計』Kindle 版.翔泳社.2015 を再度読む機会を得た際、正解に近づくためのヒントがたくさん書かれていることに気づきました。
我々のアプリケーションには「戦略的設計」がなく、それが原因で設計に失敗したのではないかと感じるようになりました。
今回の読書前にも『実践ドメイン駆動設計』は何回か読んでいたのですが、どうして「戦略的設計」が必要であるかを理解するのに適した経験がなかったため、「戦略的設計」をうまく理解できておらず、それがこの失敗を産んだのではないかと感じています。
この失敗を直接改善する機会は今後ないのですが、ただ失敗してしまったという感想だけ持って終わるのでは勿体無い、そして個人的な経験をなんらかの形に残しておきたいと思い、そもそも失敗要因はなんだったのか、戦略的設計とはなにであったのか、をドキュメントに残そうと思いました。
このような考え方によって記述されているので、大いに『実践ドメイン駆動設計』の影響を受けたドキュメントになっています。また、戦略的設計を実践できているわけでもないので、いざ戦略的設計を導入したら、もしかしたら別の辛さが出てくる可能性もあると考えていますが、それ以上に戦略的設計は物事を整理する手法として有益であると考えています。ドキュメントの論調がこのような形なので、もしかしたらもっと良い解が存在しているのかもしれませんが、問題に関する解決策を『実践ドメイン駆動設計』からたくさん引用しています。引用に特に記述がなく、ページ番号のみが書かれている場合、『実践ドメイン駆動設計』からの引用になっています。
レガシーアプリケーションの設計
私が担当していたアプリケーションは、私がジョインしたタイミングでは Java で実装されていました。
フレームワークについては、Web レイヤは JAX-RS の実装である RESTEasy, DI は、CDI(Weld)、DB 周りは、Hibernate で、Hibernate を除きややマイナーなフレームワークであるとはいえ、一般的といえるフレームワークが採用されていました。
アプリケーションアーキテクチャは JavaBeans の影響が濃い、ゲッター/セッターを駆使した素朴なトランザクションスクリプトです。クライアントからの入力は、RESTEasy によりリクエストボディの JSON が入力の DTO にバインドされるので、これを Hibernate のエンティティ のセッターにマップして、トランザクションスクリプトに実装されたビジネスロジックを実施後、永続化します。出力は、ネイティブクエリはあまり使わず、Hibernate を使ってエンティティを取得して、加工するクラスが、Hibernate のエンティティのゲッターで得た値を出力用の DTO のセッターでセットして、RESTEasy が JSON 形式に変換してレスポンスボディにのせて返す流れです。
我々のビジネスにはいくつかの部門があり、それぞれが各業務をしていました。おそらく、それに影響されて、このアプリケーションの設計の論理単位は、部門になっていました。各部門ごとにクライアントアプリケーションが用意されており、そのクライアントアプリケーションが使うバックエンド側のエンドポイントは、部門ごとに Resource クラスが切られておりました。一番最初のアプリケーション設計者が、RESTful な API を知らなかったのか、すべての Web API は、RPC のような命名規則を持っていました。
public class DepartmentAResource {
@Inject
SomeService someService;
@Inject
SomeEntityService someEntityService;
@Path("/updateSomeDBTable")
@POST
public UpdateResponseDto updateSomeDBTable(RequestUpdateDto dto) {
SomeEntity entity = someEntityService.getById(dto.getId());
entity.setName(dto.getName());
entity.setDate(dto.getDate());
entity.setAddress(dto.getAddress());
entity.setTime(dto.getTime());
// ... さらにたくさんのプロパティ
// ビジネスロジックの実行
someService.someCommand(entity)
UpdateResponseDto output = new UpdateResponseDto();
// レスポンスステータスとは別に自前でステータスをセット
output.setStatus("OK");
return output;
}
@Path("/getSomeDBTable")
@POST
public OutputDto getSomeDBTable(RequestGetDto request) {
List<Entity> entities = someEntityService.getEntities(request.getCondition());
ResponseDto response = new ResponseDto();
response.setList(new ArrayList<EntityDto>());
for (Entity entity : entities) {
EntityDto dto = new EntityDto();
dto.setName(entity.getName());
... 必要なプロパティ
response.getList().add(dto);
}
// レスポンスステータスとは別に自前でステータスをセット
response.setStatus("OK");
return response;
}
サービスがローンチした段階ではこの設計でも問題がなかったと思われますが、私がジョインしたタイミングではある程度サービスが拡大しており、問題が際立っている状態でした。
1 クラスに大量のメソッドがある
エンドポイントの再利用性は考慮されておらず、画面上のボタンなどを一つの単位とした 1 アクション に特化したエンドポイントが個別に設計されていたので、例えば、画面の操作が 3 つあれば、3 メソッドが Resource クラスにぶら下がっていました。私が参画したタイミングでは経年により、1 Resource クラス内のメソッド数が相当数になって巨大化しており、部門によっては Resource クラスが最大 6000 行弱あるという状態でした。このような状態なので、Resource クラスは、「役割(責任) = 変更理由」(『アジャイルソフトウェア開発の奥義』 P123) になっておらず、どのような変更があっても触る可能性が高いソースファイルになっていました。このため、タスクを複数人で並行して進める場合、高い確率で同一の Resource ファイルを担当者が並行して編集するので、行頭の import が大量に存在していることもあり容易にコンフリクトを起こしていました。
画面の関心ごとをそのまま実装
Resource クラスは画面に返すレスポンスのフォーマット (整形) をしていました。
セッターを多用してても、単純な文字列・数字をセットするのであればシンプルですが、頻繁に文字列のフォーマットの実装が Resource クラスに入り込んでいました。
if (!CommonUtils.isEmpty(data.getTimeTo())) {
// : 08:00:00 (火) にする
dto.setTime(":" + data.getTimeTo());
} else {
// getTimeFrom() にプラス 1 時間にして 08:00:00 (火) にする
String time = dateUtil.createTime(data.getTimeFrom());
dto.setTime(time);
}
フレームワークによる、シリアライザー/デシリアライザー や、フレームワークでのデフォルトのフォーマット の機能を使っていなかったのでレスポンスの形式の統一感はなく、この手のセッターの整形のコードが至る所に散らばっていました。また、2016 年以降の開発だったのになぜか、Jdk 1.7 を採用していたので、日付はすべて 悪名高い Date 型 であったため、余計に読みづらいものになっていました。
クライアント側のレンダリング
クライアントアプリケーションは、Web API が返すレスポンスボディに含まれる JSON を JQuery で自作のテンプレート機能を実装してレンダリングしていました。
自作のテンプレート機能には tmpl や jsrender に存在している関数呼び出し、分岐、ループ、配列のサポート、整形といった機能はまったくないシンプルなものでした。できることは、JSON を素朴に画面上に表示することだけであるので、自作テンプレート機能に渡す前に整形する必要がありました。いろいろ機能がないのが問題でしたが、特に整形機能がないことでした。画面の関心ごとはサーバーサイドでフォーマットしているのですが、なかにはフォーマットしていないものも含まれており、tmpl では、
$.tmpl($("#template"), json)
と json を渡して、
${format(json.prop1}
と HTML 側でフォーマットするような実装を
// window オブジェクトを拡張して自作ライブラリの関数をセットさせている。せっかくサーバーサイドで生成した json を展開する必要がある。
var prop1 = format(json.prop1)
window.renderToTemplate("area", { prop1: prop1, prop2: json.prop2.....});
とJSON を展開し、
${prop1}
HTML ファイル側にもバインドさせなければなりませんでした。js ファイルと html ファイルに大量に類似コードが必要で、著しく可読性を下げていました。
Service クラスという恣意的なクラス
このアプリケーションでは、Service クラスというものが存在していました。
このクラスの役割は非常に曖昧で統一感がありませんでした。
Service クラスという名前から想起されるよう、なんらかの業務的な手続きをクラスに切り出しているように見え、たしかにそのようなことをしているように見えるメソッドもあるのですが、Dao クラスのように単純なデータベースアクセスに特化したメソッドも混在していました。
Dao のような性格もあるので、Service クラスは「なんらかのデータベースアクセスをするクラス」と、実装者の好みによって立ち位置が変わる恣意的な設計になっていました。
業務上のビジネスロジックがメソッドなどから見えてこない
Service クラスで、業務上の関心ごとを実装していたメソッド名は、業務上の関心ごとがベースで命名されたりしておらず、データベースのオブジェクトをベースに設計されていました。実装は、Hibernate のエンティティをゲッター/セッターの操作なであったこともあり、ビジネス上のなにを実現したいのかが見えづらいものが大半でした。ビジネス上の関心ごとが実装に入り込まないことが大半であったので、なかには「ただ実装者がオリジナリティを出したいだけのでは?」と思われるようなトリッキーな実装も混じっていて、解読が大変でした。
Service クラスが原則再利用不可
単なる Dao のように扱われるシンプルな Service クラスのメソッドは呼び出し元との結合度が低くなっていて理解しやすく再利用性がありましたが、業務を実現するための Service クラスのメソッド相当数は、特定のユースケースからしか利用できない、呼び出し元を意識した結合度が高い再利用性が乏しい設計になっていました。やりたいことがほぼほぼ同じであっても、呼び出し元が異なるので個別に実装され、コピペベースで類似したコードが散らばっている状態でした。
制御結合による結合度の向上
コピペの多用もありましたが、その一方で 制御結合 の結合度のメソッドもたくさんありました。
Service クラスのメソッドが改修さる際、修正箇所を極小化して、なるべく変更するコード量を減らしたいという実装者の意図から、新規メソッドの設計はされず、つぎはぎの if が Service クラスのメソッドに追加されているようでした。
public void usecase1(UseCase1 dto) {
// 業務要件1
if (dto.getName().equals("")) {
}
// 業務要件2
if (dto.getAddress().equals("")) {
}
}
要件ごとに if が増えるような改修のされかただったので、最終的に呼び出し元の事情を把握したメソッドになっていました。
これだと、引数により振る舞いを変わるので、メソッドの設計意図が汲み取りづらかったです。
副作用前提のプログラミング
セッターを多用する設計なので、変更箇所を特定することが難しくなる場合がありました。
例えば、Service クラスのメソッドでなんらかの値変更が行われた場合、いつ・なぜ・どのように変わったかなどを特定することが難しくなっていました。
@Inject
Usecase1Service usecase1Service;
public void usecase1(UseCase1 dto) {
Entity entity = entityService.getEntity(dto.getId());
entity.setName("name")
// doUsecase の中で、setName をなんらかの判断理由を基に実行するので entity の値がいつのまにか変わってしまっている
usecase1Service.doUsecase(entity);
}
デバッグできない
当時の開発手法は、jar ファイルをローカルでビルドし、なぜか Vagrant に jar を配置して、Vagrant の WildFly を再起動して動作確認する運用でした。
ローカル PC で動作することは想定されておらず、誰もデバッグ作業の実現に着手してこないまま放置されていたのでステップ実行ができない状況でした。
そのため、ソースコードをじっくり見たり、問題が発生しそうな箇所で都度都度ログ出力を実装して、Vagrant の Wildfly にデプロイ・再起動して、ログを見る作業を強いられて非常に非効率的でした。
改善の実行
これらは総じて開発を継続するにあたり問題であると判断したので、プロダクトオーナーに説明して、漸次的に改善をしていきました。アプリケーションの設計についてはリワークをしようとしましたが、まずその前に、フレームワークを Spring Boot に置き換えることにしました。Spring Boot は、Java アプリケーションで最も柔軟性が高くさまざまなフレームワークの選択肢を与えながら、シームレスに結合してくれるので、選択しない理由はないですし、RESTEasy, WildFly, Weld といったライブラリよりはるかに、情報にアクセスしやすく、フレームワークを置き換える価値は高いと判断されました。
Web レイヤのシームレスな統合
Spring Boot への移行において、Web レイヤーは通常であれば Spring MVC にするところですが、現行が JAX-RS (RESTEasy) でできており、JAX-RS のフィルタ が使われていることもあったので、変更箇所・影響箇所を極力減らすためいったん Spring Boot がサポートしている Jersey に置き換えることにしました。幸い、Jersey と Spring MVC は共存できたので、新規・リワークの Web API は Spring MVC にする方針としました。将来的には Web レイヤは、Jersey 以上に情報にアクセスのしやすい Spring MVC に移行することを想定したので、Resource クラスのクラス名を Controller として、適切な単位にクラスを分割しました。
分割のタイミングで、spring.jackson.date-format、シリアライザ・デシリアライザ を利用してレスポンスに統一感を持たせるようにしました。SPA にする余裕・モチベーションは低かったので、JQuery のクライアントアプリケーションはそのままにしつつ、自作のテンプレートエンジンの構文が tmpl と同じであったので tmpl を使うようにしました。
ヘキサゴナルアーキテクチャ導入
Spring Boot にフレームワークを移行したタイミングで我々は、ヘキサゴナルアーキテクチャ を導入し、「アダプター」のレイヤを意識し始めました。
Hibernate を利用していた時は、Resource クラスならびに Service クラスで、データベースのテーブルスキーマそのものである Hibernate のエンティティを扱っていました。Hibernate のエンティティは、データベースに永続化される値を保存するための器であり、値をデータベースに渡すためのアダプターとして扱われていたので、アプリケーションレイヤから、データベースというインフラストラクチャレイヤへ依存している状態でした。Spring Boot を導入したタイミングで、ヘキサゴナルアーキテクチャのセオリー通り、アプリケーションレイヤからインフラストラクチャ(データベース) の依存をなくして、代わりにインフラストラクチャレイヤがドメインレイヤに依存して、そのドメインレイヤのオブジェクトをアプリケーションレイヤが利用する 依存関係逆転の原則 を活かした リポジトリパターン を採用して、データベースへアクセスするようにしました。ドメインオブジェクトとしてのリポジトリはインターフェースで、そのインターフェースの実装が DB へのアダプターとして JOOQ を使います。
JOOQ の採用
永続化レイヤは、Spring Data JPA を使わず、JOOQ に置き換えることにしました。以前私が Spring Data JPA を利用した際に、個人的に Hibernate はいろいろとつらい と感じて、QueryDSL を利用しましたが、Spring Initializr に QueryDSL がないので、JOOQ を採用しました。私が、当時、Spring Data JPA がつらい・つらそうと感じたのはこんな要素です。
ファインダーメソッドの名前がとても長くなる
我々のアプリケーションでは、あるテーブルのカラムがすでに肥大化していて、そのカラム数の多さにより Spring Data JPA を使うとクエリのメソッドが長くなることが見えていました。
fun findTableNameDistinctByColumn1OrColumn2OrStartingWithColumn3Or..(prop1: String, prop2: String, prop3: String ...)
メソッドを定義するだけで対象のエンティティを取得できるのは便利ですが、メソッド名からなにがしたいのかよくわかりません。
文字列のクエリを危険であると考えた
要件によっては複雑なクエリを要求されるシーンが多々あり、union、join、exists を多用しないと実現できないユースケースが存在していました。Native Query や JPQL を使えばなんとかなりますが、文字列で SQL/JPQL を書かないといけないことに抵抗がありました。文字列でクエリを書いていくと、カラムドロップが発生する時、既存のソースコードを Grep して、影響箇所を徹底的に探さなければなりません。JOOQ であれば メタモデルに相当するオブジェクトを自動生成する仕組み がライブラリにあるので、変更のたびに maven で自動生成を実行すれば、カラムドロップの影響箇所をもれなく拾うことができます。JOOQ のオブジェクトの自動生成の手順は入るものの文字列でクエリを書くよりも変更に強いと感じています。
メタモデルの仕様
Specification、メタモデル を使えば型安全にフィルタリング機能を実現できます。しかし、Specification のフィルタリングは SQL と結構異なり、JPA に馴染みがなく、SQL に馴染みのある人間がこの実装をみると、直感的になにをやっているのかがわからないように思われました。それであれば、SQL で書いてしまった方がもはや良いのではと感じました。
メタモデルは、自動生成の仕組みを Spring Data JPA が持っていないのが微妙であると感じましたし、JPA のエンティティと同じ場所に配置しないといけない制約があることが微妙であると考えました。仮に JPA のエンティティをドメインオブジェクトとして扱うと、ドメインレイヤに Hibernate というインフラストラクチャレイヤのメタモデルを配置しないといけないので、これがヘキサゴナルアーキテクチャの原則を破ってしまっているように感じました。
ドメインモデルとデータベースに境界を引く
我々は、Java で利用されている Hibernate のエンティティをデータベースのテーブルにアクセスするための単なる「データ置き場」(p.163) で、ドメインオブジェクトとして見なすことはできないものであると考えました。というのも、我々は、新規ドメインモデルを設計する一方で、すでに存在しないエンジニアにより設計されたデータベースのテーブルスキーマを流用することにしたためです。
何かのモデルがきっかけとなって永続化用のデータベーススキーマを作ることになったとしたら、そのデータベーススキーマも、同じ境界の内部に属することになる。というのも、そのスキーマは、モデリングチームが設計・開発・保守を受け持つことになるからだ。つまり、データベースのテーブル名やカラム名などは、モデルで使っている名前を反映させたものになるはずで、別の形式に変換したりすることはない。[...]
一方、データベーススキーマが事前にできあがっている場合もあるし、モデリングチームとは別のデータモデラーが、モデルとは相反する設計のデータベーススキーマを作る場合もある。そんな場合、そのデータベーススキーマは、ドメインモデルが属する境界づけられたコンテキストには入らない。
p.62-63
我々が関わっていたアプリケーションのデータベースのテーブル名、カラム名は英語で命名されていましたが、英語であることを差し引いてもドメインエキスパートに伝わらないものでした。おそらく、当時いたエンジニアの判断で良しとした名前になっており、業務のデータモデルを適切に表現できているとは言えない状態でした。我々はデータベースのテーブルスキーマはビジネス上の関心ごとを表すには不完全で、その欠陥を補うためにビジネスの関心ごとを性格に反映したドメインモデルを設計する必要があると考え、ドメインモデルとデータベースに境界を引くことにしました。
この考え方をベースにアプリケーションを設計していくと、Spring Data JPA のリソースすべてはインフラストラクチャレイヤに配置されると考えました。
JPA のエンティティをドメインモデルとして扱うと、上述のデータベースと実態のビジネス上の区別をつける必要が生じて、データベースの事情をドメインモデルに伝えるため @Table
@Id
@Column
@GeneratedValue
といった アノテーションを記述する必要があります。ドメインレイヤにデータベースの事情が入り込むのは依存関係逆転の原則を実現できていないはずで、問題があると考えました。
JPA のエンティティがドメインモデルになることができないので、Spring Data JPA のリポジトリもドメインモデルにならないと考えました。Spring Data JPA を利用すると、JpaRepository インターフェースを実装するインターフェースを作成します。このインターフェースは、Spring が データベースアクセスに関する実装のプロキシを注入してくれるので実装そのものは隠蔽してくれるので、依存関係逆転の原則をフレームワークレベルで実現できているように思います。しかし、JpaRepository を実装しているインターフェースの型引数、メソッドの引数・戻り値には、JPA のエンティティを記述します。JPA のエンティティは、上述のように、データベースの事情が入り込んでいます。JpaRepository を実装したクラスは、インフラストラクチャレイヤのオブジェクトに依存してしまうので、依存関係逆転の原則からインフラストラクチャレイヤに配置するべきであると考えました。
Spring Data JPA を使ったリポジトリパターン考察
Spring Data JPA をインフラストラクチャレイヤとして見なすと、インフラストラクチャレイヤとは別にドメインオブジェクトのリポジトリを定義して、それを Spring Data JPA のリポジトリが継承する必要があると思います。
// 抽象
interface EntityRepository {
fun entityBy(id: EntityId): Entity?
}
// インフラストラクチャレイヤ。EntityRepository というドメインオブジェクトを実装しており、Spring がプロキシを注入する、という実装を隠蔽している。
internal interface JpaEntityRepository: EntityRepository, JpaRepository<JpaEntity, String> {
fun findByPrimaryKey(id: String): JpaEntity?
override fun entityBy(id: EntityId): Entity? {
return this.findByPrimaryKey(id)?.let {
Entity(id = EntityId(it.id), )
}
}
}
しかし、この設計は、リポジトリが 3 つでてきて、さらにドメインモデルのようなエンティティとドメインモデルがでてくるので、無駄な複雑さが出ていますし、なによりまったく自然な実装に見えません。
Spring Data JPA を使うと、上述の Specification もそうですが、Spring Data JPA を使うときに意識する、JpaRepository, @Repository
, @Entity
といったものが、DDD の用語から拝借されている (と思われる) ので、Jpa のエンティティもリポジトリもドメインモデルとして扱われるものとして考えたくなります。
実際、Spring Data JPA のチュートリアル や、私が最初に読んだ Spring Boot の入門書 をそのままなぞると上記の設計にはなりにくいと思います。(Spring Boot の入門書は、domain パッケージと repository パッケージを切っているので、リポジトリがデータベースのアクセスレイヤのような役割に見えますが)
Spting Data JPA の考察
当時の自分の意識には、Spring Data JPA というインフラストラクチャに、DDD で使われる言葉が使われているので、取り扱いを間違ってインフラストラクチャレイヤがドメインレイヤに入り込む可能性が高そうに見え、Spring Data JPA は、DDD のリポジトリパターンに向いていないと考えました。一方で、JOOQ のクラスは、DSLContext, Record, Pojo, Dao といったものであり、DDD のモデルに使われておらず、取り違えが起きないだろうと考え、リポジトリパターンに最適であると考えました。
しかし、冷静に振り返るとこの考え方は、かなりバイアスがかかっているように思います。視野が狭くなるのも問題なので、備忘録的に Spring Data JPA について考察します。
『実践ドメイン駆動設計』での Hibernate
『実践ドメイン駆動設計』では、データベースアクセスレイヤは、Spring Data JPA ではなく、Hibernate です。Hibernate のエンティティがドメインモデルになっていますが、JPA のアノテーションは利用せず、hibernate.cfg.xml を利用しています。XML ファイル経由でデータベースとドメインオブジェクトの O/R マッピングを実現しているので、Hibernate の事情を直接ドメインオブジェクトに記載せずに設計できています。また、データベースのスキーマも新規に設計されて、同一チームにてメンテナンスされているように思います。データベースとドメインモデルに乖離がある場合でも、『実践ドメイン駆動設計』と同様に xml ファイル でドメインオブジェクトとデータベースのマッピングを定義しつつ、これを Spring Data JPA に統合することができればインフラストラクチャの事情がドメインオブジェクトに入り込むことなく JPA の機能を利用してドメインモデルを設計できます。ただ、xml ファイルのメンテがつらそうなこと、xml ファイルを使ったマッピングは Spring Data JPA の標準的な使い方とは思えないのでこの方法は選択肢にはいらないと思います。
ドメインモデルとデータベースが同一コンテキストであれば問題ない
上記引用のように、あるモデルからデータベースの設計が反映されるのであれば、データベースのスキーマとドメインモデルが一致しそうです。テーブル名、カラム名の違いを吸収するために指定していた、@Table
@Column
といったアノテーションは、ドメインモデルと一致するようであれば、Spring Data JPA の命名規則の機能によって記述不要になります。エンティティに入り込む Spring Data JPA の事情は、 @Entity
と @Id
、もしかしたら @Embedded
/@Embededdable
だけになりそうです。また、「レイヤスーパータイプ」を使えば、@Entity
@Id
のアノテーションをドメインモデルから可能な限り隠蔽できそうです。
public abstract class IndentifiedDomainObject {
private long id = -1;
public IdentifiedDomainObject() {
super();
}
protected void setId(long anId) {
this.id = anId;
}
}
このIdentifiedDomainObject がレイヤスーパータイプだ。この抽象クラスのprotected なアクセサメソッドを使って、代理主キーをクライアントから隠蔽する。クライアントからこれらのメソッドが使われることを気にする必要はない。この基底クラスを継承したエンティティのモジュール(9) 以外からは、これらのメソッドが見えないからだ。
p.178
レイヤスーパータイプに @Entity
@Id
を記述することはできるはずなので、Spring Data JPA の都合をこのクラスにおしつけてしまいます。@Id
については、主キーはすべて id という名前の代理識別子になるようテーブル設計する必要がありますが、新規設計する上では問題ないと思います。
JPA のエンティティをドメインモデルとして見なすことができれば、JpaRepository を実装したインターフェースでの依存先がドメインモデルになり、ドメインモデルの依存先として問題ありません。相変わらず、Spring Data JPA の持ち物である JpaRepository の実装と @Repository
のアノテーションの記述が必要ですが、別途リポジトリを作ると、無駄な定義が一つ増えてしまい混乱を生むので、これらは割り切ってドメインオブジェクトに依存させてしまったほうがよいと思います。
JPA の機能をミニマムに利用するドメインモデルを設計できれば問題ない可能性が高い
私が Spring Data JPA を全面的に採用してアプリケーションを設計した際は、@Entity
のアノテーションが付与されたクラスを DDD のエンティティとして扱っていました。データベースのスキーマ設計も自身でしていたので、ドメインモデルと JPA のエンティティを同一に見なすことができそうな設計です。Spring Data JPA のマッピング機能は非常に強力なので、初めのほうは簡単にドメインモデルを生成できて生産性がとても高いと感じていました。しかし、徐々に複雑な仕様を実装し始めると、とにかくSpring Data JPA のトラブルシュートをしていた印象が強く、これによって Spring Data JPA に悪印象をもったように思います。JOOQ を採用したらシンプルなクエリでのアクセスだけになり、よくわからないトラブルシュートをする必要がなくなったので、余計に Spring Data JPA への悪印象が高くなりました。しかし、今振り返ると、私がしていた Spring Data JPA のトラブルシュートは、私が正しくドメインモデルを設計できていなかったために発生していた可能性が非常に高いです。
巨大な集約を設計
私が設計していたドメインモデルは複雑すぎる「巨大な集約」になってしまっていたと思います。私は、「利便性を考えてどんどん合成してしまい、大きすぎる集約を作ってしまうという罠にはまり」(p.333)、@OneToMany
の機能を多用していました。当時の私には集約の整合性の境界を設定しようという発想はなく、JPA の機能をフルに使いたいという考え方を優先していたと思います。その結果、 集約ルートが、ほぼすべてのエンティティに対して参照を持つような設計になっていたかもしれません。
そのため、「外部の集約への参照にはその一意な識別子だけを利用し、オブジェクトそのものへの参照 (ポインタ) は保持しないようにしよう。」(p.346) という発想はなく、とても大きな集約が 2 ~ 3 個あるような設計だったように思います。この結果、「複数のリポジトリ上で、ユースケースに最適化したクエリを使うファインダーメソッドを数多く作る必要が出てきたら、それはおそらく「コードの臭い」だ。」と言われている、「集約の設計ミスを覆い隠すリポジトリ」(p.416) が作成されてしまい、上述の長いファインダーメソッドが必要になったのだと思います。
後述のように、戦略的設計を導入すると、複雑なクエリを発行する必要がなくなる可能性が非常に高く、集約も非常に小さくなるはずで、これに一意の識別子で他の集約にアクセスする設計を適用すれば良い気がします。@OneToMany
のアノテーションを、DDD において利用する必然性はとても低いと思います。
双方向依存
JPA の機能をフルに活かすことを主眼としてしまったので、@ManyToOne
も当然のようにすべてのエンティティで設定していました。この機能をつかうと JPA の機能を使ってリレーションができ、トラブルシュートの可能性が増えますが、これはドメインモデルの設計としてもよくないものです。@OneToMany
と同じように、やはり、戦略的設計で双方向依存がなくなりそうですが、そもそも DDD において、@ManyToOne
を使うのは NG だと思います。
Hibernate がライフサイクルに関するイベントを送信したときに、子からルートエンティティにたどりつけるようになる。しかし、忘れてはいけないことがある。[Evans] は、よっぽどのことがない限りは双方向の関連を使わないよう勧めているということだ。
p.372
継承戦略を多用
JPA の機能に 継承戦略 がありますが、区分によって変わるドメインモデルの振る舞いを、自前でファクトリメソッドを実装せず実現できることを魅力的に思い、@Inheritance
を多用しました。一番シンプルで理解しやすいと考えて SINGLE TABLE戦略を採用しましたが、今思うと継承戦略をいれることで余計な JPA の複雑さが増えていましたし、そもそもクラスを派生する必要があったのか、という疑問もわきます。当時の設計を振り返ってみると、後述のコンテキスト間の統合に近しいものとして、ある集約の成果物として、異なる集約を作ることをしていました。集約が境界づけられたコンテキストのような設計になってしまっていたと思います。成果物の集約を作る際は、元となる集約にファクトリメソッドを持たせ、このメソッドが継承戦略を適用したエンティティの抽象基底クラスを元に、成果物の集約を生成していたと思います。
テーブルには関係ないものの @MappedSuperclass
を使っていたのもよくなかったと思います。多重継承が発生して、クラス間の関係が複雑になってしまいましたし、親クラスに派生クラスで共有したいプロパティを紛れ込ませてしまいました。たぶん、こういった継承戦略に関わる実装は、戦略的設計を導入すれば不要になるのではないかと思います。当時の私は、腐敗防止層という発想を持っておらず、腐敗防止層の代替として抽象基底クラスを設計していたのだと思います。
このあたりが Spring Data JPA を使っていた時の設計でした。どれも致命的ですが、戦略的設計を適用すればもっと柔軟な設計になっていたように思われます。Spring Data JPA が DDD に向いていない、ということはないように思われます。
プロジェクトの分割
それまで Web API は一つの大きなプロジェクトになっていましたが、リワークしたタイミングで Maven のマルチプロジェクトを導入しました。上述のように、部門ごとに Resource クラスが存在していたので、部門間の振る舞いがお互いに影響を受けないはずであると考え、部門を論理的な単位として捉えて、Web API のサブプロジェクトに切り出しました。
また、部門間が使うモデルは結局一つのビジネスをしているはずである、共通的な知識の配置先として捉え、ドメインモデルは Web API のプロジェクトが依存する Domain Project として切り出しました。
この際、JOOQ は依存関係逆転の原則から、Domain Project のインフラストラクチャのレイヤのみが依存して、その他のプロジェクトが依存しないものである、と考えて、Web API のプロジェクトから遠ざけたいという重いから、JOOQ 専用の別プロジェクトに切り出しました。最終的な形は下記です。
------------------
|Web API Project1| ---------------------------
------------------ | |
------------------ | Domain Project |
|Web API Project2| | | |
------------------ --→| |--- domain |
------------------ | | ↑ |
|Web API Project3| | |--- infrastructure |
------------------ | |
---------------------------
|
| --------------
|---→|JOOQ Project|
--------------
JOOQ Project が単独で切り出されて、JOOQ Project からの Domain Project の依存がないので、このプロジェクト構成では、ドメインレイヤのオブジェクトを直接 JOOQ の Record にマッピングすることを可能とする forced-types の恩恵は失われていますが、forced-type は非常に協力なマッピングをフレームワークで実現できるものの、暗黙性のある機能で、pom.xml に例えば削除してしまったクラスなどを書いてしまってもコンパイルエラーで問題を発見できず、実行時例外になってしまいます。この手のマッピングに関する実行時例外を絶対に発生させないようにするため、JOOQ 自体にはデータベースのアクセスだけに専念してもらってドメインに関する知識はあえてなにも入れないで、ドメインモデルとデータベースのモデルのアダプタは自前で実装するよう振り切りました。
Resource クラスの分割
巨大な Resource クラスをリソースを意識した小さい Controller クラスに分割したので、プルリクエストで競合が発生しなくなりました。
アプリケーションログを適切に出力する
Spring Boot 導入前は無作為にログが出力されており、リクエストのログには関連付けがありませんでした。そのため、調査しても原因を特定することがほぼできない状態でした。この問題をクリアするため、リクエストごとに ID を割り当てるフィルタを作成して リクエスト内のログを一覧化できるようにしました。
リクエストログの関連付けができたあと、たまたま私がログを見ていたところ 1 リクエスト 45 秒かかっているログがありました。実装を見ると Hibernate の lazy フェッチのゲッターをループで何度もコールして N + 1 問題が発生していました。ユーザー報告は上がっていなかったものの、深刻な問題になる可能性を持っていたので、JOOQ で実装し直しました。結果、ミリ秒でリクエストが終了するようになりました。
Kotlin の採用
Spring Boot 移行のタイミングで Kotlin でアプリケーションを実装できるようにして、新規開発、リファクタは Kotlin で実装するよう方針づけしました。
Kotlin は Java と共存可能かつ、Java のリソースからの双方向利用が可能なので、既存の機能追加・変更とリファクタ作業を同時並行的に行える大変良い選択肢であったと思います。
Kotlin での実装では、(Java でも final を書けば実現できますが、そうなっていなかったので) val を使ったり、forEach ではなく、map を使ってイミュータブルに実装しました。一度セットした値が違う値に書き変わる可能性を減らせたので、安心してプログラミングできるようになったと思います。
フレームワークの切り替えの恩恵
Spring Boot を導入することで多くの恩恵を得ることができました。一番大きい要素は情報の取得の容易さです。
Web レイヤの RESTEasy は基本的に 公式のドキュメント を見ていましたが、RESTEasy が JAX-RS の仕様を拡張しているので、機能が豊富すぎて目的の情報にたどりにつくのに一苦労することに困っていました。Spring Boot での JAX-RS の実装は、Jersey になり、RESTEasy に比べtて機能が少ないもののシンプルであるし、Spring Boot にシームレスに統合されているので、RESTEasy に比べて調査時間が減った感覚があります。(とはいえ、Spring MVC に比べると事例が少ないので調査にそれなりに時間がかかりましたが)。
JOOQ は今回のプロジェクトで初めて使いましたが、まったく問題がありませんでした。うまく設計すれば再利用性の高いコンポーネントも設計可能で、とてもよいライブラリだと思いました。(ただし、再利用性が必要になる場合は、再利用しないといけない設計がデータベーススキーマに入り込んでいて、それが問題である場合もあると思います。)
ドメインモデルのモデリング
フレームワークを変更してヘキサゴナルアーキテクチャを適用したので、今度はドメインモデルとは別の境界である Hibernate のエンティティを捨てて、ドメインモデルを Kotlin で設計しました。
具体的には上述の Service クラスに配置されていたビジネスロジックを各ドメインモデルに置こうとしました。Service クラスは、ビジネス上の目的を達成できているものの、実装者以外にビジネス上のなにを実現しているかがわからない「いんちきアプリケーションサービス」になっていて、可読性を著しく下げていました。
ドメインモデル貧血症なクライアント(いんちきアプリケーションサービス(4,14)、またの名をトランザクションスクリプト)のコードを読んでいるときによく見かける光景を考えてみよう。基本的な例を示す。
@Transactional
public void saveCustomer(
String customerId,
String customerFirstName, String customerLastName,
String streetAddress1, String streetAddress2,
String city, String stateOrProvince,
String postalCode, String country,
String homePhone, String mobilePhone,
String primaryEmailAddress, String secodaryEmailAddress
) {
Customer customer = customerDao.readCustomer(customerId);
if (customer == null) {
customer = new Customer();
customer.setCustomerId(customerId);
}
customer.setCustomerId(customerId);
customer.setcustomerFirstName(customerFirstName);
customer.setcustomerLastName(customerLastName);
customer.setstreetAddress1(streetAddress1);
customer.setstreetAddress2(streetAddress2);
customer.setCity(city);
customer.setstateOrProvince(stateOrProvince);
customer.setpostalCode(postalCode);
customer.setCountry(country);
customer.setHomePhone(homePhone);
customer.setMobilePhone(mobilePhone);
customer.setprimaryEmailAddress(primaryEmailAddress);
customer.setsecodaryEmailAddress(secodaryEmailAddress);
}
このコードは、いったい何をしているのだろう。実際のところ、万能といってもかまわないくらいのコードだ。新しいオブジェクトだろうが既存のオブジェクトだろうが一切気にせず、Customerを保存する。姓が変わった場合だろうが引っ越した場合だろうが一切気にせず、Customerを保存する。固定電話回線を新しく契約した場合だろうが契約を解除したときだろうが、はたまた携帯電話回線を追加したときだろうが一切気にせず、Customerを保存する。メールアカウントをJunoからGmaillに変えたときにも、転職して職場のメールアドレスが変わったときにもこのメソッドはCustomerを保存する。圧倒的じゃないか!本当に?実際のところ、このsaveCustomer()メソッドを使う場面が思い浮かばない。そもそも、何の目的でこのメソッドを作ったのだろう?当初の意図を誰かが覚えていて、その後の改修の際にも業務的な目的に合わせて変更しているのだろうか?修正を繰り返しているうちに、数週間もすれば、当初の意図などあっという間に消え去ってしまう。そして事態はさらに悪化する。[...]
少なくとも、以下の大きな問題がある。
①saveCustomer()のインターフェイスに、その意図がほとんど反映されていない。
②saveCustomer()の実装自体が、さらに複雑性を増してしまっている。
③「ドメインオブジェクト」であるはずのCustomerは実のところオブジェクトですらなく、単なるデータ置き場に過ぎない。
こういう気の進まない状況を、貧血誘発性記憶喪失と呼ぶことにする。
p.16-18
Hibernate のエンティティはすべて「単なるデータ置き場」として扱われていたので、「「ドメインモデル」のほぼすべての概念が、ひとつのエンティティと大量のゲッター/セッター群になってしま」(p.163) っており、Service クラスが Hibernate のエンティティのゲッター/セッターの操作をしていました。また、Service クラスのメソッドはユースケースが少しでも異なればコピペをして少し変えだけのメソッドが実装されていたので、凝集度が低い、類似した実装が散らばる設計になっていました。Service クラスのメソッドはゲッター/セッターの単なる操作であるために「なにを」とか「なぜ」とか「いつ」といった背景がコードに入り込まず、それがプログラムのコードの可読性を下げていました。
この欠陥を改善するため、これまで Service クラスに散らばっていたビジネスルールをドメインオブジェジェクトに移行しようとしました。
ここまでの話を踏まえて、Customerの設計を見なおしてみる。Customerに、サポートすべき業務的なゴールをすべて反映させてみよう。
public interface Customer {
public void changePersonalName(String firstName, String lastName);
public void postalAddress(PostalAddress postalAddress);
public void relocateTo(PostalAddress changedPostalAddress);
public void changeHomeTelephone(Telephone telephone);
public void disconnectHomeTelephone();
public void changeMobileTelephone(Telephone telephone);
public void disconnectMobileTelePhone();
public void primaryEmailAddress(EmailAddress emailAddress);
public void secodaryEmailAddress(EmailAddress emailAddress);
}
@Transactinal
public void changeCustomerPersonalName(
String customerId,
String customerFirstName,
String customerLastName
) {
Customer customer = customerRepository.customerOfId(customerId);
if (customer == null) {
throw IllegalStateException("Customer does not exist.")
}
customer.changePersonalName(customerFirstName, customerLastName);
}
p.21-22
ゲッター/セッターを、ビジネス上の関心ごとを表現しているエンティティのメソッドに置き換えて、ユースケースに対して最適化されたアプリケーションサービスのメソッドを作成します。上記の例だと、ビジネス上、カスタマーの名前を変える場合があることがユースケースとして明確になります。
このモデリングの仕方を段階的に取り入れていき、重複したコードは疎結合な関数としてドメインモデルに収斂させて再利用性を高め、最終的には英語の文法構造と同じように、インスタンスを主語、メソッドを動詞、引数を目的語として customer.changePersonalName(customerFirstName, customerLastName) のようなヒューマンリーダブルなプログラムを目指しました。
ドメインモデルのモデリング例
我々がリファクタを開始した時のアプリケーション設計の基本線は Kotlin で DDD のツールを使い、トランザクションスクリプトの Java のコード並びに、既存のテーブル設計に依存した Hibernate のエンティティを減らすことを目的としました。この方法を継続していくにつれ、Kotlin という書きやすい言語で書かれたドメインモデルが増えていき、アプリケーションに統一感が生まれたように感じていました。
具体的にどのようにモデリングしたかについて、1 例を紹介したいと思います。 以下は、ビジネスに関するコンフィデンシャルな内容が記事に含まれるといけないので、私がまったく知らないビジネスで、荒唐無稽な要件ですが、設計方針は大筋このような形で、実際の設計に関する問題点を内包しています。
今回例で使うシステムは、農作物管理システムです。ある企業が農作物を栽培・販売しており、農作物の状態を農作物管理システムを使って細かく管理しています。
農作物には状態 (ステータス) があります。農作物の状態は担当者によって細かく観察されて、ある状態になると担当者が状態の変更をクライアントアプリケーションで報告 (農作物のステータスを更新) します。
リファクタ前のモデリング
リファクタ前のアプリケーションにおいて、農作物はテーブルの構造が露出した Hibernate のエンティティでした。
Hibernate のエンティティのもとになっているデータベースのテーブルスキーマは下記です。
id | 管理番号 | 名前 | ステータス | 理由 | 登録日時 | 更新日時 |
---|---|---|---|---|---|---|
1 | 111 | にんじん | A | null | 2020-01-01 15:00 | 2020-01-02 16:00 |
2 | 112 | にんじん | A | null | 2020-01-01 16:00 | 2020-01-04 17:00 |
3 | 113 | キャベツ | B | null | 2020-01-01 16:01 | 2020-01-03 16:01 |
項目 | 説明 |
---|---|
id | アプリケーションで生成される主キーです。 |
管理番号 | 管理番号はビジネスルールに基づいてドメインエキスパートによって管理される番号で、ユニークです。実質、id と同じ意味になります。 |
名前 | 作物の名前です |
ステータス | 現在の農作物の状態がなにであるかを記号であらわしています。 |
理由 | 該当ステータスに変更された理由です。 |
登録日時 | 登録日時のタイムスタンプです。 |
更新日時 | 更新日時のタイムスタンプです。 |
Hibernate のエンティティは、ゲッター/セッターだけしか存在しないので、ステータス更新時には「ステータス」のセッターを使用して、永続化していました。
@Autowired
private EntityManager entityManager;
public saveAsStartSeeding(String id) {
HibernateCrop crop = entityManager.find(Crop::class, id)
crop.setStatus(CropStatus.START_SEEDING.value());
entityManager.persist(crop);
}
public interface Code {
String value();
// その他の抽象メソッドも実際は存在している
enum CropStatus implements Code {
仕掛かり前("A")
播種完了("B")
芽吹き("C")
開花("D")
収穫完了("E")
検品正常完了("F")
検品規格外("G")
出荷完了("H")
収穫失敗("I")
検品異常("J")
廃棄("K")
private String value;
... その他の抽象メソッド用プロパティ
@Override
public String value() {
return this.value;
}
... その他の抽象メソッドの実装
}
... MyCode1, MyCode2 には今回の話題には関係ありませんが、そういうこともあったな、という記録と再発防止の備忘に記述しています。
enum SomeCode1 implements Code {
...
}
enum SomeCode2 implements Code {
...
}
}
農作物のステータスは、CropStatus という enum で定義されています。このステータスは、例外があるものの上から下に向かって、農作物の進捗が進んでいきます。
名前 | 説明 | DBの値 |
---|---|---|
仕掛かり前 | 種をまく前の状態 | A |
播種完了 | 種まきが完了した状態 | B |
芽吹き | 農作物の芽が出た状態 | C |
開花 | 農作物の花が咲いた状態 | D |
収穫完了 | 農作物を収穫した状態 | E |
検品正常完了 | 農作物の検品が完了した状態 | F |
検品規格外 | 農作物を収穫できたが形がよくなかった状態(異常系) | G |
出荷完了 | 農作物を出荷した状態 | H |
収穫失敗 | 農作物が枯れたなどにより収穫できなかった状態(異常系) | I |
検品異常 | 農作物の検品をしたが虫食いなどで出荷できない状態(異常系) | J |
廃棄 | 異常があるなどして出荷前に廃棄された状態(異常系) | K |
対応するステータスごとに enum のメンバーが定義されており、これらのメンバーは、value() プロパティでデータベースのテーブルのカラムに入る「記号」が文字列として取得できる、データベースのカラムのアダプターのような役割を持っています。
上記の setStatus の実行例だと、A という値がテーブルのステータスカラムの値として永続化されます。
コード値がひとまとめ
このアプリケーションの enum はすべて、Code インターフェースを実装して、かつ、Code インターフェースの内部クラスになっている設計でした。Code として抽象化する必要がないのにインターフェースの実装を強要されるし、不要な場合でも抽象メソッドを実装しなければなりませんでした。enum の DB のアダプターになるメソッド名が value なりプロパティ名が揃うといえば揃いますが、それがどれほど嬉しいのか、というところです。「コード」という業務的になんの繋がりもない機械的な点のみに着目して抽象化・依存関係ができる方がよくないと思います。一番ひどいのが enum の実装場所です。Code の内部クラスに enum が実装されていくので、Code インターフェース自体が途轍もないほど肥大化しており困りました。
DDD のパターンを適用したモデリング
我々は DDD のパターンを用いて、Hibernate の農作物のエンティティをドメインオブジェクトとしての農作物エンティティとしてモデリングしようと考えました。
まず、値オブジェクト
を適用しました。
Hibernate のエンティティのプロパティの型は文字列や数値のみで定義されていました。
ビジネス上意味のある概念が、プリミティブな型に埋没すると他と区別がつかなくなり、コードの可読性が下がってしまうと考え、原則プリミティブな型を値オブジェクトに置き換えました。
値オブジェクトはやりすぎの部分もあったかもしれない
値オブジェクトについては機械的に行っていたかについては検証していませんでした。Java には 名前付き引数機能が存在していないため、引数の指定順序によっては全く異なる結果になってしまう可能性がありましたので、型で区別するメリットがここにもありましたが、Kotlin には名前付き引数の機能があります。
間違った実装をする可能性という観点で考えると、基本的に名前付き引数を使えば問題は解決するはずです。振り返って見ると、「基本データ型への執着」を回避することに執着しすぎていた感もあり、下記の観点が抜け落ちていたのかもしれない気もします。
ここにきて、あらゆるものが値オブジェクトに見えるようになってきたのかもしれない。[...] ただ、少々注意すべき場面もある。非常にシンプルな属性で、特別扱いが一切不要なものを扱う場合だ。おそらくそれは、Boolean の値であったり、自己完結した数値であったりするだろう。特別な機能は不要で、同じエンティティ内の他の属性とも一切関係はない。そのシンプルな属性が単体で、意味のある全体を成している。それなのに、その属性単体を値型でラップしてしまうという「間違い」を犯してしまいかねない。特別な機能を何も持たないそんな値型を作るなら、値を一切作らないほうがまだマシだ。少しやりすぎだと気づいたら、常にリファクタリングを検討しよう。
p.221
農作物エンティティの設計
この流れで農作物エンティティを設計しました。ステータスは値オブジェクトとしてモデリングして、変更する場合は、上記の Customer の例同様、なんでもできてしまう setStatus のようなメソッドではなくビジネス上の関心ごととして個別にメソッドを定義しました。
ビジネスルール上、例えば、「播種完了」のステータスに変更する時は、エンティティは「仕掛かり前」になっていないといけないなどのステータスの遷移に関する制約がありました。
setStatus のようなセッターでステータスを変更すると、その制約がまったく表現されておらず、業務について詳しくない人が制約を無視してどのステータスにも変えられてしまいます。
これを防ぐために実現した制約のイメージが下記です。
data class Status(
private val status: CropStatus,
private val reason: String?
) {
fun change(status: Status, reason: String? = null) = copy(status = status, reason = reason)
fun isSame(status: Status) = status == this.status
}
data class Crop(
val id: CropId,
val manageNo: ManageNo,
val status: Status,
val name: String,
) {
fun completeSeeding(): Crop = if (status.isSame(CropStatus.仕掛かり前)) status.change(CropStatus.播種完了) else this
fun sprout(): Crop = return if (status.isSame(CropStatus.播種完了)) status.change(CropStatus.芽吹き) else this
... 省略
fun inspectionNonStandard(): Crop = if (status.isSame(CropStatus.開花)) status .change(CropStatus.検品規格外) else throw IllegalStatusChangeException("検品規格外に変更できません。")
... 省略
fun discard(reason: String): Crop = if (status.isSame(CropStatus.検品規格外)) status.change(CropStatus.廃棄, reason) else throw IllegalStatusChangeException("廃棄ステータスに変更できません")
}
// enum は、Code インターフェースを実装せずに Kotlin で書き直し
enum class CropStatus(val value: String) {
仕掛かり前("A") // 種をまく前の状態
... その他の列挙
}
@Service
@Transactional
class CompleteSeedingUseCase(
private val cropRepository: CropRepository
) {
fun saveAsCompleteSeeding(id: CropId) {
cropRepository.cropOfId(id)?.let { crop: Crop ->
cropRepository.save(crop.completeSeeding())
}
}
}
実際にはもっと細かい要件がありましたが、単純に物事を伝えるため最小の設計にしています。
Hibernate のエンティティで恣意的にステータスを変更ができた setStatus は、農作物エンティティの利用者が変更したいステータスに変更するメソッドに分解されました。また、それらのメソッドで、現在の農作物エンティティのステータスがなにであるかを確認して、変更することができないステータスであると判断された場合、変更なしでオブジェクトをそのまま返すか、あるいは例外を投げます。
変更なしであっても例外のスローのどちらであったとしても不正なステータス遷移をブロックします。
エンティティを永続化する際のデータベースの永続化のアダプターは Hibernate から JOOQ に起き変わっていますが、CompleteSeedingUseCase というアプリケーションサービスからはドメインオブジェクトである CropRepository だけ見えており、その実装・および実装が依存している JOOQ は見えません。依存関係逆転の原則によりアプリケーションサービスがインフラストラクチャレイヤを意識しない設計になりました。
次第に大きくなった違和感
このようなドメインのモデリングは効果的で、リファクタが進むにつれアプリケーションとしての統一感や規則性を感じることができていました。なにか変更があったとしてもこれまでのようなセッターを使った副作用が前提の実装ではないので自信をもって修正ができるし、修正による影響が見えやすいと感じていました。新規機能作成時で正しく動作するリーダブルなコードであるかの観点で、チーム全員でレビューをしていたので、正しいことが常にできていると考えていました。
ただ、次第に機能が色々と追加され、さらに私自身にビジネスの知識が蓄積され、アプリケーション全体を俯瞰してみることができるようになればなるほど、違和感が大きくなっていきました。
機能がリリースされてしばらくたち、細かい仕様に関する記憶が薄くなった時に調査などでソースコードを眺めてみると、「本当にこれは読みやすいコードなのだろうか」「これは修正が容易いのだろうか」と、思う場面があることに気づきました。
上記のモデリングした農作物エンティティで例を説明したいと思います。
部門の違いを表現したファサードクラスが非常に煩雑
上述のように農作物エンティティのステータスは担当者によって変更されます。
この担当者は、「農作物管理部門」に所属しています。
ビジネス上、大体の場合において農作物のステータスは農作物管理部門によるステータスの変更で進捗し、それが通常のフローになりますが、それ以外のフローもありました。
農作物管理部門とは別に、包括的にビジネス全体を管理・サポートする部門 (以降、業務運営部門と呼びます) がありました。
- 農作物管理部門: 農作物の成長を観測し進捗を管理する部門
- 業務運営部門: 包括的にビジネス全体を管理・サポートする部門
- その他部門も複数あり、業務運営部門はその部門に対してもサポートを行う
業務運営部門はなんらかの想定外の状況が発生して現場担当者が単独で業務を遂行できなくなった際にフォローしたり指示を出したり、あるいはエンジニアへの調査依頼をしたりと、包括的にビジネスを管理していました。
業務運営部門の業務は現場担当者のサポートをするので、農作物管理部門者と一部業務が重複し、同様の機能が必要であり、この中に農作物の担当のステータス変更が含まれていました。
業務運営部門でのステータスの変更機能は、通常の業務フローで農作物管理部門の業務が滞る場合に利用されます。このため、農作物管理部門では、
仕掛かり前 -> 播種完了 -> 芽吹き -> 開花
と、ステータス遷移の順番を遵守することがビジネス上必須でしたが、業務運営部門は農作物管理部門のステータス遷移の順番を遵守する必要はありませんでした。
業務運営部門のステータス変更のビジネス要件を実現しようとすると、既存のビジネス上のステータスの遷移のルールの実装をどうにかしなければなりません。案は 3 つあったと思います。
業務運営部門と農作物管理部門のオペレーションを揃える
農作物管理部門は農作物の遷移を進めることもできれば、元に戻すことができました。業務運営部門のユーザーは、農作物管理部門の機能を利用して、農作物管理部門と同じ手順で手動でステータスを遷移させます。
システム側で業務運営部門の操作に農作物管理部門のルールを適用する
業務運営部門が農作物管理部門と同じ操作をすると、仕掛かり前から 開花に遷移させたい場合においても、「播種完了」「芽吹き」のステータスを経由する必要があります。この経由は、業務運営部門では本質的ではないので、ユーザーの余分な操作をなくすため、システム側で農作物管理部門のステータス変更遷移のルールを適用します。業務運営部門のユーザーが、仕掛かり前から、開花にステータスを変えた場合、システム側で、播種完了、芽吹きのステータス変更の処理を入れます。
業務運営部門にステータスの更新ルールを適用しない
業務運営部門と農作物管理部門のルールは関係がありません。関係のないルールを無理に適用する必要がないので、業務運営部門はどのステータスにも自由に遷移できます。
1つ目の「業務運営部門と農作物管理部門のオペレーションを揃える」はドメインエキスパートから煩雑な操作をする必要が生じるので、ドメインエキスパート側から不評でしたので不採用としました。2つ目の「システム側で業務運営部門の操作に農作物管理部門のルールを適用する」の設計である場合、ユーザー体験としては問題ないものの、システム設計の複雑さと追跡の正しさが問題になるとエンジニアで考えました。
このアプリケーションではエンティティの状態をジャーナルとして追跡していましたので、業務運営部門のユーザーが仕掛かり前 -> 開花へ遷移させる場合、農作物管理部門のステータス遷移ルールが適用されて、「播種完了」「芽吹き」のステータス変更のジャーナルがシステムで自動的に生成されます。ユーザーが操作していない状態が追跡され、業務運営部門のステータス遷移ルールの暗黙的な設計が複雑です。
Java での業務運営部門のステータス更新の設計が、3 つ目の「業務運営部門にステータスの更新ルールを適用しない」であり、かつ、最も単純な設計で問題が発生しにくいと思われたので、エンジニアで、Kotlin でのリファクタの際にも現行踏襲することにしました。
設計方法
部門ごとにステータス遷移に関するルールのありなしがあるため、ステータス遷移の変更可否の判断を農作物エンティティ上のステータス変更のメソッドから、別の場所に移動して、農作物管理部門にのみ判断を適用させる設計に変えました。ステータス変更の可否は、まず、アプリケーションサービス内に配置しようと考えましたが、農作物エンティティのメソッドにあった制約をアプリケーションサービスに移すと「いんちきアプリケーションサービス」になってしまうと考え、ステータスを変更するためのファサードとして、ドメインサービスのようなクラスを設計しました。
// 農作物管理部門のステータス変更ファサード
sealed interface CropStatusChangeForCropAdministrator {
fun change(): Crop
class CompleteSeeding(private val crop: Crop): CropStatusChangeForCropAdministrator {
override fun change(): Crop = if (cropStatus == CropStatus.仕掛かり前) crop.completeSeeding() else this
}
class Sprout(private val crop: Crop): CropStatusChangeForCropAdministrator {
override fun change(): Crop = if (cropStatus == CropStatus.播種完了) crop.sprout() else this
}
... その他のステータス変更派生ファサード
}
// 業務運営部門のステータス変更ファサード
sealed interface CropStatusChangeForBusinessAdministrator {
fun change(): Crop
class CompleteSeeding(private val crop: Crop): CropStatusChangeForBusinessAdministrator {
override fun change(): Crop = crop.completeSeeding()
}
class Sprout(private val crop: Crop): CropStatusChangeForBusinessAdministrator {
override fun change(): Crop = crop.sprout()
}
class Discard(private val crop: Crop, private val reason): CropStatusChangeForBusinessAdministrator {
override fun change(): Crop = crop.discard(reason)
}
... その他のステータス変更派生ファサード
}
data class Crop(
...
) {
fun completeSeeding() = copy(status = CropStatus.播種完了)
fun sprout() = copy(status = CropStatus.芽吹き)
fun discard(reason: String) = copy(status = CropStatus.廃棄, reason = reason)
...
}
この設計では部門ごとにステータス変更のファサードのインターフェースの実装クラスが作成されます。例えば、「播種完了」「芽吹き」「廃棄」にステータス変更できる部門であれば、これら 3 つの派生クラスが実装されます。我々は、この設計を、アプリケーションサービスにビジネス上の関心ごとを流出させず、部門ごとの特徴を切り出すことに成功しているものであると考えていました。今思うと、これらのクラスは、ロールオブジェクト のような発想で設計されており、手続き型の形になっていてドメイン貧血症を引き起こすものです。しかし、しばらく経ってみると、個人的にはドメイン貧血症の問題以上に、ある程度背景を知っている私ですら混乱してしまう要素がいくつもあるように思えてきました。
同じような名前・役割をしたクラスが存在している
上記設計の実装を久しぶりに見た際、農作物エンティティにステータス変更のメソッドがあるのに、それに対応する似た名前のついたステータス変更ファサードが列挙されていることに違和感が生じました。農作物エンティティのステータス変更ファサードクラスから農作物エンティティのクラスへの依存役割が必須でありつつ、似たようなことをしているので、なにをしているのか、なぜファサードクラスがあるのかを思い出すのに時間を要しました。
機能が重複したステータス変更のファサードクラスが存在する
上記の例だと、ステータス変更のファサードの数が少ないのですが、実際の農作物管理部門は例以上の数のステータスに変更できます。変更可能な数だけファサードを定義する必要があります。ステータス変更のファサードクラスが多くなればなるほど混乱しやすくなりましたが、それはビジネス要件上必須であるのでやむを得ないと考えていましたが、この膨れ上がったステータス変更のファサードクラスの中で、継承先のインターフェースだけ違う重複した機能のクラスが含まれていることに違和感がありました。上述の例ではステータスを変更できる部門を 2 つのみあげていますが、実際には、5 つ以上の部門がステータスを変更できました。ステータス変更のファサードは、部門ごとに定義する設計にしたので、部門ごとにインターフェースが定義されます。類似したステータス変更のインターフェースおよび実装クラスが増えるのも混乱しますが、これらの中には、まったく同じ実装が含まれていました。というのも、制約なしでステータス変更できる部門がその他にもあったためです。
// 業務運営部門のステータス変更ファサード
sealed interface CropStatusChangeForBusinessAdministrator {
fun change(): Crop
class CompleteSeeding(private val crop: Crop): CropStatusChangeForBusinessAdministrator {
override fun change(): Crop = crop.completeSeeding()
}
// その他の管理部門のステータス変更ファサード
sealed interface CropStatusChangeForDepartmentA {
fun change(): Crop
// 業務運営部門と同じ内容だが、部門ごとにファサードを定義しているので重複した実装が必要になる
class CompleteSeeding(private val crop: Crop): CropStatusChangeForDepartmentA {
override fun change(): Crop = crop.completeSeeding()
}
ステータス変更ファサードの設計は、部門ごとに行っていたので、部門がどのステータスに変更できるかを表現できるクラスでわかりやすいと思っていましたが、この設計を適用した部門が増えてくると、同一のもの・違うものが混在した統一感があるようでない理解するのが難しいものになっていたと思います。
部門によっては利用しないエンティティのステータス更新メソッドが public で定義されている
特殊なステータス、例えば「廃棄」は、業務運営部門でのみ変更されるステータスであるので、業務運営部門のステータス変更ファサードクラスにのみ存在します。
sealed interface CropStatusChangeForBusinessAdministrator {
// 廃棄をするユースケースが唯一存在している
class Discard(private val crop: Crop, private val reason): CropStatusChangeForBusinessAdministrator {
override fun change(): Crop = crop.discard(reason)
}
... 同じような内容
}
sealed interface CropStatusChangeForDepartmentA {
// この部門にはDiscard はない
... 同じような内容
}
sealed interface CropStatusChangeForDepartmentB {
// この部門にはDiscard はない
... 同じような内容
}
この設計だとステータス変更ファサードが必要最低限実装されているので、例えば、農作物管理部門のユースケースが、「廃棄」のステータスを意識するようなことがないので適切にビジネス上の関心ごとを設計できていると思っていましたが、久しぶりにみると、農作物エンティティのステータス変更のメソッドのアクセス修飾子が public であることが気になりました。public に設定されたメソッドであれば、どのスコープからもアクセスできるので、ステータス変更ファサードがあったとしてもアプリケーションサービスからはアクセすることができます。この場合、該当ステータスに変更することがない部門のユースケースからは誤って利用されてしまわれないよう、むしろ隠蔽しておきたいメソッドではないかと感じていました。Kotlin には指定したクラスからのみアクセスできるようにする機能が存在していないので、言語の問題かとも思ったのですが、そういう問題でもないという違和感がありました。「廃棄」は、業務運営部門のみが関心があるステータスなので、アクセス修飾子でビジビリティをなんとかするのは小手先で、農作物管理部門のユーザーが扱うときは、農作物エンティティに「廃棄」のステータスのメソッドがそもそも不要であるように感じられました。
部門を意識した農作物エンティティのメソッドが必要になった
今回の例では現れてきていませんが、部門ごとに異なる振る舞いが必要となったのはステータス変更だけではありませんでした。その他のプロパティでも、変更時に部門ごとに特別な振る舞いが必要なものがあり、それらについては、ステータスの変更の時とは異なり、ファサードクラスを用意せず、エンティティに直接メソッドを実装しました。
data class Crop(
...
) {
fun changeSomethingForDepartmentA(arg...) = changeSomething(something = ...)
fun changeSomething() = changeSomethingForDepartmentA(arg...)
}
部門ごとの変更ファサードを作らなかった理由は、ステータスの変更よりも単純で、ステータスのようなバリエーションもなかったため、クラスではなくメソッド名で解決しようと考えたためです。相変わらず、public のアクセス修飾子なのでステータスの時と同様、どの部門のユースケースからでもアクセスできるので、うっかり使うメソッドを間違えてしまう可能性があります。
完結した値を実現できない
『実践ドメイン駆動設計』の値オブジェクトの説明で、「完結した値」という概念が紹介されています。
50,000,000 ドルという値には二つの属性がある。50,000,000 とドルだ。これらは、それぞれ、単独で見れば別の意味になる (あるいは特別な意味を持たない)。「50,000,000」はもちろん、「ドル」だってそうだ。これらを取りまとめた概念的な統一体が、金銭的な計測値を表す。50,000,000 ドルという値が2 つの属性を持っていて、amount は50,000,000 で currency はドルだ、などとは考えないだろう。数値の 50,000,000 だけでは価値がないし、通常単位のドルだけでも価値がない。[...] そのモノの価値を適切に説明するには、これら二つを個別の属性として扱うのではなく、全体として完結した値 (50,000,000 ドル) として扱う。
p.213-214
値オブジェクトを設計する時に完結した値を考慮すると物事の関連性を表現できるので、アプリケーションにも取り入れました。農作物エンティティにも、上記の説明に近い属性があるので、完結した値の値オブジェクトをモデリングしようとしました。
農作物エンティティは、現在のエンティティがどういう状態であるかを表すためのステータスの他、そのステータスに変わった理由を表す変更理由(reason) プロパティがあります。変更理由一つだけだと意味が成り立ちませんので、CropStatus をモデリングしました。
data class Status(
private val status: CropStatus,
private val reason: String?
) {
fun change(status: Status, reason: String? = null) = copy(status = status, reason = reason)
fun isSame(status: Status) = status == this.status
}
ステータスの変更理由 (reason) が null 許可になっているのは、ある特定のステータスに変更する時には必ずセットしなければならないものの、特定のステータス以外のステータスの時には変更理由をセットする必要がないためでした。「変更理由をセットする必要がないもの」は、変更理由をセットしてもよいというオプショナルな意味ではなく、そもそも該当ステータスへの変更時に変更理由に関心がない、不要な属性という意味ですので、変更理由が必要なステータスの場合は、変更理由はステータスとワンセットの完結した値になります。CropStatus 値オブジェクトは、この状況を表しているものの、曖昧さが含まれています。一般的に、null 許可は、任意項目であることを表現する場合が多いように思われますので、私のように長く参画しているものはともかく、新規参画者は、変更理由が null 許可である理由はステータスによっては必須であるためではなく、任意項目であるためであると解釈する可能性が高いように思われました。
エンティティのステータスが「廃棄」の場合においてはステータス・変更理由の関係性は一緒に配置するべきである一方、それ以外のステータスの場合は、変更理由が関係のなく、ステータスとの組み合わせにおいてなんら意味がありません。変更理由の null 許可は、この矛盾の妥協の産物ですが、任意・必須の解釈が発生してしまい、また、どのステータスにおいては変更理由が必須であるかを表現できていないので、良い妥協とは思えなくなりました。
CropStatus 値オブジェクトのバリデーション
CropStatus の値オブジェクト初期化時に、ある特定のステータスの場合は、変更理由は必須でなければならない、というバリデーションを実装すればどのステータスであれば変更理由が必須であるかがわかります。
data class CropStatus .. {
init {
require(CropStatus.廃棄 == status && reason != null) { "廃棄時にはステータスが必須です。" }
}
}
しかし、そもそも変更理由が必要ないステータスの場合であれば、reason などというステータスは不要ですし、それ一つで完結する CropStatus をエンティティに直接持たせるべきではないか?という疑問が生じます。意味はわかりますが、バリデーションの実装が完全にこの問題を解決するわけでもなく、値オブジェクト生成時に例外をなげてしまう、副作用を伴うものになっていて良い設計とは思えません。
プログラミングが硬くなった
上記とは異なるステータスに関する設計方法の改善もありました。これも当初うまく行っていたと考えていたのですが、そのうち大きな問題になっていきました。
ビジネス上複数のステータスをひとまとめに考えて、それらの条件に合致する場合特定の処理をする機能が存在していました。例えば、リポジトリから取り出した農作物エンティティが「播種完了」「芽吹き」の状態である場合、特殊な処理を実施する、といった要件です。我々は Kotlin でリファクタする前の Java でのこういう要件の実装を「もろい」と感じていました。私がジョインしてからビジネスが複雑化していき、その中でステータスのバリエーションが増えていったためです。Java での実装では素朴にひとまとめにするステータスの種類を「または」で判断していました。
Crop crop = entityManager.find(CropTable::class, cropId);
if (crop.getStatus() == CropStatus.仕掛かり前 || crop.getStatus() == CropStatus.播種完了) {
// ステータスが該当の状態である場合のビジネスロジック
}
この実装は、ステータスの種類が増えたり、条件の再利用がないのであれば問題ありません。しかし、あるステータスが後に追加されて上記の分岐条件に追加しなければならない場合、漏れがないように分岐に該当ステータスを記述しないといけません。小規模なアプリケーションであれば特に問題ないのですが、その段階でソースコードは結構な量になっており、影響箇所を全部特定するのが非常に困難になっていました。ステータスが追加されることは非常に稀でしたが、ステータス追加時は修正漏れによるバグが確実に発生しました。また、まったく同じビジネス的な理由でステータスの判断条件を複数箇所で再利用したい場合においても、モジュール化されていないので、コピペして判断条件を追加しないといけませんでした。コピペされた箇所を含めてすべての影響箇所を特定する必要があるので修正漏れが発生する可能性が高かったです。
この問題を解決するため、Kotlin の when の機能を利用したコンポーネントを設計しました。Kotlin のコンパイラは、enum に対して when を利用すると、when ですべての分岐の可能性を網羅していると判断できる場合コンパイルエラーが発生しませんが、すべての可能性を網羅していない場合は else を使わない限りコンパイルエラーが発生します。それを利用して、else を利用していない、
object SpecificStatusSpec {
fun doesMatch(status: CropStatus) {
when (status) {
仕掛かり前 -> true
播種完了 -> true
芽吹き -> false
開花 -> false
収穫完了 -> false
検品正常完了 -> false
検品規格外 -> false
出荷完了 -> false
収穫失敗 -> false
検品異常 -> false
廃棄 -> false
// else を使うとステータスが増えた時にコンパイルエラーがでないので else は書かない
}
}
}
というステータスに関する仕様のクラスを設計しました。なんらかのステータスを新規に加えた場合に、このクラスでコンパイルエラーが発生するのですべての影響箇所をコンパイラに洗い出してもらうことができます。ステータスのかたまりをクラスに切り出したことで、利用元で、doesMatch メソッドが true を返すか、という使い方で再利用可能です。
この設計で「もろい」問題は解決したと思ったのですが、今度は違う問題が発生しました。この仕様のクラスはことあるごとに作られていき、その数をどんどんと増やしていくことになりました。複雑なビジネスなのでこういった仕様クラスが増えることはある程度しかたのないことかと考えていたのですが、次第に仕方がないと考えることができなくなるほどその数が多くなっていきました。ステータスが一つ追加されるとコンパイルエラーが相当数発生して、すべてのコンパイルエラーで新規ステータスが対象かどうかの判断をしてコンパイルエラーを解消する作業は相当コストが高くなりました。もろさを無くしたトレードオフでアプリケーションが硬くなってしまったのだと思います。
「「硬さ」とはソフトウェアのちょっとした変更でさえとても難しいことだ。たった 1 つの変更で、それと依存関係のあるモジュールを芋づる式に変更しなければならないような場合、設計が「硬い」という。[...] 一見簡単そうな変更を頼まれ、変更箇所をざっと調べてから仕事量の順当な見積もりを出す。しかし実際に作業を進めるに従い、予測していなかった事態に直面することになる。
「もろさ」とは、たった 1 つの変更によって他の多くの部分が壊れてしまうような傾向があるということだ。変更した部分とは概念的に関連のない箇所まで壊れてしまうことがままある。[...] モジュールのもろさが増すと、たった1つの変更でも必ず予期せぬ事態を引き起こすようになる。
『アジャイルソフトウェア開発の奥義』p.109
上記のステータスの仕様の設計だと、ステータスが減る場合も増える場合も、すべての仕様のクラスに影響がおよびます。ステータスが減る場合はともかく、増える場合は、ほとんどのところで単に false とつければよい修正が必要ですが、すべてを機械的に修正すると本来修正しないといけない部分から漏れてしまうのでそれなりに変更が大変な状態になっていました。ステータスの増減がコストになることは我々のチーム以外にも暗黙的な了解になっていたため、とあるステータスを追加するかどうかの議論が我々以外のチームで発生した際、「どうもあのチームの作業がとても大変になるようなので既存のステータスを活かして設計しよう」という結果になりました。ステータスが増えなかったことの設計の良し悪しはともかく、作業が大変になるのでステータスのバリエーションを増やさない選択肢が発生するのは問題を発生させる設計ではないかと感じられました。
原因
上記の不都合が発生した原因はなにかと考えながら『実践ドメイン駆動設計』を再度読んだところ、いろいろな課題がありつつも、根本的には戦略的設計が欠如していたことが原因だと確信しました。我々「が実践していたのは結局のところ軽量DDD であり、ただ技術的な見返りだけを求めて戦術的パターンを使っていた」(p.70) のでした。
DDD は、まず技術的なツールセットとして取り入れられることが多い。この手のアプローチのことを軽量DDD と呼ぶ人もいる。主にエンティティやサービスを用いて、集約の設計にも手を出してみて、永続化の管理にはリポジトリを検討する。これらのパターンは今までのなじみの技術に似ているので、取り入れやすい。これらに加えて、値オブジェクトの使い道を見出す人がいるかもしれない。これらはいずれも、戦術的設計に属するもので、どちらかと言えば、テクニカルなパターンだ。
『実践ドメイン駆動設計』地形のマッピング、そしてフライトチャート作り
何度か『実践ドメイン駆動設計』を読んでいたので「軽量 DDD」という言葉は知っていたのですが、お恥ずかしい話、その本質を理解できていなかったため、DDD は技術的なツールセットの一種であると思い込んでいたようです。この思い込みにより、すべての事業の関心ごとは、データセントリックなプロジェクトの巨大なデータベースが、ER 図を使って関連性を表現してビジネスを表現しようとするように、各ドメインモデルたちはお互いなんらかの直接的な関連性があり、一枚岩で繋がっているのだと考えていました。我々は「エンタープライズモデル」という DDD が目指すのとは全く異なるモデリングをしていたのでした。
ドメインモデルという用語には「ドメイン」という単語が含まれているので、その事業のドメイン全体をカバーする全部入りのモデルをひとつだけ作るのかと思う人もいるかもしれない。そう、いわゆる「エンタープライズモデル」ってやつだ。しかし、DDD の最終目標はそこではない。DDD が目指すのは、むしろその正反対の方向だ。
p.42
我々は、ビジネス全体を 1 つのドメインとしてエンティティなどのモデリングをしていて、「複雑に絡みあったモデルを外部から解剖し、実際の機能にもとづいたサブドメインに切り分ける」(p.44) ことをせず、「ユビキタス言語を見つけ出して育てていこうなどという気は毛頭な」く、「境界づけられたコンテキストやコンテキストマッピングも使わずに済ませ」(p.38) ていました。我々が「主に注目していたのはエンティティ(5)や値オブジェクト(6)で、全体像にまで目が回っていなかった」ので「コアとなる複数の概念をひとつの汎用的な概念に混ぜ込んでしまい」、「DDD を実践するうえでのゴールを完全には達成できて」(p.50) いなかったのではないかと考えています。
ドメインモデルの設計の指標
ドメインモデルを設計する際、我々はなるべくドメインエキスパートから話をきき、業務上の関心ごとをドメインモデルに表現しようとしていました。しかし、振り返って冷静に見てみると、あれだけドメインモデルと異なる境界づけられたコンテキストとして考えようとしていた、データベースのテーブルスキーマとできあがったドメインモデルは酷似していました。確かにドメインモデルのプロパティ名は我々が後ほど命名したビジネスを意識したテーブルスキーマとは異なるものでしたし、プロパティの型もリファクタ時に設計した値オブジェクトにしました。しかし、エンティティ が持っているプロパティはデータベース上のテーブルスキーマから大きく影響を受けていました。例示した Crop エンティティは実際の物と比べるとはるかにシンプルなものですが、実際のエンティティも例と同様にテーブルスキーマのカラムと大差ありませんでした。明らかに使われていないカラムを除いて、ほぼそのまま異なる名前のプロパティがエンティティに定義されています。たまたま、テーブル設計が適切でドメインモデルがテーブル設計と同じ構造になったかと言われるとそうではないと考えています。例えば、Crop のドメイン上の識別子 (エンティティを一意に特定できる、一度設定されたら決して変更されないもの) は、CropId です。この識別子の設計は今となると不適切であると感じています。というのも、CropId は、アプリケーション上で生成してデータベースに永続化されているものの、その実態はドメイン上で必要な識別子ではなく、Hibernate の仕様上必要になる代理識別子であるためです。
ORM ツールの中には、Hibernate のように、オブジェクトの識別子を自分の流儀で管理したがるものもある。Hibernate は、データベースのネイティブ型 (数値のシーケンスなど) を、エンティティの識別子として使う。もしドメイン側で別の形式の識別子を使っているようだと、Hibernate との間にあまり好ましくない衝突が発生する。一方はドメインモデルの流儀にあわせた識別子で、そのドメインの流儀にあわせた識別子だ。もう一方は、Hibernate 用の識別子で、これは、いわゆる代理識別子となる。[...]
代理識別用の属性は、外部からは見えないようにしておくといい。その属性はドメインモデルの一部ではではないので、外部に漏洩させてはいけない。
p.177
リファクタ前の Java で利用していた Hibernate の農作物テーブルのエンティティにも当然、CropId に相当するプロパティがありました。このプロパティには、Hibernate で必須となる @Id
アノテーションが設定されていました。この指針は、Kotlin でのリファクタ時にも引き継がれ、CropId は、エンティティを一意に特定するプロパティであると考えました。これは明らかにあやまったエンジニアの先走りです。ドメインエキスパートが農作物エンティティの一意性に関連してなにかを話す場合、一貫して CropId プロパティには言及せず、ManageNo (管理番号) プロパティを口に出していましたためです。CropId はあくまでデータベースの代理識別子で、リファクタ前の Hibernate のエンティティおよびデータベーススキーマに影響を受けたプロパティにすぎず、CropId は後述のユビキタス言語に登録できるものではありませんでした。
// CropId と ManageNo はまったく同じ意味だが、ドメインエキスパートが認識していたプロパティは、ManageNo のみ。
data class Crop(
val id: CropId,
val manageNo: ManageNo,
この発想は CropId にのみ当てはまるものではないように思います。我々は、データベースのスキーマを中心に物事を考えており、ドメインエキスパートの言葉は、そのスキーマに以下にして適合させるかの観点で聞いていたのだと思います。
エンジニアの独自判断
CropId がドメインモデルの一部ではないことが今振り返ってみるとわかるのですが、当時私を含めたエンジニアは CropId はドメインモデルに欠かすことのできない概念であると考えていました。エンジニアたちは将来的に、管理番号が重複を許容して一意で特定できなくなるかもしれないと予測し、そうなっても CropId で特定できればエンティティのモデリングを変えなくてもよいのではと考えていたためです。エンジニアは、CropId を、エンティティの識別子としてよく設計されたものであると肯定的に捉えていました。確かにいろいろなことを想定してアプリケーション開発をすると柔軟性がある設計になると思うのですが、ビジネス上に関するエンジニアの予測がズバリ的中してそのとおりになったことがほぼないように記憶しています。エンジニアは、ビジネスの変化についてあれこれと予測をしつつも、その予測を設計に反映させるべきではないと思われます。管理番号のプロパティも例に漏れず、ドメインエキスパートたちに一意の識別子として認識され続け、最後まで変わりませんでした。
結果的に管理番号の性質が変わらなかったので、このようなことがいえるのだ、というわけでもないと思います。仮に管理番号の定義が一意性を担保するものではないものに変わり、CropId のみが一意性を担保する属性になったとしても、管理番号でエンティティを特定できなくなることの影響は大きい形で現れるはずです。なんらかの重大な影響を極小化することは非常に難しいものと思います。
農作物エンティティはビジネスの中心にあり最初にモデリングしたため、その後のモデリングの指向性にも影響を与えました。我々は農作物エンティティをうまくモデリングできたと考えていたので、同じような設計ミスがその他のドメインモデルに入りました。ドメインエキスパートから都度ビジネス上の話を聞いてはいたのですが、どちらかというと個別要件の疑問点解消という点での会話が大きく、要件をプログラムに落とし込む際のドメインモデルの設計は該当のドメインモデルがマップされるテーブルスキーマの影響を受けていました。この設計プロセスのため、DDD の目的の一つであるドメインエキスパートのメンタルモデルのドメインモデルへの反映は失敗しやすい状態にあったと思います。
ビジネス上新たな概念が発生したとき、その新規概念は基本的には既存のビジネスの延長線上で、既存の概念への追加として考えられました。そのため、ドメイン上の新規概念は、原則既存のテーブルに新規列として追加され、既存のドメインオブジェクトの新規プロパティとして追加されていました。これを繰り返した結果、初めはコンパクトにまとまっていたのですが、ドメインモデルは肥大化しました。このプロセスが、アプリケーションを巨大な泥団子に向けていたのではないかと思います。
巨大な泥団子: 既存のシステムを精査した結果、システムを構成する各部分がどれも大規模であることがわかった。しかも、さまざまなモデルが混在しており、境界もつじつまが合わない。その場合は、そのひどい状態の全体を大きく囲む境界を定め、巨大な泥団子として扱う。このコンテキスト内では、きれいにモデリングしてやろうなどとは考えないこと。そんなシステムの影響が他のコンテキストにおよばないように、注意すること。
p.90
戦略的設計について
戦略的設計では「それなりに複雑な組織の事業を、全部入りのモデル一つで定義しようとするのは難しい」ので、「事業のドメインを分野ごとに切り分けてい」(p.42) きます。
戦略的設計をする時に重要になるのが、サブドメイン、境界づけられたコンテキスト、ユビキタス言語、そしてコンテキストの統合をするコンテキストマップであると思います。戦略的設計はアプリケーションを巨大な泥団子にならないようにする防御手段であると思われます。
サブドメインについて
サブドメインは事業のドメインを分野ごとに切り分けた論理的な単位です。『実践ドメイン駆動設計』の説明では、オンライン販売 (E コマースシステム) を例にあげています (p.42) 。オンライン販売と一言で言っても、オンライン販売を実現するための事業の分野は複数あります。例えば以下です。
- 買い物客たちに見せる商品カタログ
- 買い物客たちからの注文の受付
- 販売代金の徴収
- 買い手への商品の発送
これらはオンライン販売ビジネスを分割したもの (構成するもの) で、サブドメインになります。サブドメインの中にも種類があり、この種類は合計 3 つあります。
コアドメイン
事業を実現するために不可欠な分野です。
そこにはコアドメインと名づけられたサブドメインがある。[...] コアドメインとは業務ドメインの一部で、組織を成功に導くために最も重要なものだ。戦略的な意味において、そのコアドメインでは他を圧倒しているに違いない。事業を成功させるためには、それが不可欠だ。プロジェクトはコアドメインに最優先で取り組む。そのサブドメインに関する深い知識を持つドメインエキスパートと最高の開発者たちを集めて、しっかりしたチームを作り、思う存分その能力を活かせるようにしよう
p.48
支援サブドメイン
業務に不可欠な内容を表しているが、まだコアドメインとはいえないようなモデルのことを、支援サブドメインと呼ぶ。
p.48
将来的にコアドメインになりうる可能性があるものの、まだコアドメインと呼ぶことができない領域のようです。この引用から、コアドメインは支援サブドメインが昇格して、複数個できる可能性があるサブドメインであると考えています。
汎用サブドメイン
「業務上特別なことを特に何もしていなくても、ソリューション全体として必要なドメイン」(p.48) です。
当事者が開発に関わらないながらも、ビジネスの課題を解決するには必要不可欠なサードパーティ製のソフトウェアのように、「置き換えても、基本的な業務要件を満たせる」(p.56) ドメインです。
上述のように、サブドメインは 3 つに分類されていますが、コアドメインだけが重要と言うわけでもないようです。「汎用サブドメインもしくは顧客にとっての支援サブドメインになるであろうドメインは、実際のところはその事業のコアドメインである。」(p.33) からです。
境界づけられたコンテキスト
境界づけられたコンテキストは「解決空間」と呼ばれる「特定のソフトウェアモデルの集合」であり、「「特定のソリューションを表すものであり、それらを具現化したビュー」です。「何らかのソリューションをソフトウェアとして具現化する」(p.54) ので、ソフトウェアが作られる粒度として考えて良いと思います。
サブドメインが分割されたビジネス領域である一方、境界づけられたコンテキストがソフトウェアモデルの集合なので、1 サブドメインの概念が複数境界づけられたコンテキストのソフトウェアモデルにまたがる場合も、1 境界づけられたコンテキストの中に複数のサブドメインの概念を表したソフトウェアモデルができる場合もありますが、「各サブドメインを、境界づけられたコンテキストと一対一で対応させるのが、望ましいゴール」(『実践ドメイン駆動設計』2.3 実世界におけるドメインとサブドメイン) です。
ユビキタス言語について
ユビキタス言語についてはだいぶ誤解していたと思います。単に「業界で標準として定められている用語」や「ドメインエキスパートが普段使っている言葉」や「全社的に使われている言葉」であると考えていましたが、そうではありませんでした。認識がそもそもブレていたためか、最終的には、ドメインエキスパートに通じない言葉があったとしてもエンジニア内で共通の言語になっていたらそれでよい、という意識も、上述の CropId を振り返るとあったと思います。「ユビキタス言語とはドメインエキスパートやソフトウェア開発者を含めたチーム全体で作り上げる共有言語」(p.20)でした。
ユビキタス言語は、1 つの境界づけられたコンテキストにおいて有効で、すべての境界づけられたコンテキスト (事業全体) で有効になることはない、という認識もありませんでした。
・境界づけられたコンテキストごとに、それぞれのユビキタス言語が存在する。
・境界づけられたコンテキストは、当初の想定よりも比較的小さくなる。境界づけられたコンテキストは、ひとつの業務ドメインのユビキタス言語全体を捉えることができる程度の大きさにとどまるものであり、それより大きくはならない。
・その言語がユビキタスになるのは、あくまでも、個別の境界づけられたコンテキストの開発プロジェクトで作業をするチーム内に限ったことだ。
・単一の境界づけられたコンテキストで開発をする単一のプロジェクトには常に、別の境界づけられたコンテキストが絡んでくる。これらの統合にはコンテキストマップ(3)を使う。個々の境界づけられたコンテキストは、用語の重複はあるかもしれないが、自身のユビキタス言語をそれぞれ持っている。
・ひとつのユビキタス言語を事業全体で共有しようとしたり、あわよくばいくつもの事業で共有しようとしたりすると、失敗するだろう。
p.23
そもそも境界づけられたコンテキストの概念がないので、コンテキストマップ (境界づけられたコンテキスト間の統合) の発想はありませんでした。エンジニアは、ドメインエキスパートと協業することなく、既存の概念は、もっぱら既存のデータベーススキーマから「ユビキタス言語」を読み取りドメインモデルに反映させていました。
境界づけられたコンテキスト間で重複するユビキタス言語
我々は境界づけられたコンテキストの考え方がなかったものの、部門によって、ドメインモデルの扱いが異なると認識していました。この考え方がベースなので、部門ごとにクライアントアプリケーションがあり、それに応じたサーバーサイドのエンドポイントを設計しました。クライアントアプリケーションについては、確かに部門が違うので、部門ごとに異なるものとして設計してもよいように思います。しかし、ドメインモデルが実装されているサーバーサイドはそういうわけでもないと思っています。Java で設計されていたかつてのアーキテクチャをひどいものとして、リファクタしようとしましたが、Kotlin でリファクタしても結局部門ごとにアダプタが区切られて大枠は同じような設計になっていることから、サーバーサイドのアプリケーションもレガシーのアプリケーションの影響を受けていたのだと思います。我々は、各部門ごとに差異を考えなくてもよい共通する部分をひとつのドメインモデルに収斂させ、部門で振る舞いが変わる部分を実現するためにクライアントアプリケーションとそのアダプタ・アプリケーションサービスを設計していたのだと思います。結果として、部門が境界づけられたコンテキストのように扱われていたように思います。
我々はドメインモデルの差異を部門を使って表現しようと考えていたのですが、おそらくそれは間違いであったのだと思います。我々は「農作物」という言葉が 1 つしかないと考えていたのですが、この言葉は、コンテキストによって意味が変わる言葉であり、その前提で設計するべきであったのだと思います。
ここでは、出版社の業務をモデリングすることを考える。出版社では、書籍の誕生から最期までのあらゆる段階を扱う必要がある。おおざっぱに考えて、出版社は以下のような段階を扱うことになるだろう。これらは、書籍のライフサイクルにおけるさまざまなコンテキストにあわせたものだ。
・書籍の内容を案としてまとめ、起案する著者と出版契約を結ぶ
・書籍の執筆と編集のプロセスを管理する書籍の装丁や挿絵をデザインする
・書籍を他の言語に翻訳する
・紙の書籍や電子版を作成する
・書籍を宣伝する
・書籍を再販業者に販売する(あるいは顧客に直販する)
・紙の書籍を再販業者や顧客に出荷する
これらの各段階で、書籍を適切にモデリングする方法はたったひとつだけなのだろうか?もちろん、そんなわけはない。段階ごとに「書籍」の定義も変わってくる。出版契約を結ぶまでは書名案も確定しないだろうし、その書名案だって編集中に変わるかもしれない。執筆・編集段階の書籍と言えば、草稿とそれに付随するコメントや校正の集まりと最終草稿のことだ。グラフィックデザイナーはページレイアウトを固める。プロダクションはそのレイアウトを使って出版イメージを作り、印刷に回す。マーケティングの際には、執筆・編集段階の成果物はほとんど使わない。使うのは、せいぜい表紙のイメージと概要説明くらいだろう。出荷の際に書籍が抱える情報は、ID・倉庫の位置・在庫数・サイズ・重量などだ。書籍を扱う統一モデルを作って、すべての段階でそれを使おうなどと考えると、いったいどうなるか。きっと混乱・意見の不一致・争いなどが発生し、ソフトウェアの完成も遅れてしまうだろう。その共通モデルが全体でうまく使えることも、たまにはあるかもしれない。しかしそれも、すべてのクライアントの要求をその瞬間だけ満たしているものであって、いつまでも続くことはない。
そんな望まざる状況に対応するため、DDD でのモデリングの際には、別の境界づけられたコンテキストを利用する。これらの複数の境界づけられたコンテキストのそれぞれに、書籍を表す型を用意する。これらの書籍オブジェクトにはおそらく識別子があって、それをコンテキスト間で共有するのだろう。[...] しかし、識別子は共有するものの、各コンテキストにおける書籍のモデルはすべて異なる。それで全然かまわないし、むしろそうあるべきだ。ある境界づけられたコンテキストに関わるチームが書籍のことについて話すとき、それはまさに、そのコンテキスト内で必要となる意味合いで使われることになる。
p.60-61
確かにライフサイクルによって、書籍を扱うチームは変わることが想定されています。チームによって「書籍」の意味は確かに変わるのですが、意味が変わるきっかけ・原因は、「書籍」のライフサイクルであると思います。チームは書籍のライスサイクルの結果現れるものです。ドメインモデルにしたいモノからドメインモデルを設計しないと、我々のビジネスのように、同一ライフサイクルに複数部門が関わることがあり、不都合が生じる可能性が高いと思われます。
戦略的設計の活用をしていたら
上記のようにビジネスにコンテキストがあるのであれば、戦略的設計を導入する必要があるはずです。
もしも戦略的設計を我々のビジネスについて活用していたとすれば、どのように境界づけられたコンテキストを設計するかが重要になるだろうと考えられます。この答えを私の頭の中で出そうとしていますが、答えを出すのは難しいと感じています。「ドメインエキスパートたちの用語が、実際の文脈上の境界をどう表しているか」に注意せず「アーキテクチャ的な問題や開発者の問題に対応してしまうと、言語が分断されて、表現力を失ってしまう」(P.66) ためです。
ステータスがコンテキストになると仮定する
とはいえ、なにか考えないので仮定の仮定で考えてみたいと思います。ドメインエキスパートがいないのでどこまで意味があるかわかりませんし、境界づけられたコンテキストのサイズを「先走って小さくしすぎてしまって」(p.67) いるかもしれませんが、我々が「ステータス」と呼んでいたものが実は境界づけられたコンテキストを作るための指針の一つであったのではないか?と考えています。例えば下記のようにコンテキストを定義できたのかもしれません。
----- -----
|栽培| |種子|
|計画| |購入|
----- -----
----- -----
|播種| |廃棄|
----- -----
----- -----
|成長| |品質|
|観測| |調査|
----- -----
----- -----
|収穫| |出庫|
----- -----
サブドメインとコンテキストを 1 対 1 で対応させていますので、サブドメイン = 境界づけられたコンテキストで考えています。
「農作物」についてドメインエキスパートが「ステータス」とよく言っていたので、ステータスが重要だと思っていました。ステータスという言葉が使われる頻度から確かに「ステータス」というものは重要だったのだと思います。ただ、それとは別の見方が必要だったのでは?と考えた結果が上記です。
農作物のステータスが変わる時、ドメインエキスパート・エンジニア共に「ステータスを変更する」と表現していましたが、これはステータスに関する関心ごとが高いコンテキストでの話で、それ以外の関心ごとは別にあるのではないかと考えています。
ドメインエキスパートとユビキタス言語を作っているわけではないので、すべてが想定でどこまで核心をつけているかがわからないので一部だけ思うところを書きます。
成長観測コンテキスト
農作物管理部門は、農作物の現在の状態を観測して、ステータスを「芽吹き」から「開花」に変更します。ステータスが変わったのは結果ですが、このコンテキストではあくまで農作物の成長過程を観測し、その結果として農作物のエンティティのステータスが変わります。これを「成長観測」というコンテキストとして考えました。
農作物管理部門の他、業務運営部門同様のステータス変更をします。部門をベースに設計すると、2 つの部門がやっているビジネスは厳密には異なるものであるように見え、実際そのように認識していたのですが、見方を変えて業務運営部門は農作物管理部門ではどうにもならないなんらかのアクシデントに対応しており、その際に、業務運営部門は、農作物管理部門の中心的な関心ごとの「成長観測」を間借りしてビジネスを実行しているのでは?と仮定しています。このコンテキストでは農作物エンティティは CropStatus で列挙したいくつかのステータス遷移があるかもしれない、と考えています。
このコンテキストの農作物エンティティはこんなイメージです。
data class Crop(private val mangeNo: ManageNo, private val status: Status,,,,)
// コンテキストに関連するステータスだけ列挙される
enum class Status {
芽吹き
開花
収穫完了
}
廃棄サブドメイン
業務運営部門のみ関係あるコンテキストです。業務運営部門は、農作物の品質のチェックの報告を受けて、農作物を「廃棄」しました。この業務は「廃棄」コンテキスト上で行われていると考えました。農作物管理部門は農作物が成長することを観測しているので農作物管理部門を農作物のステータスを「廃棄」することはありません。
農作物が廃棄された際に、「変更理由」が必要になります。農作物は、「廃棄」コンテキストでは状態を変えないので、ステータスがプロパティにありません。
data class Crop(private val mangeNo: ManageNo, private val reason: String,,,,,)
部門に特殊な振る舞いを付与する問題
コンテキストを分割したことによって、部門で重複するステータス更新の実装の問題は解消するのではないかと考えています。コンテキストに特化したドメインオブジェクトが設計されることで、紛らわしい類似クラス・メソッドが減ります。
一方でまだ問題が残ります。「廃棄」コンテキストのような、業務運営部門のみ関係ある場合はこの場合問題ないのですが、「成長観測」コンテキストの場合、業務運営部門と農作物管理部門などが関わり、それぞれの振る舞いの違いを実装しないといけません。上述の「業務運営部門はステータスの更新の遵守ルールが適用されない」ではない設計の検討も考えられますが、個人的にはこの設計が一番ドメインエキスパートに直感的であるので、踏襲するべき設計であると考えています。
これを考慮すると、もしかしたら、先述の業務運営部門は、農作物管理部門の中心的な関心ごとの「成長観測」コンテキストを間借りしてビジネスを実行しているのでは?という仮定が間違いであり、業務運営部門は独自のコンテキストを有しているのかもしれない、という気もします。確かに、業務運営部門が「成長観測」での農作物エンティティの状態を変える行為は、実際に観察を行った結果ではなくて、なんらかのサポートです。似て非なることなのかもしれないとも思います。
とはいえ、個人的には農作物というものを中心にコンテキストを考えてみると、やはりライフサイクルでコンテキストを引いた方が実態に近いように感じています。農作物のライフサイクルを中心にコンテキストを作り、部門間の差異を設計するには、部門に近い概念を「利用者が演ずる役割」(p.61) とみなすことが効果的ではないか、と考えています。
我々のアプリケーション設計では、部門ごとにクライアント・サーバーサイドアプリケーションがあり、部門内のユーザーに権限の違いはなかったので、どのユーザーでもログインしてしまえば同じことができる、というのが設計の基本線でした。しかし、部門ではなくビジネス上の関心ごとでコンテキストを作ると、このユーザーはこれができるべきだが、このユーザーはこれができるべきではない、という考え方をなんらかの形で表現しなければなりません。それが「利用者が演ずる役割」だと思います。
「成長観測」コンテキストでの利用者の役割ですが、「管理者」「作業者」がパッと思い付きます。管理者はステータス遷移の制約がありません。作業者は、ステータス変更の遷移のルールを遵守しないといけません。この役割によって、ステータスの遷移のルールを遵守する・しないを判断し、ルールを遵守しないといけない場合は、バリデーションをすることができると思います。バリデーションの実行する・しないについては、上述のロールモデルのような設計でバリデーション実行の有無を切り分けるのがよいのかもしれません。
部門が重要なコンテキストも存在するはず
「部門」は、「成長観測」では重要ではない考え方であると思いますが、部門が重要になるコンテキストは、別に存在する可能性が高いと思います。おそらく、そのコンテキストは、認証や権限などにに関わる、ビジネスに関わるユーザーを管理するコンテキストになると思います。そのコンテキストでは、このユーザーはこの部門に所属している、という事実を記録する必要があるはずです。「成長観測」コンテキストでは、現在作業しているユーザーをそのコンテキストにて認証し、そのユーザーが所属している部門を元に、成長観測コンテキストの腐敗防止層でローカルコンテキストで重要となる「利用者が演ずる役割」としての「管理者」「作業者」への変換をするのであろうと思われます。
バリデーションコンポーネント
この場合のバリデーションの種類は、 DB の長さを超えるような「無効な値がモデルに入ってこないように完全にガード」する「ディフェンシブプログラミング」(p.201) というより、「あるエンティティの個々の属性/プロパティが完全に妥当な状態だったとしても、エンティティ全体として妥当な状態」(p.203) ではないことを検証するものであると思います。「バリデーションはそれ単体で個別の関心事であり、ドメインオブジェクトではなくバリデーション専任のクラスが受け持つべき責務である」(p.200) ため、別途バリデーションコンポーネントを作成することになると思います。ただ、バリデーションコンポーネントがエンティティ全体で妥当な状態かを判断するもの、という定義であれば、バリデーションコンポーネントでステータスのルールの遵守を検証の仕方をどうするか考えあぐねています。今回の要件では、変更前後のステータスを比較する必要がありますが、農作物エンティティ単体には、以前のステータスがプロパティにありません。バリデーションを成功させるためには前回のステータスのプロパティを新規にエンティティに追加する必要がありますが、それが妥当な設計であるかは確信がもてません。確かに業務要件上必要なものではあるのですが、このバリデーション以外になにか役に立つか思いつきません。それであれば、ステータスを変更する際は、ステータス変更後の新規インスタンスを生成し、変更前と変更後の 2 つのインスタンスのステータスの比較をしたほうがシンプルな気がします。あるいは、ステータスというプロパティをやめて、農作物を抽象とした派生を作ってステータスの代替にする方が良いのかもしれません。派生の設計だと、比較についてはエンティティの型を調べるので、「いくつかのエンティティの組み合わせが全体として妥当であるか」(p.207) の観点で調べます。しかし、継承の煩雑さが発生してしまいますし、派生させるほどの特別な振る舞いがそれぞれのエンティティに存在するのかも疑問です。私が思いつくことができない、バリデーション以外にスマートな解決策があるのかもしれません。
なお、我々はバリデーション専任クラスの必要性に全く気づいていなかったので、上述のようにエンティティのメソッドの中でバリデーションのような実装が入り込み、なんらかのコマンドメソッドで例外を投げたり、あるいはなにもしないでエンティティを返すような実装になっていました。挙動がわかりずらいというのもありますが、単純に実装がごちゃごちゃしていまったので、バリデーションコンポーネントとして切り出すべきでした。
硬いプログラムの解決
戦略的設計を採用すれば、なにかステータスが追加すると修正箇所がとても多くなる、プログラムが硬くなる問題も解決できるような気がしています。
これまでの設計では、例えば、播種完了、芽吹きのステータスのみを対象とする場合、すべての農作物のステータスの中から 2 つのステータスをピックアップしていました。関心ごととなるステータス以外にも常に、すべてのステータスを列挙しなければならないこと、これがプログラムが硬くなる原因であったのではないかと思います。境界づけられたコンテキストを設定した場合、播種完了、芽吹きのステータスが対象となるおそらく多くのケースが、これらの状態がユビキタス言語になっている境界づけられたコンテキストになるのではないかと考えています。対象となるインスタンスを取得した場合、境界付けられたコンテキストで対象となるインスタンスが特定できるので、フィルタリングする際にステータスが不要になり、kotlin の when を駆使したプログラミングを硬くするクラスが不要になると考えています。
境界づけられたコンテキストの横断の問題の解決
上述のように硬いプログラムについては、適切に境界づけられたコンテキストを定義できれば問題解決しそうですが、物事はそうシンプルに進まなさそうな気がしています。境界づけられたコンテキストを定義することで影響が出るであろうことを 2 つ思いつきました。
1.バッチ処理
我々のアプリケーションでは、バッチで一括であるステータスのエンティティをまとめてクエリして、集計したりアラートをだしたりしていました。このバッチでクエリするインスタンスは、境界づけられたコンテキストを横断するかもしれません。技術的には関連した境界づけられたコンテキストから対象となるインスタンスをかき集めることは可能ですが、取得の仕方は複雑になり、ネットワークのレイテンシーが許容できないほど悪化する可能性があります。
2.農作物の俯瞰・進捗確認
業務運営部門の主要な業務のうちの一つで、農作物の状況を把握するビジネス要件があり、このビジネスでは農作物のステータスは重要な概念でした。
業務運営部門は農作物エンティティをまとめて一覧で表示し、この農作物は、「仕掛かり前」この農作物は「播種完了」といった確認を日常的にに行っていました。また、特定の農作物エンティティをピックアップして、いつ「仕掛かり前」になり、いつ、「播種完了」になったのか、という進捗を確認する業務もありました。境界づけられたコンテキストを導入すると取得対象インスタンスが複数コンテキストにちらばり、バッチ処理と類似した問題が発生します。
境界づけられたコンテキストを導入しなければ、すべてのモデルが汎用的なひとつのモデルにおさまっていたので、この業務をデータベースのレコードに基づいてクエリを組むことで実現できていました。そもそもドメインモデルにデータベースの関心ごとが入り込んでいたおかしな使い方になってしまっていますが、Status enum の value プロパティから DB の値を取得できるので、
TABLE.status `in` Status.values().filter { SpecificStatusSpec.doesMatch(it) }.map { it.value }
として取得していました。境界づけられたコンテキストを定義すると硬くするコンポーネントが消滅し、コンテキストも別れるのでこの実装の肩代わりを考える必要があります。
コンテキスト間の統合について
この問題は、コンテキストの上流と下流について考えてコンテキストを統合すると解決すると思います。境界づけられたコンテキストは、自立したアプリケーションですが、すべて自分のコンテキストで賄えるかというと、決してそんなことはなく、コンテキスト間には依存関係が生じます。この依存関係が、コンテキスト間の「上流」「下流」の関係性です。「上流のモデルは下流のモデルに影響をおよぼ」(P.95) します。下流のコンテキストは上流のコンテキストのモデルに依存して、なんらかの形で上流のコンテキストのモデルにアクセスして、ローカルコンテキストの中に最適化された形で取り込む必要があります。
「下流のモデル内で管理すべきプロパティの数を最小限にできる」(P.211) ので、「上流のコンテキストからオブジェクトを受け取るときは、可能な限り、下流のコンテキスト側でのその概念のモデリングには値オブジェクトを使う」(P.222) ようにします。統合は「上流のコンテキストのデータベースを手元にコピーするという意味では」(P.96) ありません。
この観点で考えると、境界づけられたコンテキストに散らばったインスタンスをまとめて扱いたいような場合、まとめて扱いたいコンテキストは、ステータスによって分割されたコンテキストとは別物で、ステータスごとに分割したコンテキストを上流とした下流コンテキストとして考えることができそうな気がしています。
栽培計画 上流 |-----------|--下流 1.バッチで一括でなんらかのインスタンスを生成したり通知するコンテキスト(バッチは様々な種類があるので種類に応じてコンテキストができるはず)
上流 |
| |
下流 |
種子購入 上流 |-----------|--下流 2.業務運営部門が農作物一覧を俯瞰するコンテキスト |
上流 |
| |
下流 |
種蒔き 上流 |-----------|
... その他のコンテキスト
下流コンテキストは上流コンテキストのモデルにアクセスします。上記例だと、栽培計画 => 種子購入 => 種蒔きの関係性の関係性があります。これらは、ステータスの遷移に従い、別のコンテキストに新規の農作物エンティティが生成されるイメージです。
バッチ、一覧のコンテキストについても、農作物の状態が変わるごとにインスタンスが生成・更新されると思います。
CQRS のアーキテクチャも必要になりそう
コンテキストをまたいだ農作物に関するなんらかの通知をするバッチや業務運営部門の農作物の一覧・進捗の管理のコンテキストでは、農作物のステータスの更新は行われないはずです。下流で農作物のステータスを更新すると、下流でだけ状態が代わり、上流と矛盾が生じるためです。上流のモデルは下流のモデルに影響を及ぼしますが、その逆についてはないものと思います。コンテキストをまたいで農作物を抽象化するようなコンテキストでは、農作物のモデルは、読み取り専用になるはずです。これは「問い合わせを最適化するようにチューニングした [...] クエリモデル」(p.134) であるはずで、そうなってくると、CQRS のアーキテクチャを採用することになるようにも思われます。
クエリモデルは、非正規化したデータモデルである。ドメインの振る舞いを伝えることを目的としたものではなく、表示用 (印刷用) のデータだけを扱うものだ。仮にこのデータモデルが SQL データベースだったとすると、クライアントのビュー (表示内容) の種類ごとにテーブルを用意することになる。個々のテーブルのカラム数も多くなるだろう。
p.136
CQRS もうまく利用すると複雑なクエリが不要になるはずで、トライしがいのあるものに思われます。しかし、字数の都合上、なにかまた別の機会に譲り、一旦備忘録的に書き記しておきます。
コンテキストの統合手法
エンタープライズモデルではすべてのプロパティがどの使われ方も同じように認識されます。巨大な集約になるにしろ、集約を可能な限り小さくする努力をするにしろ、1 つの大きなモデルですので、欲しいデータは 1 つのデータベースから取得できるため、コンテキストの統合という考え方は不要です。一方、コンテキストを作ると「リレーションを使ってデータをたどる」という考え方が消えます。代わりにコンテキストの統合が必要になります。うまくコンテキストの統合をすると、上述の農作物の一覧を別コンテキストから集める必要があるコンテキストのクエリはとてもシンプルなものになるように思います。
1.バッチ処理
下流側のコンテキストは、変更された上流のモデルを何らかの形で取得します。下流のコンテキストではなんらかの腐敗防止層により、上流のコンテキストのモデルをローカルコンテキストが扱う上で最適な形に変換します。
集計の場合、集計対象となる上流のインスタンスをローカルのモデルに変換します。これまでの設計ではバッチで集計していますが、この場合、リアルタイムで集計できると思います。上流のモデルにアクセスしたその断面で、バッチでまとめて出していた集計をすることになると思います。
通知の場合は、原則すべての農作物のインスタンスが通知対象の候補となります。そのため、農作物のエンティティが最初に生成されたタイミングで、農作物エンティティを生成したコンテキストのモデルにアクセスして、ローカルのコンテキストに変換します。一方、上流コンテキストで通知対象外となったら、下流のコンテキストはなんらかの形で上流のコンテキストにアクセスして、ローカルコンテキストにある通知対象から該当のエンティティを削除します。
2.農作物一覧・進捗確認
バッチ処理と同様の方法で下流側のコンテキストは、変更された上流のモデルにアクセスし、ローカルの農作物一覧のモデルに変換します。現在の状況、ならびに進捗がしりたいので、各上流コンテキストで農作物エンティティが生成・変更されたタイミングで、上流のモデルにアクセスして、下流のコンテキストに反映します。
下流のコンテキストが上流のコンテキストのモデルにアクセスする方法は 2 パターンありそうです。
タイマーを使ったドメインサービスによる統合
下流コンテキストは、腐敗防止層としてドメインサービスを設計して上流の状態から定期的に同期を取ります。
腐敗防止層: ドメインサービス(7) を下流のコンテキストで定義して、それぞれの型に対する腐敗防止層とすることができる。[...] 下流の腐敗防止層は、その表現を、ローカルのコンテキストのドメインオブジェクトに変換する。
p.97
ドメインサービスによる統合の考え方は『実践ドメイン駆動設計』の p.102 あたりで説明されている MemberService の実装方法に影響されています。『実践ドメイン駆動設計」では、「認証・アクセスコンテキスト」を上流、「アジャイルプロジェクト管理コンテキスト」を下流として解説しています。
認証・アクセスコンテキスト 上流 ---------- 下流 アジャイルプロジェクト管理コンテキスト
認証・アクセスコンテキストではユーザーと権限を管理しています。アジャイルプロジェクト管理コンテキストでは自社プロダクトであるアジャイル管理ソフトウェアの関心ごとのコンテキストです。アジャイルプロジェクト管理コンテキストは、認証・アクセスコンテキストのモデルにアクセスして、自分のコンテキスト内の利用者の役割に変換します。この変換は MemberService というドメインサービスが、認証・アクセスコンテキストにある RESTful HTTP のエンドポイントから変更情報を取得することで実現します。
MemberService はドメインサービスで、ローカルのモデルに対して ProductOwner オブジェクトと TeamMember オブジェクトを提供する。これが、基本的な腐敗防止層へのインターフェイスとなる。 maintainMembers() メソッドを定期的に実行して、 認証・アクセスコンテキスト からの新たな通知がないかどうかを確認する。このメソッドは、モデルのクライアントから実行されることはない。一定の間隔で起動するタイマーイベントを受けて、イベントを通知されたコンポーネントが MemberService を利用するために、 maintainMembers() メソッドを実行する。[...]
MemberService はさらに、IdentityAccessNotificationAdapter に移譲する。これはアダプターの役割を果たし、ドメインサービスと、リモートシステムの公開ホストサービスの間を取り持つ。
リモートの公開ホストサービスからの応答をアダプターが受け取ったら、それを MemberTranslator に移譲して、公表された言語からローカルシステムの概念への変換を行う。
p.102
ドメインサービスの maintainMembers() メソッドが実行されると、上流の認証・アクセスコンテキストの User は、下流のアジャイルプロジェクト管理コンテキストのローカルモデルProductOwner と TeamMember になります。これらのクラスは、Member のサブクラスです。下流の Member はエンティティで、値オブジェクトにするセオリーから外れていますが、上流の User エンティティにある、パスワード、電話番号といったプロパティは存在しておらず、「ミニマリズムを考慮した統合」(p.224) になっています。下流の腐敗防止層で変換された Member インスタンスは、updateMember() メソッドでローカルコンテキストに更新されます。
ローカルの Member インスタンスがすでに存在する、既存のドメインオブジェクトを更新する。これは、MemberService が自身の内部メソッド updateMember() に委譲することで行う。ProductOwner と TeamMember は Member のサブクラスであり、それぞれがローカルのコンテキストにおける概念を表す。
p.102
updateMember() によって既存のドメインオブジェクトに同期する理由は、「あとで使いまわせるように手元に保存せずに」単に REST ベースでデータ取得するだけだと「昔ながらの RPC 風の手法でリソースにアクセスする」だけなので「リモートサービスに依存しており、自立的ではない」(P.95) ためです。上流のコンテキストに異常があると下流のコンテキストにもその影響がおよびます。
この発想を流用し、下流のコンテキストは上流のコンテキストに対して、ローカルコンテキストで欲しい、農作物エンティティの変更をドメインサービス経由で、リモートの公開ホストサービス (おそらく RESTful HTTP) に問い合わせ、コンテキストの統合を実現します。
ドメインイベントによる統合
ドメインサービスを腐敗防止層にしてタイマーイベントで統合をはかると、上流の境界付けられたコンテキストがダウンしている時に、その影響を受けてしまいます。また、タイマーイベントを使うので、コンテキスト間でのモデルの不一致が出る期間が長くなってしまうかもしれません。
この問題の解決策として、ドメインイベントが挙げられています。ドメインイベントは、「ドメインエキスパートが気にかける、何かの出来事」で、
・「するときに」
・「もしそうなったら」
・「の場合は、私に知らせて欲しい」
・「の場合は、通知して欲しい」
・「が発生した時に、」
p.273-274
といったコトを一般化している言葉です。
ドメインイベントはローカルのコンテキスト内にのみ通知するものもあれば、「あるドメインで発生したイベントを別の境界づけられたコンテキスト(2) に通知しなければいけないケース」(p.274) もあります。この仕組みについては、イベントが発生したタイミングでコンテキストの統合を発生できることができそうなので、タイマーを使った統合よりもシンプルな実装で要件を実現できる可能性が高くなるように思われます。
多くのシステムで行われているであろうバッチ処理についても、考えなければいけない。おそらく、深夜などの混雑していない時間帯に、日次のメンテナンス処理などを行うこともあるだろう。不要になったオブジェクトを削除したり、新しい業務要件に対応するために必要なオブジェクトを作ったり、いくつかのオブジェクトの状態を同期させたり、特定のユーザーに対して通知を送ったりといった作業だ。この手のバッチ処理では、その対象を特定するために、複雑なクエリを実行しなければならないことも多い。これらに対応するための計算や処理のコストは大きいし、すべてのオブジェクトに対する変更を同期させるには、大きなトランザクションが必要になる。この面倒なバッチ処理を不要にできるとすれば、いかがだろうか?
ここで実際に、前日の出来事に後から追従する必要が出た場合のことを考えてみよう。もしそれらの個々の出来事がそれぞれひとつのイベントで表現されていて、システム内のリスナがそのイベントを受け取れるとしたら、話が単純にならないだろうか。実際、そうなっていれば、複雑なクエリを使わずに済む。いつ何が起こったのかが正確に把握できるので、その結果どうするべきなのかさえわかっていればよい。各イベントの通知を受け取ったら、それに対応する操作を行うだけのことだ。
p.275
引用はバッチのことを主眼に書いていますが、業務運営部門の農作物の一覧の俯瞰・進捗に関する問い合わせについても、「個々の出来事がそれぞれひとつのイベントで表現されていて、システム内のリスナがそのイベントを受け取れるとしたら」同じように、複雑なクエリが消えるローカルのモデルに変換できると思われます。
ドメインイベントをうまく実装できれば、ずいぶんシンプルな実装が実現できそうですが、「イベントを他のコンテキストに配送する際には、それが同じシステムであろうが別のシステムであろうが、結果整合性を利用するのが一般的」(p.275) であり、「あるモデルにおける変更が、他のモデルの状態も変えてしまう場合、ある期間だけ切り取ってみると、モデルの整合性が不完全になっている場合もあり得」、「複数の境界づけられたコンテキストをまたがって利用するには、結果整合性を導入する必要がある。これには逆らえない」(p.290) ので、1 トランザクションでコミットする以上に気を使う必要がありそうです。
結果整合性が導入されている背景には、アプリケーションの自立性を高めるため、ドメインイベントの配送手段が、「ActiveMQ や RabbitMQ、Akka、NServiceBus、MassTransit」(p.290) などのプロダクトを使ったメッセージング基盤が想定されているためです。
ドメインイベントを使うと、任意の数の業務システムを自立型のサービスおよびシステムとして設計できる。[...] リモートプロシージャ呼び出し (RPC) を避けることで、他システムからの高いレベルの独立性が達成される。RPC を用いると、リモートシステムへの API リクエストが成功しなければ、ユーザーリクエストの処理が完結しないのだ。
リモートシステムは、障害や高負荷などのせいで使えなくなるときもあるので、それに依存する側のシステムの成否は RPC に影響されうる。あるシステムが、RPC 方式の API を通じて他システムに依存するならば、依存する他システムの数が増えれば増えるほど、このリスクが高まる。[...]
他のシステムを呼び出すのではなく、非同期メッセージングでシステム間の独立性を保ち、自立させる方法がある。社内に散在する境界づけられたコンテキストからのドメインイベントを運ぶメッセージを受信するたびに、自分たちの境界づけられたコンテキストにおけるそのイベントの意味を反映した振る舞いを、モデル上で実行する。
p.292
仮にデータベースを適切に境界づけられたコンテキストに配置して、データベースが共有されている問題を解決して可用性を向上させても、RPC を使うとリモートの境界づけられたコンテキストのボトルネックになってしまいます。RPC をメッセージング基盤で代替することにより、リモートの境界づけられたコンテキストのトラブルに、ローカルの境界づけられたコンテキストが巻き込まれることがなくなります。
イベントを配送する際、イベントを発生させた境界づけられたコンテキストは 2 つの永続化ストアに変更内容を反映します。この 2 つの永続化ストアは常に整合性を保つ必要があります。
メッセージングのソリューションにおいて、少なくとも二つのメカニズムは常に整合性を保たないといけない [...]。その二つとは、ドメインモデルが使う永続化ストアと、モデルから発行されたイベントをメッセージング基盤が転送する際に使う永続化ストアだ。これらの整合性を保つことで、モデルへの変更が永続化されたときに、イベントが配送されることが保証されることが保証される。
p.291
ドメインモデルが使う永続化ストアが、ローカルコンテキストでのドメインモデルの永続化ストアで、メッセージング基盤が転送する際に使う永続化ストアが下流のドメインモデルに影響を与えるドメインイベントの永続化ストアであると思われます。2 つのデータストアが同期されていなければ、上流のローカルコンテキストのドメインモデルの変更が永続化されてないのに、下流に影響を与える永続化ストアには変更されたというイベントがストアされたり、あるいは、その逆の状況になってしまい、上流と下流で不整合が生じます。
ドメインモデルとイベントの整合性を担保する方法として、『実践ドメイン駆動設計』は「イベントストア」で解決しています。
イベント用の特別なストレージ領域 (データベースのテーブルなど) を、ドメインモデルがつかっているのと同じデータストア内に用意する。これはイベントストアと呼ばれるもので、[...] このストレージ領域を所有し管理するのは、メッセージングメカニズムではなく、境界づけられたコンテキストだ。外部のコンポーネントは、このイベントストアを使って、格納済みのイベントのうちまだ発行していないものを、メッセージングメカニズムを利用して発行する。
p.291
イベントストアは、メッセージング基盤ではなく、境界づけられたコンテキストによって管理されるので、上流の各境界づけられたコンテキストごとに配置されるはずです。イベントストアは、イベントが発生したコンテキストのトランザクションで管理され、イベント発行時に、Rabbit MQ などのメッセージには残さないはずです (イベントストアの id はメッセージで入れるかもしれない気もします)。上述の業務運営部門が農作物の一覧の俯瞰・進捗の管理をするようなコンテキストにイベントを配送する際は、上流に配置されたサブスクライバが購読すると、イベントストアにアクセスして必要なイベントを取り出し、下流のコンテキストにイベントストアの内容を配送すると思われます (下流のコンテキストが上流のイベントストアを触ることはないだろう、と思っています)。このケースでは、下流にはなんらかの農作物エンティティの状態が変わったイベントを受け取るための入口が 1 つあり、ここから腐敗防止層によって上流のドメインイベントを下流のモデルに変換すると思われます。この入口にアクセスするのが上流のメッセージングキューのサブスクライバで、イベントストアを下流に配送するのではないかと思われます。このコンテキスト間の関係性は、ドメインサービスを使った統合とは逆のように感じました。(メッセージングを使ったシステムに関わったことがないので、解像度の低い推測です)
『実践ドメイン駆動設計』では、認証・アクセスコンテキストを例にして、Spring のアスペクトで、コンテキストで発生したイベントはすべてイベントストアに保存しています。
@Aspect
public class IdentityAccessEventProcessor {
@Before
"execution(* com.saasovation.identityaccess.application.*.*(..))"
public void listen() {
DomainEventPublisher
.instance()
.subscribe(new DomainEventSubscriber<DomainEvent>() {
public void handleEvent(DomainEvent aDomainEvent) {
store(aDomainEvent);
}
// 略
}
}
private void store(DomainEvent aDomainEvent) {
EventStore.instance().append(aDomainEvent);
}
}
ドメインイベントを受け取ったイベントストアは、イベントをデータベースに永続化します。
package com.saasovation.identityaccess.application.eventStore;
...
public class EventStore ... {
...
public void append(DomainEvent aDomainEvent) {
String eventSerialization =
EventStore.objectSerializer().serialize(aDomainEvent);
StoredEvent storedEvent =
new StoredEvent(
aDomainEvent.getClass().getName(),
aDomainEvent.occuredOn(),
eventSerialization);
this.session().save(storedEvent);
this.setStoredEvent(storedEvent);
}
}
public class StoredEvent {
private String eventBody;
private long eventId;
private Date occuredOn;
private String typeName;
public StoredEvent(
String aTypeName,
Date aOccuredOn,
String anEventBody) {
this();
this.setEventBody(anEventBody);
this.setOccuredOn(aOccuredOn);
this.setTypeName(aTypeName);
}
}
イベントストアは tb_stored_event に書き込まれます。
CREATE TABLE 'tbl_stored_event' (
'event_id' int(11) NOT NULL auto_increment,
'event_body' varchar(65000) NOT NULL,
'occured_on' datetime NOT NULL,
'type_name' varchar(100) NOT NULL,
PRIMARY KEY ('event_id')
) ENGINE=InnoDB
p.298
StoredEvent の TypeName がドメインイベントのクラス名に対応しているので、どのイベントが発生しているのを判断できます。変更につよい疎結合なアーキテクチャが実現できるのだろうと思います。
イベントストアとドメインモデルの永続化を 1トランザクションで 2 つコミットしなければならない問題については、この記事 で納得させられました。このあたりの設計は経験不足により非常に難しく感じられてしまいますが、設計する機会があればやってみたいと考えています。
終わりに
改めて『実践ドメイン駆動設計』を読んで、自分自身に戦略的設計の意識がなかったこと、戦略的設計の重要さを認識しました。それと同時に仮に当時の我々が戦略的設計を取り入れても、相当苦労しただろうと思いました。今振り返るとそう思うのですが、ドメインに関するナレッジが今ほどない時に、コンテキストを適切に設定することが難しかったと思います。また、データベースはアプリケーションで 1 つしかない状態であったので、コンテキストごとの自立性を高めるようとするならば、このデータベースを適切に分割する必要が生じます。ここから、さらに複数コンテキストの統合も必要になってきます。『実践ドメイン駆動設計』では、コンテキスト間の統合はメッセージングによって実現させていますが、単一のトランザクションで完結させる方法に比べて、設計がとても難しいと感じました。
境界づけられたコンテキストを実現するためにもっとも難しいのがデータベースを適切に分割することではないかと思います。すでに動いているものに対しての変更を、レガシーのデータベーススキーマの維持をしながら行うのはとても難しかったと思います。そのため、境界づけられたコンテキストを作るのであれば、段階的にまず、データベースを共有して、アプリケーション (Kotlin の実装) のみ分割するような方針をとらねばならないように思いました。この場合、データベースはコンテキスト間で共有されているので、データベースが単一障害点になり、アプリケーションの自立性を高める手段としてのメッセージングを利用するメリットは低そうな状態になっているようにも思います。そうなってくると、このパターンでのコンテキスト間の統合はメッセージングではなく、もっぱら RESTful HTTP ベースで実現し、いよいよデータベースの分割をする段階でメッセージングの導入を検討するべきであるように思われます。
ただし、アプリケーションの分割にしても、ドメインモデルの設計面以外に、インフラストラクチャレイヤの見直しが必要であり、骨の折れそうな作業になりそうだと思います。統合に使われる RESTful HTTP でのコンテキスト間の統合をどのように実現するかを考えねばなりませんし、RESTful HTTP のネットワークレイテンシの問題も起きないようにしておかなければなりません。リクエスト・レスポンスでの腐敗防止層での変換作業もドメインモデルのシンプルな変換よりもコストが高いと思います。これを考えると、我々がもし、戦略的設計を導入しようと決断していたら、まず「隔離されたコア」を目指すのがベターであったと思います。
これ(隔離されたコア) を達成するためには、まずコラボレーションコンテキストの中にある、セキュリティや権限がらみの箇所を徹底的に調べ上げる。それから、認証やアクセス管理のコンポーネントをリファクタリングして、同じモデル内で完全に切り離されたパッケージにまとめる。完全に別の境界づけられたコンテキストを作るという結果にはつながらないが、少しはそれに近づけるだろう。というのも、[Evans] に書かれているとおり、「隔離されたコアを切り出すタイミングは、システムにとって重要な、巨大な境界づけられたコンテキストがあり、モデルの本質的な部分が大量の補助的な機能のせいでわかりにくくなってきた時である」からだ。
p.73
この引用は、ある開発チームがセキュリティ、権限、ユーザーといった関心ごとを、本来混ぜるべきではないコンテキスト (コラボレーションコンテキスト) に含めてしまったシナリオの問題点の解決策を示しています。あいにく kotlin にはパッケージプライベートのアクセス修飾子がありませんが、その代わりに internal の修飾子があります。隔離されたコアごとにサブプロジェクトをきれば、多くのモジュールを internal にしてプロジェウトの独立性を高めることができます。また、サブプロジェクト間の依存関係を記述する必要が生じるため、パッケージで隔離されたコアで発生しうる相互参照は発生しなくなり、上流・下流の関係性が明らかになると思われます (個人的にはパッケージプライベートは maven のような仕組みがなかったので考えられたもので、kotlin ではプロジェクトの管理ができることが前提となっているので、より洗練されたものとして internal が登場し、kotlin にパッケージプライベートがなくなった代わりに、その近い概念として、同一ファイル内の private に置き換えられたのではないかと思われました)。アプリケーションの自立性は低いものの、ドメインモデルはコンテキストに最適化されるはずで、その次の段階の改善に向かいやすいと思います。
いろいろと書いてきましたが、『実践ドメイン駆動設計』で例に出ている開発チームが直面する問題は、最後に書いた部分も含めてまさに我々が直面していた問題でした。『実践ドメイン駆動設計』に書かれてる問題解決方法は、このドキュメントよりも圧倒的に深く、このドキュメントはそのうわっつらを触った程度です。特に境界づけられたコンテキストの統合方法については、そのような方法があるという記録と解像度を上げるための私の推測のメモです。たぶん、これだけ書いてもどうやって問題を解決するのかについて明確な答えが出ていないと思います。なるべく俯瞰して書こうと思いましたが、なかなか難しく、結果的に、私の備忘録的なものになってしまったと思いますが、読んでいただいた方に、なにか少しでも参考になることがあれば幸いです。