Spring Frameworkを使用している方ならば、JPAに慣れ親しんでいることでしょう。Node.jsベースのサーバー開発をしている方ならTypeORMやPrisma、Python界隈ならDjango ORMやSqlAlchemyを使用していることでしょう。ORM技術はなぜ生まれ、どのような課題を経て発展してきたのでしょうか?ORMに隠されたさまざまなパターンと課題について一緒に見ていきましょう。
ソフトウェアはある時点を境に劇的に規模が大きくなり始めました。ほとんどの新事業はIT技術を基盤として成長し始め、ソフトウェアの価値が大きく上昇しました。ビジネスに合わせてソフトウェアも進化が必要でしたが、ソフトウェアはさらに急速に変化し拡大するビジネスに対応しきれず、企業全体の成長の足かせとなってしまいます。IT業界でよく言われる「生産性」という指標が生まれるのです。
ソフトウェアの機能的要件に迅速に対応するために、逆説的にも非機能的要件が注目され始めました。アーキテクチャ、デザインパターン、OOP、クリーンコードなどがその例です。
マーティン・ファウラーは機能的要件、つまりビジネスロジックを「ドメインロジック」と表現し、これを3つのパターンに分類しました。トランザクションスクリプト(Transaction Script)、ドメインモデル(Domain Model)、テーブルモジュール(Table Module)がそれに該当します。また、マーティン・ファウラーはサービスの複雑度が高まるにつれてドメインモデルパターンに近づけることで適切なレベルの生産性を維持できると言います。
ドメインモデルパターンは、ソースコードのオブジェクト、すなわちドメインモデルが意味のある一つの対象(ドメイン)を表す相互に関連したオブジェクトのネットワークで構成されるパターンです。マーティン・ファウラーのPEAA本で2行程度で簡単に紹介されたエヴァンスのドメイン関連の本では、このドメインモデルをうまく作るための戦略と戦術的な方法が本の内容の半分以上を占めています。そして、これらのドメインモデルパターンを使用するための必要条件についてエヴァンスは次のように言っています。
「実装をモデルとそのまま結びつけるには、通常、オブジェクト指向プログラミングのようなモデリングパラダイムをサポートするソフトウェア開発ツールと言語が必要である。」 [Evans03]
もし現在、自分がオブジェクト指向の文法をサポートする言語を使用しているなら、ドメインモデルパターンを使用するための準備はすべて整っています。それでは、ドメインモデルパターンを実際にアプリケーションで実装する際にどのような課題があったのかを一緒に見ていきましょう。
ドメインモデルの最大の弱点は、データベースとの接続が複雑であることです。[Fowler02]
現代のほとんどのソフトウェアのデータは持続性(Durability)と完全性(Integrity)が保証されなければなりません。しかし、開発者が作成するドメインモデルのオブジェクトは通常、インメモリ上で生成され、修正され、削除されます。つまり、インメモリで行われるオブジェクトの動作はデータベースに同期されなければなりません。しかし、データベースロジックをドメインの外まで公開するのは非常に危険であり、ドメインモデルパターンの利点を失うことになります。データベースロジックはドメインロジック内で適切にカプセル化され、抽象化されて透過的に動作しなければなりません。
つまり、私たちの要件は次のようになります。
Context: ドメインモデルパターンを実装しようとする
Problem: ドメインモデルはデータベースとの接続が複雑
Forces:
→ インメモリオブジェクトの動作がデータベースに反映されなければならない
→ データベースロジックは適切にカプセル化され、抽象化されなければならない
それでは、上記の問題を解決し、Forcesを持つパターンを見ていきましょう。
Conceptual Pattern: Domain Store [Alur et al. 02]
本格的に紹介するマーティン・ファウラーのパターンの前に、そのパターンのWrapper Facade Pattern[POSA2]にあたるDomain Store Pattern[Alur et al. 02]について見ていきましょう。
Problem: 永続化(Persistence)ロジックをオブジェクトモデルから分離したい
Forces:
→ ビジネスオブジェクト[Alur et al.]に詳細な永続化ロジックを入れたくない
→ オブジェクトモデルが継承と複雑な関係を持っている
Solution: Domain Storeパターンはオブジェクトモデルを透過的に永続化できます。永続化サポートをオブジェクトモデル内に含むことで、永続化ロジックとドメインモデルを分離できます。
まず、パターンの構成要素と関連パターンは次の通りです。
PersistenceManager (Unit of Work[PEAA])
→ オブジェクトモデルの永続性とクエリを管理、Persistence ManagerはState Managerと相互作用し、必要に応じてオブジェクトの状態を更新するようStateManagerに命令
PersistenceManagerFactory
→ Persistence Managerを生成および管理
StateManager (Data Mapper[PEAA], Identity Map[PEAA], Lazy Load[PEAA])
→ 永続化可能なオブジェクトの状態管理、StateManagerは保存装置にトランザクション処理を行い、データリソースから状態を取得
StoreManager (Data Gateway[PEAA], Table Data Gateway[PEAA])
→ データリソースと相互作用してCRUDコマンドを実行、すべてのデータリソースメカニズムをカプセル化
PersistMap
→ オブジェクト間の関係、および永続化可能なオブジェクトとデータリソース間のマッピングに関する情報を持つ
Persistable
→ 永続化するオブジェクトが実装する必要があるインターフェースまたは抽象クラス
→ 現在の@Entity
アノテーションのようなもの
Business Object (Domain Model[PEAA])
上記のパターンを見ると、現代のJPA、Hibernateに似ていますが、異なる部分もあります。PersistenceManagerはEntityManagerに類似しており、StateManagerとStoreManagerの用途はやや不明瞭です。そして、"dirty"という概念が登場し、それの管理が行われていることが確認できます。
JPAやHibernateが上記のパターンとどれほど類似しているかよりも、なぜ類似しているかが重要です。類似した課題から出発したため、DomainStoreパターンと同様に大きな絵では異なる部分が見られることがありますが、PEAAのより詳細な要素のパターンは現在のJPAやHibernateで使用されている方式と非常に類似しています。それでは、PEAAのオブジェクトと関係パターンについて一緒に見ていきましょう。
Domain Store Patternが現在のJPA / Hibernateと非常に似た形態をしていることがわかりました。では、類似する要素を比較し、なぜこのような形態を持つようになったのかを一緒に見ていきましょう。
質問1: JPA/HibernateにDirty Checkingはなぜ作られたのか?
一般的にOOPでは、オブジェクト同士の協力は「In-Memory」で行われます。私たちはメモリ上のオブジェクトの協力と変化の間に永続化コードを記述したくありません。次の例のように。
public class PersonManagementProgram {
private final Database database;
@Transactional
public changePersonName(Long id, Sring name) {
Person person = database.execute("select * from person where id = ?", id, Person.class);
person.changeName(name);
database.execute("update person set name = ? where id = ?", name, person.id);
}
}
上記のコードを見てみると、DatabaseからPersonオブジェクトを取得し、その名前を変更しています。この時、Personの名前はIn-Memoryでのみ変更され、これをデータベースに反映するためには、上記のようにUPDATE文を使ってデータベースにクエリを発行する必要があります。開発者は意識的にオブジェクトの変化を追跡する必要があり、もしこれを見逃してデータベースに反映するコードを記述しなければ、データの持続性が保証されない結果を招きます。
このような問題を解決するためのパターンとして、UnitOfWork[Fowler 02]パターンを見ていきましょう。
Unit of Work Pattern [Fowler02]
ビジネストランザクションの影響を受けたオブジェクトのリストを維持管理し、変更内容を記録することと同時に、並行性の問題を解決します。
オブジェクトがロードされるか変更されるか生成された時に作業単位に登録し、コミット時に作業単位が何をすべきかを決定します。例を見てみましょう。
class UnitOfWork { ...
List<Object> dirtyObjects;
public void commit() {
updateDirty();
}
public void registerDirty(Object object) {
this.dirtyObjects.add(object);
}
public void updateDirty() {
dirtyObjects.forEach(( object ) -> {
DomainObject domainObject = (DomainObject) object;
Database.getMapper(domainObject.getClass()).update(domainObject);
});
}
}
class Person {
String name;
public void markDirty() {
UnitOfWork.getCurrent().registerDirty(this);
}
public changeName(String name) {
this.name = name;
markDirty();
}
}
public class PersonManagementProgram {
private final UnitOfWork unitOfWork;
@Transactional
public changePersonName(Long id, Sring name) {
Person person = Database.getMapper(Person.class).find(id);
person.changeName(name);
}
}
Personオブジェクトを取得し、changeNameを実行する時にUnitOfWorkにdirtyオブジェクトとして登録することが分かります。UnitOfWorkはトランザクションのコミット時にcommitメソッドが実行され、dirtyオブジェクトをデータベースにupdateします。
ここで、二つの重要な概念が出てきます。オブジェクトの状態(new, dirty, deleted, clean)とオブジェクトの登録(marking)です。作業単位がどのオブジェクトを維持管理するかはオブジェクト登録を通じて知ることができ、どのような動作を行うべきかはオブジェクトの状態によって決まります。この二つの簡単なコンセプトを通じて、Unit of WorkパターンはIn-Memoryオブジェクトの状態変化を追跡する責任を持つことができました。
現在のJPA/Hibernateのスナップショット方式(copy-on-read)とは異なり、各状態別にオブジェクト登録をする手間はありますが、おそらく進化を続けてきた現在のJPA/Hibernateの方が少し進化した実装でしょう。
質問2: In-MemoryのReference(アドレス)をどのようにDatabase Rowにマッピングするのか?
次のコードがあると仮定してみましょう。PersonオブジェクトをDatabaseから取得し、Personオブジェクトを生成してマッピングし、応答するメソッドです。
public class PersonRepository {
private final Database database;
public Person findById(Long id) {
Tuples tuples = database.execute("SELECT * FROM person WHERE id = ?", id);
return new Person(tuples[0], ..., tuples[n]);
}
public Person save(Person person) { ... }
}
そして、このコードを使用するクライアントのコードも一緒に見てみましょう。
public class PersonService {
private final PersonRepository personRepository;
public Person changeName(Long id, String firstName, String lastName) {
Person person1 = personRepository.findById(id);
person1.changeFirstName(firstName);
Person person2 = personRepository.findById(id);
person2.changeLastName(lastName);
}
}
このコードを見て、何かおかしな点に気付く方もいるでしょう。Databaseには1つのRowしかありませんが、In-Memory上ではそれを表すオブジェクトが2つに分かれています。もしUnitOfWorkにこれらのオブジェクトを追加するなら、UnitOfWorkは順番に処理しますが、これはDatabaseのLost Update問題に似ています。In-Memoryオブジェクトの変更が行われるたびにDatabaseにUPDATEするならば問題ありませんが、これは多くのクエリを発生させ、UnitOfWorkパターンが意図することと異なります。これを解決するためのパターンとして、Identity Mapパターンを紹介します。
Identity Map Pattern [Fowler02]
すべてのオブジェクトを一つのマップにロードし、オブジェクトが一度だけロードされるようにします。
class PersonIdentityMap {
private Map<Long, Person> identityMap;
public void putPerson(Person person) { ... }
public Person getPerson(Long id) { return identityMap.get(id); }
}
public class PersonRepository {
private final Database database;
public Person findById(Long id) {
if (PersonIdentityMap.instance.getPerson(id) == null) {
Tuples tuples = database.execute("SELECT * FROM person WHERE id = ?", id);
PersonIdentityMap.instance.putPerson(new Person(tuples[0], ..., tuples[n]));
}
return PersonIdentityMap.instance.getPerson(id);
}
public Person save(Person person) { ... }
}
例を見ればすぐに理解できるでしょう。マップを1つ作り、データベースからロードするとオブジェクトをマップに保存し、次の呼び出しが来た時に同じオブジェクトを返します。これにより、前述のケースでのLost Update現象を防ぐことができます。前の例では、person1とperson2は同じオブジェクトの参照を指しているためです。JPA/Hibernateでよく言われる「1次キャッシュ」がまさにこのIdentity Mapパターンです。
1つのTransaction Scriptで前述のケースのコードを書くことはほとんどありませんが、IdentityMapの存在理由とその存在のおかげで、ORMでは特に識別子マッピングが重要視されます。
ORMの限界
前回のブログ記事で紹介したUnit Of WorkとIdentity Mapを見ると、結局ORM技術はIn-Memoryレベルでのオブジェクトとデータソースのギャップを埋めることで、OOPにより近いソフトウェア開発を目指していることがわかります。私は簡単にいくつかのパターンだけを紹介しましたが、実際にはテーブルのデータを読み取り、オブジェクトにマッピングするパターンには、Metadata Mapping、Identity Field、Foreign Key Mapping、Serialized LOB、Embedded Value、Inheritance Mapping[PEAA]などの重要なマッピングパターンも存在します。しかし、発表でも述べたように、ORMフレームワークやライブラリにも明確な限界があります。それはデータソースの進化がORMフレームワークやライブラリの進化よりも速いということです。
RDBMSを超えて
RDBMSでは、ドメインモデルパターンを実装するためにJPAのようなORMツールを使えばほとんどの問題を解決できます。しかし、現在ではNoSQL、Column-Oriented、Search Engine、Time Seriesなど、さまざまなデータベースがデータソースとして利用される時代になっています。例えば、Kafkaを通じて実現するKappa ArchitectureのSingle Source of Truthや、AWSで常にデータソースとして登場するDynamoを見ても、RDBMSでは処理できない要件が増えてきました。RDBMSのORMだけを使用しているサービスに多様なデータソースが入ってくるとどうなるでしょうか?
ドメインを守ろう
ドメインロジックが詳細に埋もれないようにするのは非常に難しいことです。JPAだけを使用しているService-Repository-Entity構造のプロジェクトがあるとしても、データベースのプロシージャが必要な操作や特定のデータソースに依存する操作は、ORM技術上では実現不可能です。しかし、ヘキサゴナルアーキテクチャのPrimary Port — Domain — Secondary Portを考えれば、ドメインを守りつつこれらの要件を解決することは理論的には可能です。私が考えている「一貫性のある」ドメイン使用体験のための簡単で実践的な解決策を共有したいと思います。
Repositoryについて
Repositoryもまたパターンです。そして代表的なSecondary Portです。FowlerとEvansが定義したRepositoryの定義を見ると、Repositoryこそがデータソースを隠すことに最適化されたオブジェクトであることがわかります。
ドメインとデータソースの間で仲介する役割を担う [Fowler]
モデルをデータソースから構成(Configuration)/再構成(Reconfiguration)する [Evans]
バーノンのIDDDを見ると、RepositoryはCollection-OrientedとPersistence-Oriented Repositoryに分けられることが確認できます。コレクション指向リポジトリの代表例としてHibernateが挙げられます。しかし残念ながら、多くのData Gatewayに相当するモジュール(例えば、spring-data-redis/elasticsearch)などはコレクション指向リポジトリの要件を満たしていません。そしてまた、spring-dataのCrudRepositoryはPersistence-Oriented Repositoryです。コレクション指向リポジトリをラッピングした永続性指向リポジトリは非常に難解です。実際にJpaRepositoryの実装を見ると、saveメソッドで分岐してem.persistやem.mergeを実行している様子が見られます。
では改めて、データソースとO-Rマッピングに対する最良のオブジェクトがRepositoryであり、Repositoryがコレクション指向と永続性指向に分けられるならば、私たちは「データソースを隠す責任」と「ドメインモデルを返す責任」をRepositoryに委ね、これをコレクション指向で実装するか、永続性指向で実装するかを決めることができます。
個人的な経験では、「永続性指向リポジトリ」方式の方が実装コストを削減してくれると考えています。コレクション指向リポジトリを実装するには、JPAのダーティチェッキングのようなオブジェクト状態を追跡するパターンを実装する必要があるからです。現在JPAを使用しているなら、ダーティチェッキングを通じた更新方式を使用しないことをお勧めします。
public class ClientService {
private final PersonRepository personRepository;
@Transactional
public void changeName(Stirng id, String name) {
Person person = personRepository.findById(id);
person.changeName(name);
personRepository.save(person);
}
}
また、すべてのO-Rマッピングに関連するロジックをRepositoryの背後に隠すことができます。Repositoryが代表的なSecondary Portなら、その背後には常にAdapterとTest Classがあります。そして完全な形のドメインモデルを返せる必要があります。個人的にはPOJOドメインモデルとそのマッピングを直接実装するのが好きですが、ある程度の便利さとのトレードオフは可能だと思います。
それではNoSQLについても、継承関係のマッピングなど、既存のspring-dataがサポートしていないモデルのマッピングが可能になります。より豊富なドメインモデルを得ることができます。ただし、当然ながらマッパーを直接実装することは非常に多くのコストと頭痛を引き起こす可能性があります。このように大きなコストを支払って作成したドメインモデルがAnemicにならないように注意を払う必要があります。バーノンのIDDDに興味深く紹介されているAnemicドメインモデルの診断表を共有します。
- あなたが「ドメインモデル」と呼ぶソフトウェアは主にパブリックなゲッターとセッターを持ち、主に属性値ホルダー(Value Holder Pattern)として機能し、ビジネスロジックがほとんどないか全くないですか?
- 「ドメインモデル」を使用するソフトウェア構成要素がシステムのビジネスロジックの大部分を含み、これらが「ドメインモデル」のパブリックゲッターとセッターを頻繁に呼び出していますか?あなたはおそらくこれらのクライアント層をサービス層やアプリケーション層と呼ぶでしょう。もしあなたのユーザーインターフェースがこれに該当するなら、「はい」と答えた後、黒板に1000回「もう二度とこうしない」と書きなさい。
- ドメインモデルが豊富である(データが十分にある)場合、ビジネスロジックをドメインモデルに入れることで健全なドメインモデルを維持できます。
サービスについて
サービスは現在の開発者に非常に馴染み深いですが、その定義は人によって異なることがよくあります。Service LayerはFowlerがPEAAで述べているように、Cockburnのアプリケーション境界に触発されて書かれたと言っても過言ではありませんが、境界を作る責任だけでなく、DDDのDomainServiceのような別の責任も存在します。両方ともServiceという名前を共通して使用しており、なぜこのような違いが生じたのかを見ていきましょう。
[1] Service Layer[PEAA]では、Service Layerを二つの方式に分けています。トランザクションスクリプト形式でさまざまなドメインを組み合わせてクライアントの要求を実現する作業スクリプト(Operation Script)方式と、ドメインに対する薄いインターフェースを提供するDomain Facade方式があります。クライアントの要求は時にドメイン一つの単純なロジックを実行する要求であることもあれば、非常に多くのドメインをオーケストレーションする要求であることもあります。そのため、「境界」という意味を持つService Layerはこのように異なる形態を取ることができます。しかし、これを単にクライアントとの「境界」という意味に焦点を当てると、これはヘキサゴナルアーキテクチャのPrimary Portとなり、現代ではこれをApplicationServiceと呼ぶことが多いようです。
[2] DDD/IDDDでは、アグリゲート内で実装するのが「曖昧なドメインロジック」をカプセル化して提供するものをServiceと呼びます。一般的にアグリゲート内部でRepositoryを参照することを避けるため、Repository参照が必要なドメインロジックをまとめてこれをDomainServiceと呼びます。サービスは本当に必要な場合にのみ使用されるべきであり、無秩序なDomainServiceの使用によるAnemicドメインモデル化を懸念します。DomainServiceもまたドメインモデルの一部であるため、ドメインモデルの制約に従う必要があります。つまり、この時のサービスは「境界」を分けるものではなく、ドメインの一部の実装にすぎません。
ドメインモデルについて
最近では、アグリゲートで代表されるドメインモデルは非常に複雑なシステムの要求を実現するために必然的に現れるパターンです。これは以前の投稿で紹介した「認知負荷」とも密接に関連しています。ドメインモデルが豊富で凝集性が高いほど、ドメインモデルを読むだけで開発者は十分な知識を得て、高い生産性で開発に迅速に参加できます。
GRASPの情報エキスパートの原則により、オブジェクトは十分なデータを持っていることで十分な責任を割り当てることができます。そのためには、ドメインモデルに十分なデータを与えるためにRepositoryでのマッピングが非常によく行われている必要があります。また、多くのドメインモデルを使用してクライアントの要求を実現するServiceでは、単にRepository、DomainService、Domain Modelの組み合わせだけで要求を実現できる必要があります。
また、DDDで紹介されたドメイン蒸留や、最近流行しているイベントストーミングなどの手法を通じて、ドメインの十分な識別とコンテキスト設定がよく行われる必要があります。会社での要求を継続的に実現することで、ドメインに対する理解を深め、適切なオブジェクト定義をするために努力する必要があります。オブジェクト識別の作業は、レガシープロジェクトでも行うことができます。レガシーデータソースから健全なドメインモデルのマッピングが不可能であれば、データマイグレーションを検討することもできますし、マッパーコードを直接書くことで健全なドメインモデルに進化させることもできます。これはDDDのEvolving Order章でも興味深く紹介されています。
CRUDはきれいに書けても複雑なRead要求では健全なドメインモデルを維持することが非常に難しいことがあります。この時には、CQS/CQRSの概念とSpecificationパターンを適用することが助けになることがあります。Specificationパターンの適用を通じて、オブジェクトに対する検証、生成、選択(クエリ)の概念をユビキタス言語で抽象化し、これをQueryModelで再利用することができます。
QueryModelのレイヤーはドメインモデルと同じレイヤーですが、全く異なるモデルであることを認識する必要があります。QueryModelはドメインロジックを持たず、単にRead要求を実現するために別途分離されたオブジェクトです。CQRSの始まりは「モデル」を分離することから始まり、ドメインモデルとは完全に異なる物理的な場所(パッケージなど)でサーブされるべきです。これはドメインロジックを毀損するオブジェクトを識別するためであり、Eventual-Consistencyのオブジェクトを識別するためです。ドメインモデルがQueryModelに依存しているならば、これもまたヒューリスティックとして使用されることがあります。
JPAパターンシリーズを書きながら
最近さまざまな参考文献や書籍を読み、ドメインレイヤーに対する明確な定義を出したくて始めたシリーズです。偶然にもLazy Developer Conference(以下LDC)でこのテーマで発表することになりましたが、アマチュアが集まるカンファレンスにもかかわらず、多くのセミナーやカンファレンスの参加経験を振り返ってみると、非常に完成度の高いカンファレンスになっていて驚きました。そのおかげで私も急いで知識の空白を埋めるために勉強しました。このカンファレンスがさらに大きくなり、来年にはCOEXで開催されることを願っています。
Reference
- Conferenece 発表資料
- POSA2, Patterns for Concurrent and Networked Objects, 2000
- Pattern of Enterprise Application Architecture, Fowler, 2002
- CoreJ2EE Pattern (2nd Edition), Alur at el, 2003
- Domain Driven Design, Evans, 2003
- Exposing the ORM Cache, Keith & Stafford, 2008
- Specifications, Evans & Fowler, 2012
- Implementing Domain Driven Design, Vernon, 2013
- Clean Architecture, Martin, 2017