はじめに
- 今回「JPAの真実と誤解」という講義を受講し、改めて学んだ内容を整理した記事です
- 内容に誤りがある可能性もございますので、ご指摘いただけると幸いです
- 機械翻訳を使用しているため、不自然な部分があるかもしれません。もしありましたら、ご指摘いただけるとありがたいです
レイヤードアーキテクチャ
レイヤードアーキテクチャは以下のような流れで構成されます
- 赤い矢印はリクエストの流れです
ここで大きく2つの概念が確認できると考えられます
モジュール化 (Modularity)
システムを複数の独立した層(モジュール)に分割します。
各層は特定の役割と責任(例:プレゼンテーション、ビジネス、データアクセス、データ保存)を担います。
層間のインターフェースを通じて通信することにより、各層の実装の詳細が外部に露呈されないようにします。
単方向依存性(Downward Dependency)
伝統的なレイヤードアーキテクチャでは、各層が直下の層にのみ依存する(単方向)ように構成されています。例えば、プレゼンテーション層はビジネス層に、ビジネス層はパーシステンス層にのみ依存する形となっています。
下位の層は上位の層の存在を知らないため、上位層の変更が下位層に直接的な影響を及ぼさないようになっています。
これらの2つの概念により依存関係が制御され、テストが容易になるとともに、各層が独立しているため、ある層のコードの変更が他の層に影響を与えないようになり、変更や拡張が柔軟になります。
JavaEE (Jakarta EE) の設計方式の一つです.
各層の整理
プレゼンテーション層 (Presentation Layer)
ユーザーとの直接の相互作用(UIの提供、ユーザー入力の処理、結果の表示)
ビジネス層 (Business Layer)
アプリケーションの中核となるビジネスロジックとドメインルールを実装します
パーシステンス層 (Persistence / Data Access Layer)
ビジネス層で使用されるオブジェクトと実際のデータ保存先(データベース)との間でデータの変換を行い、CRUD操作を実施します
データベース層 (Database Layer)
物理的にデータが保存される層(データベース)
ここで、本記事で核心的に扱う部分は
ビジネス層 -> データベース層 の設計です。
トランザクションスクリプトパターン
ドメインロジックを手続き的に記述することをトランザクションスクリプトパターンと呼ぶことができます
実際にお金を入金するドメインコードを作成してみました.
public String deposit(String userId, String accountId, double amount) {
// 1. ユーザー確認
// userDao.findUserById() などのメソッドを使用して、ユーザー情報を取得します。
User user = userDao.findUserById(userId);
if (user == null) {
throw new Exception("User not found");
}
// 2. 口座確認
// accountDao.findAccountById() を使用して口座情報を取得し、その口座がユーザーのものであるかを確認します。
Account account = accountDao.findAccountById(accountId);
if (account == null || !account.getUserId().equals(userId)) {
throw new Exception("Account not found or not owned by the user");
}
// 3. 入金処理
// 入金金額が有効であるかを検証した後、残高に金額を加算します。
if (amount <= 0) {
throw new Exception("Invalid deposit amount");
}
double newBalance = account.getBalance() + amount;
account.setBalance(newBalance);
return "succcess";
}
- 便宜上、BigDecimalの代わりにdoubleを使用しました
トランザクションスクリプトとは、ドメインロジックを手続き的な方法で解決することを指します。(手続き型プログラミング)非常に直感的です。
User user = userDao.findUserById(userId);
この方式のパーシステンス層は、上記のコードのようにDAOで記述されており、DAO形式は以下の通りです。
Data Access Objectを通じたアクセス
Data Access Object(略してDAO)とは、データにアクセスするための層を指します。
データをメモリ上に置くためのオブジェクトを準備します。
@Getter
@Setter
public class User{
private Long id;
private String name;
// ...
}
そして、DAOを通じてデータベースにアクセスし、オブジェクトとマッピングします.
public User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
User user = new User();
user.setId(rs.getLong("id"));
user.setName(rs.getString("name"));
return user;
}
return null;
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
概ね次のような形をとります。
トランザクションスクリプトの特徴
上記のサンプルコードをご覧いただければお分かりの通り、大きく3つの特徴があります。
オブジェクトは単にデータを意味する
User
オブジェクトにはデータのみが含まれており、getterやsetterを用いて値を取得・修正するだけです。
その他の役割、つまり状態のみが存在し、責任は存在しない、実際にはデータ構造体に近い形態です。
すべてのドメインロジックはサービス層に配置されます
if (user == null) {
throw new Exception("User not found");
}
ユーザーが存在しない場合にエラーを返すという該当の主要ロジックは、サービスコードに含まれています。
まとめると、
すべてのビジネスロジックはサービス層に存在し、
オブジェクトは純粋にデータ処理のみを担当すると整理できます。
トランザクションスクリプトに適したツールはどれでしょうか?
DAOはデータアクセスのためのインターフェースにすぎませんので、実装は自由に選択することができます。
ご自身やチームの好みと都合に合わせて決定してください.
JdbcTemplate
private final JdbcTemplate jdbcTemplate;
public User findById(Long id) {
String sql = "SELECT * FROM users WHERE id = ?";
return jdbcTemplate.queryForObject(sql, new Object[]{id},
(rs, rowNum) -> new User(rs.getLong("id"), rs.getString("name")));
}
- 最も直感的な方法です
MyBatis
@Mapper
public interface UserDao {
@Select("SELECT * FROM users WHERE id = #{id}")
User findById(Long id);
}
- SQLとJavaコードの明確な分離が実現されます
- 動的なクエリの作成が容易です
jOOQ
public class UserDao {
private final DSLContext create;
public User findById(Long id) {
return create.select()
.from(USERS)
.where(USERS.ID.eq(id))
.fetchOneInto(User.class);
}
}
- コンパイル時に型安全性が保証されるため、安全なSQLを記述することが可能です
トランザクションスクリプトの欠点
データ中心の設計です
- データベース構造の変更に対して脆弱です
DAOの核心は、データをメモリ上にロードすることです。
もし要求事項が変更された場合はどうなるでしょうか?
user
に追加のフィールドができた場合はどうなるでしょうか?
User.classを修正し、Daoのクエリも修正し、マッピングも再度行わなければならなくなるでしょう.
- オブジェクト設計の自由度の制限
もし取引履歴を残すと仮定した場合は
fk(外部キー)のようなロジックは
private Long id;
private Long userId; // データベースの外部キーをそのまま表現
以下のように、ただ Long 型のフィールドにマッピングされるでしょう。
なぜなら、オブジェクトとデータベースのテーブルは1:1対応であるため、次のような問題が発生するしかありません。
オブジェクト指向であれば、当然
private Long id;
private User user;
であるべきではないかと思います。
メンテナンスが困難になる
- サービスコードにドメインロジックが記述されている
最大の欠点は、メンテナンスが困難になる点です。
if (user == null) {
throw new Exception("User not found");
}
前述の特徴で述べたように、すべてのドメインロジックはサービスコードに含まれています。
単純なCRUDの場合、トランザクションスクリプト方式の設計は非常に効率的ですが、
ドメインが複雑になるにつれてロジックがサービス層に集中し、if文や分岐処理が過剰に増加、
オブジェクトが単なるDTOレベルに留まるanemic domain model
の問題が顕著になります。
この点については、後ほどさらに整理して説明いたします。
- データの流れを把握するのが困難になる
User user = userDao.findUserById(userId); // データベースにアクセス
Account account = accountDao.findAccountById(accountId); // 別のデータベースにアクセス
double newBalance = account.getBalance() + amount; // 残高計算
account.setBalance(newBalance); // 状態変更
複数のDAOを通じてデータにアクセスしています。
このような構造では、私たちがデータを追跡および管理するのが困難になります。
まとめると、
データの変更箇所が不明瞭になり、トランザクションの範囲を把握するのが難しくなります。
オブジェクト指向の原則違反
- オブジェクトのカプセル化が不十分です
public class User{
private Long id;
private String name;
}
単にフィールドに private を付けたからといって、カプセル化されているとは言えないと講師はおっしゃっていました。
もし新しいフィールドを追加する場合や、たとえそのようなことが起こらないとしても、
例えば name を Long 型に変更したらどうなるでしょうか?
ドメインロジックから DAO ロジックに至るまで、すべてを書き直さなければならなくなります。
本当のオブジェクト指向のカプセル化とは、副作用を最小限に抑えることができるものでなければなりません。
- オブジェクトは単にデータのみを保持しています
サービスコードで入金ロジックを処理する際、ドメインオブジェクトの役割は単なるデータ伝達者(Data Transfer Object、DTO)にとどまっています。
すなわち、実際のビジネスロジック(入金の有効性検証、残高の変更など)がサービス層に集中しているため、オブジェクトが単なるデータのみを保持するアネミックドメインモデル(anemic domain model)
の問題が発生しております。
// Accountオブジェクトが自身の状態を保護できない
double newBalance = account.getBalance() + amount;
account.setBalance(newBalance);
オブジェクト指向的にはこのようであるべきです。
account.deposit(amount); // オブジェクトが自身の状態に責任を持つように
- 単一責任原則違反 (SRP)
サービスコードでは、ユーザー検証、口座検証、入金処理など複数の責任を一つのメソッドで実行しております。このように複数の責任が一か所に集まると、コード変更時に予期せぬ副作用(side effect)が発生する可能性があり、テストも困難になります。
ドメインモデル
以前の入金ロジックの一部をオブジェクト指向的に設計してみます
class Account {
private User user;
private double balance;
public void deposit(double amount) {
validateAmount(amount);
this.balance += amount;
}
private void validateAmount(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("入金額は0より大きくなければなりません");
}
}
public boolean isOwnedBy(User user) {
return this.user.equals(user);
}
}
class AccountService {
public void deposit(String userId, String accountId, double amount) {
User user = userDao.findById(userId);
Account account = accountDao.findById(accountId);
if (!account.isOwnedBy(user)) {
throw new UnauthorizedException("アカウントの所有者ではありません");
}
account.deposit(amount);
accountDao.save(account);
}
}
以下のようにオブジェクト指向的に設計したコードは、オブジェクトに状態と振る舞いがあり、メッセージで通信する、従来のDTOではなく真のオブジェクトとみなせるように修正しました。
これにより、オブジェクト指向の利点をすべて享受することができます。
- コードの意図がより明確になりました。Accountが自ら入金や出金を行うなど、オブジェクトの役割と責任がはっきりと分かるようになりました。
- ビジネスロジックが一箇所に集約されるため、メンテナンスが容易になりました。口座に関するすべての規則がAccountクラス内に存在します。
- 重複コードが削減されました。入金の検証など、共通のロジックを再利用できるようになりました。
- バグの発見が容易になりました。問題が発生した場合、該当するオブジェクトのみを確認すればよくなりました。
- 新しい機能の追加が容易になりました。既存のコードを修正することなく、新しいタイプの口座を作成することができます。
- テストコードの作成が容易になりました。各オブジェクトの責任が明確なため、単体テストが簡単になりました。
では、このオブジェクト指向的な設計をどのようにデータベースのテーブルと互換性のあるDAOに実装できるのでしょうか?
オブジェクト・リレーショナル・インピーダンス・ミスマッチ (Object-Relational Impedance Mismatch)
Javaのクラスは「オブジェクト」という概念を内包しています。
私たちは現実世界の問題(ドメイン)をコードの世界に表現するために、オブジェクト指向というパラダイムを採用して開発することが多いです。
一方、トランザクションスクリプトの手続き的なコードを開発する際は、以下のように単純に置き換えられるため、問題が発生しません。
Object(data) 1 : dao(mapper) 1 : table 1
なぜなら、RDBMS(リレーショナルデータベース)では、文字通り関係で結ばれた世界がそのままコードに反映されているからです。
オブジェクト指向のオブジェクトのあり方を一度見てみましょう。
もし、今のようにAccountというオブジェクトがUserを参照しているとしたら?
class Account {
private User user;
private double balance;
...
}
次のように表現することができます。
状態があり、振る舞いがあり、メッセージで通信するオブジェクト指向パラダイムのオブジェクトです。
しかしながら、データベースではこのように表現することができません。
あくまで account テーブルに user_id という外部キーで関係を表現しなければなりません。
また、継承や多態性といったオブジェクトの世界に存在する論理を、データベースの世界で表現するのは困難です。
これをオブジェクト・リレーショナル・インピーダンス・ミスマッチ(Object-Relational Impedance Mismatch)問題と呼びます.
Data Mapper Pattern
これを解決するために、Data Mapper Pattern が存在します。
このパターンは、ドメインオブジェクトとデータベース間のデータを変換および送信する役割を担っています。
この Mapper を導入することによって得られるメリットは以下の通りです。
- ドメインオブジェクトは純粋にビジネスロジックにのみ集中できます
- データベースに関連するすべての処理は Mapper が担当します
- Mapper はドメインオブジェクトのデータをデータベースに保存し、取得する方法を把握しています
このパターンを使用する際に併用されるパターンがさらに2つあります。
Identity Map
同一のデータベースレコードを重複してロードすることを防ぎます。
一つのデータベースレコードにつき一つのオブジェクトだけがメモリ上に保持されます。
例えば、ID が 1 の User を二度取得しても、同じオブジェクトインスタンスが返されます。
User user1 = userMapper.findById(1); // DBから取得
User user2 = userMapper.findById(1); // DBを照会せず、キャッシュされたオブジェクトを返す
assert user1 == user2; // 同一のオブジェクトインスタンス
Unit of Work
システム内で発生したドメインオブジェクトの変更点を追跡します。
変更されたオブジェクトをデータベースに一括で保存します。
不要なデータベース更新を防止し、一貫性を保証します。
user1.setName("New Name"); // 変更を検知
userMapper.commit(); // 変更されたオブジェクトのみをDBに一括保存
(実はORMの基本要素です。)
これらの要素をすべて自前で実装するのは実装コストが非常に高いため、
JavaではJPAやHibernateを通じてこのパターンを容易に利用することができます
DAO vs Repository の違い
DAO (Data Access Object) パターン
-
DAOは、データベースと直接やり取りし、データのCRUD(作成、照会、更新、削除)を行うことを抽象化したものです
-
データベースに依存しており、1:1で対応するオブジェクトを扱います
-
ドメインオブジェクトのビジネスロジックや複雑な関連関係を反映できません
-
データベースの変更や拡張が必要な場合、複数のDAOクラスを修正しなければならないという欠点があります
Repository パターン
-
DAOより一段階抽象化された概念です
- DAOはSQL中心でデータベースと直接やり取りするのに対し、Repositoryパターンはドメインオブジェクト中心にデータを扱い、内部でDAOやORMを利用することができます
-
データストアが変更されても(例: MySQL → MongoDB)、Repositoryインターフェースさえ維持すれば変更は容易です
- 内部で使用する技術が変わっても、インターフェースが維持されればビジネスロジックには影響がありません
-
コレクションに類似したインターフェースを通じてドメインオブジェクトを扱います
-
より高いレベルの抽象化を提供し、ドメイン駆動設計(DDD)の概念により近づきます
-
複数のDAOを活用して、より複雑なデータ操作を実行することができます
- 例)UserRepositoryがUserDAOやRoleDAOを組み合わせて、「特定の役割を持つユーザーの一覧」を取得することも可能です
-
単純なCRUD作業だけの場合、かえって不要な抽象化層となることがあります
まとめ
DAOはデータベースと直接やり取りする層であり、RepositoryはDAOよりもドメインオブジェクトに近い層です。
JPA (Java Persistence API)
Object-Relational Mapping (ORMとは)
オブジェクト指向プログラミング言語で使用されるオブジェクトとリレーショナルデータベースのテーブル間のデータを 自動
にマッピングする技術を指します。
Javaのエコシステムでは、JPA (Java Persistence API) を通じてORMが提供されています。
JPAとは?
JPAはJava Persistence APIの略で、Javaにおいてオブジェクトとリレーショナルデータベース間のマッピング(ORM)を容易に扱えるようにする標準APIです。
JPAは標準APIであるため、複数の実装体(例:Hibernate、EclipseLinkなど)を通じて実装体に依存せずにコードを書くことができます。
これにより、開発者はデータベースのリレーショナル設計に左右されず、ドメインモデルとしてコードを書くことができます。
JPAはどのように動作するのか?
通常、JPAの技術的な入り口は、Data Mapper、Identity Map、Unit of Workについて理解することから始まります。
次の投稿では、3つのパターンを使用したJPAの動作方法について整理していきます。