はじめに
本記事は長文記事を避けるために連載方式を採用している。
以下が筆者が書いたSpring Data JDBC関連の記事である。
- (本記事)[DDD ORM] Spring Data JDBC Entityの定義
- [DDD ORM] Spring Data JDCB Repository操作
対象読者
- Spring Data JDBCに入門したい
- Spring Data JPAとの違いを知りたい
- DDDに則ったORMを使用したい
Spring Data JDBCとは
Spring Data JDBCはSpring(Spring Boot)が提供する
数あるデータアクセスフレームワークの一つ。
データアクセスオブジェクトをinterfaceとして定義しておくことで、
アプリケーションを起動する際にSpringがclassを自動生成してアプリケーションで使用してくれる。よってエンジニアはコネクションの管理などインフラ層の煩雑な処理を書かなくて済むといったようなSpring Dataの基本的な思想に基づいている。
またDDDの思想に強く影響を受けて開発されていることから、
集約やドメインイベントなどDDDのデータアクセスに則った実装を求められる。
集約の概念については不明な人は先に下記の動画を視聴すると良いだろう。
Spring Data JPAじゃダメ?
トレードオフがあるため、一概には言えない。
長らくSpring界隈もしくはJava界隈ではJPA(Spring Data JPA)がjavaのORMapperの頂点として君臨していたが、高機能かつ複雑な仕様から懸念される側面もあった。事実JPAの仕様を理解せずにコードを書いてしまっているJavaエンジニアに遭遇したこともある。
Spring Data JDBCはシンプルなデータアクセスライブラリであるJDBC Templateをラッピングすることで、それまでスタンダードであったJPAの仕様や制約、複雑なアノテーションを排除し、シンプルな実装を提供することを目指して開発されている。それと引き換えにLazy Fetchやテーブル継承、キャッシングのような高度な機能を提供しない。
よってトレードオフを考慮し、プロジェクトの展望に応じてORMを選択する必要がある。
選択肢が増えたくらいに考えるのが妥当だろう。
メリット
最大のメリットは簡単にDDD Like なモデルの実装ができることである。Spring Data JpaはDDDの思想に影響を強く受けて開発されているため、集約やドメインイベントを標準機能としてサポートしてる。またJPAで使われていた多くのアノテーションや制約から解放されたことで、ORMのEntityとDDDのEntityの差分を意識する必要がなくなったことも挙げられる。
Entityとは
DDDにおけるEntity
DDDではEntityの定義は、識別子によって同一性が特定されるオブジェクトである。例えば、あるサービスのユーザーである田中太郎さんは時間の経過により年齢や容姿、性格に変化がおこる。そのため状態で同一性が特定できないため、Idなどの識別子によって同一性を特定する必要がある。このような理由からEntityはライフサイクルが長いため、RDBMSなどのデータソースに永続化される必要がある。
ORMにおけるEntity
混同されがちなのは、旧来のJPAなどの文脈で語られるEnitityである。このような文脈での定義をこちらの記事から定義を引用する以下のようになる。
Entityとは「永続化可能なJavaオブジェクト」をさします。具体的にはRDBにある表に相当するオブジェクトだと思ってください。データベースの表(テーブル)に列(カラム)があるように、Entityには変数(フィールド)があります。またそれらのフィールドを操作するためのアクセッサー・メソッド(getter/setter)があります。Entityをインスタンス化するということは、データベースの行に相当するレコードをEntityのフィールドに関連付けることです。
しかしながらDDDの文脈で語られるEnitityはドメインオブジェクトなのでアクセサのみならず、ビジネスロジックを持つことができ、またそうすべきである。
Spirng Data JDBCにおけるEntity
Spring Data JDBCはDDDの思想に則っているため、JPAの思想ではなく、DDDの思想が優先されるべきである。したがってEnitityはビジネスロジックを持つ永続化オブジェクトとして実装されるべきであると筆者は考える
集約内のEntityの書き方
単一Entity(リレーションなし)
テーブルと同様の名前とフィールドをもつオブジェクトを定義し、IDとして機能するフィールドにIdアノテーションを付与するだけで良い。テーブル名はSpring Data JDBCによって解釈され、post_authtor -> PostAuthor
のようにスネークケースはキャメルケースへ変換されて解釈されるため実装者はJavaとMySQLの命名規則の差分について意識する必要はない。
create table conf (
id INT NOT NULL AUTO INCREMENT
value VARCHAR(256) NOT NULL
);
@Data
class Conf {
@Id
int id;
String value;
}
1対1 Entity
集約内で1対1のEntityを表現する場合、以下の仕様に従う必要がある
- 集約内のすべての参照はデータベース内で逆方向の外部キー関連になる
- デフォルトでは外部キー列の名前は参照エンティティのテーブル名である必要がある
あとはORMが名前解決を行って自動でマッピングしてくれる。
create table account (
id int primary key auto increment,
name varchar(255) not null
);
create table location (
account int primary key reference account(id),
adder varchar(255) not null
);
@Data
public class account {
@Id
int id;
String name;
Location location;
}
@Data
public class Location {
String adder;
}
1対N Entity
EntityをSetに格納することで、集約内での1対Nの関係を表現できる。上記以外のルールは1対1のEntityと同じである。外部キーカラムの名前をカスタマイズした場合は こちら を参考にして欲しい。
create todo (
id int primary key auto increment,
title varchar(255) not null,
content text not null
);
create comment (
id int primary key auto increment,
todo int not null reference todo(id),
comment varchar(255) not null
);
@Data
public class todo {
@Id
private int id;
private String title;
private String content;
private Set<Comment> comments;
}
@Data
public class comment {
private String comment;
}
集約外への参照
残念ながら現時点ではこれらケースでの直接参照はサポートされていない。このケースは集約の外部との関係になることから今後もサポートされないだろう。参照したい場合は集約の外への参照として自前でマッピングする必要がある。
N対1 or 1対N Entity
この際にAggregateReference
を使用することで集約外への参照であることを
明示的に定義することでコードの可読性を向上させることができる。
N対M Entity
この場合にはちょっと特殊なことをする必要がある。
中間テーブルの値を保持する参照オブジェクトを作成し、それを中間テーブルに紐づける必要がある。以下に部署と従業員の関係が兼務によりN対Mになる例を示しておく。
create table department(
id int auto_increment primary key,
name varchar(255) not null
);
create table employee (
id int auto_increment primary key,
name varchar(255) not null
);
create table employee_department(
employee int not null,
department int not null,
primary key (employee, department)
);
@Data
@AllArgsConstructor
@ToString
public class Employee {
@Id
private Integer id;
private String name;
private Set<DepartmentRef> departments;
public static Employee from(String name) {
return new Employee(null, name, new HashSet<>());
}
public void joinToDepartment(Department department) {
this.departments.add(new DepartmentRef(department.getId()));
}
}
@AllArgsConstructor
@ToString
@Table("EMPLOYEE_DEPARTMENT")
public class DepartmentRef {
private Integer department;
}
@Data
@AllArgsConstructor
@ToString
public class Department {
@Id
private Integer id;
private String name;
private Set<EmployeeRef> employees;
public static Department from(String name) {
return new Department(null, name, new HashSet<>());
}
}
@AllArgsConstructor
@ToString
@Table("EMPLOYEE_DEPARTMENT")
public class EmployeeRef {
public Integer employee;
}
EmployeeRefオブジェクトとDepartmentRefオブジェクトはともにemployee_department
テーブルの値を参照し、保持している。このように中間テーブルへ紐付けられたサンショオブジェクトを定義することで、お互いに参照できるようになっている。もちろん双方向の参照が必要ない場合は、片方削除しても問題なく動作する。
このように定義することで、リポジトリから引っ張ってきた際に参照したいEntityのIdをフィールドに保持することができるので、それを用いて参照を行う。以下に実際に試した際のコードを添付しておく。
// 部署の登録
Department sales = Department.from("sales");
Department customerSupport = Department.from("customer_support");
Department backOffice = Department.from("back_office");
departmentRepository.saveAll(Arrays.asList(sales, customerSupport, backOffice));
// 従業員の登録 と 所属部署の追加
Employee tanaka = Employee.from("tanaka");
tanaka.joinToDepartment(sales);
tanaka.joinToDepartment(customerSupport);
employeeRepository.save(tanaka);
// 従業員データの確認
Employee actualEmployee = employeeRepository.findById(1).get();
System.out.println(actualEmployee);
// 出力: Employee(id=1, name=tanaka, departments=[DepartmentRef(department=1), DepartmentRef(department=2)])
// 部署データの確認
Department actualDepartment = departmentRepository.findById(1).get();
System.out.println(actualDepartment);
// 出力: Department(id=1, name=sales, employees=[EmployeeRef(employee=1)])
番外編
外部キーカラム名のカスタム(1対1)
前述したように集約を実装するためには、非集約ルートテーブルは、集約ルートのテーブル名と同一とみなされる名前の外部キーカラムを所持する必要がある。
しかしながらこの仕様はかなり使い勝手が悪く感じる。外部キーであることを明示できるカラム名を好むエンジニアはおそらく筆者だけではないだろう。この問題は@Column("CUSTOM_FOREGIN_KEY_COLUMN_NAME")を
使うことで解決できる。この時外部キーが設定されたカラム名を大文字スネークケース1で記述する必要があることに注意したい。
以下にサンプルを記述しておく
create table account (
id int primary key auto increment,
name varchar(255) not null
);
create table location (
account_id int primary key reference account(id),
adder varchar(255) not null
);
@Data
public class account {
int id;
String name;
@Column("ACCOUNT_ID")
Location location;
}
@Data
public class Location {
String adder;
}
外部キーカラム名のカスタム(1対N)
今回も1対1の時と同じくマッピングに必要なカラム名を大文字スネークケース1で
明示してあげることで解決できる。
違う点はColumnアノテーションではなく、MappedCollectionアノテーションを使用するだけである。
create todo (
id int primary key auto increment,
title varchar(255) not null,
content text not null
);
create comment (
id int primary key auto increment,
todo_id int not null reference todo(id),
comment varchar(255) not null
);
@Data
public class todo {
@Id
private int id;
private String title;
private String content;
@MappedCollection(idColumn = "TODO_ID")
private Set<Comment> comments;
}
@Data
public class comment {
private String comment;
}
Entityオブジェクト初期化時の注意点
SpringDataJDBCの公式ドキュメントによると
永続化オブジェクトであるEntityオブジェクトの初期化は以下の仕様に基づき実行される。
- @PersistenceCreator でアノテーションが付けられた単一の静的ファクトリメソッドがある場合は、それが使用されます。
- コンストラクターが 1 つしかない場合は、それが使用されます。
- 複数のコンストラクターがあり、そのうちの 1 つだけに @PersistenceCreator アノテーションが付けられている場合は、それが使用されます。
- 型が Java Record の場合、標準コンストラクターが使用されます。
- 引数のないコンストラクターがある場合は、それが使用されます。他のコンストラクターは無視されます。
しかしながら公式ドキュメントの一般的な推奨事項を読んでみると、ほとんどの場合は下記の戦略を取るべきだと考える
- コンストラクタは all-args コンストラクターのみ提供
- 他のコンストラクタが欲しい場合はStaticファクトリメソッドで代用する。
EntityのフィールドをValue Objectにする
Spring Data JDBCでは、Spring Data JPA同様に、フィールドにValue Objectを持つことができる。Embeddedアノテーションを用いることでテーブルの値をValue Objectに変換できる。
該当カラムに値が存在しない場合onEmptyの設定値によって、格納される値を変更できる。USE_NULL
が使用された場合はnullが格納され、USE_EMPTY
はデフォルトコンストラクターまたは結果セットから NULL 可能パラメーター値を受け入れるコンストラクターを使用して、新しいインスタンスを作成する。
複数カラムの値をマッピングしたい場合はEmbedded
アノテーションのオプションのprefix
要素を使用することでこれを実現できる。詳しくはこちらの記事が解説してくれているのでそちらを参照したもらいたい。
まとめ
Spring Data JDBCはSpring Data JPAを単純にシンプルにしたものではなく、Spring Dataの思想とDDDの思想を掛け合わせたものである。したがってDDDの思想(特に集約パターン)に則ってEntityを実装することを心がけてほしい
参考
Githubリポジトリ
文献
動画
-
NamingStrategyの設定をいじれば、この制約を突破できるかもしれないが、確認はしていない ↩ ↩2