Web アプリケーション開発におけるアーキテクチャと OOP の相性
多くの現代の Web サービスは、MVC(Model-View-Controller)パターンに基づく三層アーキテクチャを採用しています。より現代的な実装では、フロントエンドとバックエンドが分離され、リポジトリ層、サービス層、コントローラ層という構成が一般的です。しかし、このアプローチは厳密にはオブジェクト指向プログラミング(OOP)の原則から逸脱している可能性があります。
特にドメイン駆動設計(DDD)の文脈において、従来の「貧血モデル(Anemic Model)」と呼ばれるパターンは、プロセス指向の考え方に近く、反パターンとして扱われることもあります。これに対し、「充血モデル(Rich Model)」を取り入れた DDD アプローチが推奨される状況が増えています。本稿では、両者の仕組みとその適用場面を、仮想ウォレットシステムの構築事例を通じて解説します。
1. 貧血モデルと充血モデルの定義
1.1 伝統的な貧血モデルとは
現在の多くのビジネスシステムでは、データ構造と振る舞いが分离されたクラス設計が採られています。これは以下のような階層構成で表現されます。
- プレゼンテーション層: コントローラと VO(View Object)。API エンドポイントを公開。
- ビジネスロジック層: サービスクラスと BO(Business Object)。処理の中核。
- データアクセス層: リポジトリとエンティティ。データベースとの結合。
ここで重要なのは、BO やエンティティといったデータ持ちは、単なる DTO(Data Transfer Object)として機能し、setter メソッドや getter メソッドのみを持ちます。実際の業務処理ルールはすべて外部のサービスクラスに集約されます。例えば、ユーザ情報の更新処理を行う場合、`UserService` がデータを取得し、条件を検証し、状態を変更して保存します。このように、データ(状態)と操作(挙動)が別々のオブジェクトにあるため、カプセル化の原則が弱体化されており、これを貧血モデルと呼びます。
// 貧血モデルにおける典型的な構造
public class AccountData { // 純粋なデータコンテナ
private Long id;
private BigDecimal balance;
// コンストラクタ、getter、setter は存在するが、振る舞いはない
}
public class AccountService { // ロジックを担当
public void transfer(Long fromId, Long toId, BigDecimal amount) {
// 状態変更ロジックがここに記述される
AccountData from = repo.findById(fromId);
AccountData to = repo.findById(toId);
// ... バリデーションと更新処理
}
}
1.2 充血モデルと DDD
充血モデルでは、データとその操作を同一のクラス内にカプセル化します。つまり、ドメインオブジェクト自身が振る舞いを持っており、外部からの不正な状態変更を防ぐことができます。これは純粋なオブジェクト指向スタイルです。
領域駆動設計(DDD)は、複雑なビジネス領域をモデル化する手法であり、微服务アーキテクチャの普及とともに注目されました。DDD を実践する際、ドメインレイヤーには業務ルールを含む充血したクラスを配置し、サービス層は協調調整やトランザクション管理などのインフラ側の責務に絞り込みます。これは「重いサービス、薄いドメイン」ではなく、「軽いサービス、重いドメイン」という設計思想の違いを表しています。
2. なぜ貧血モデルが広く使われているのか
OOP 原則に反するにもかかわらず、貧血モデルが標準となっている主な理由は以下の通りです。
- 複雑性の低さ: CRUD 中心のシステムでは、明示的なドメインモデルを設計するメリットが薄く、SQL スクリプトを直接 service に埋め込むだけでも対応可能です。
- 設計コスト: 充血モデルを採用するには、初期段階でビジネスルールを厳密に定義する必要があり、学習曲線が急です。
- 組織的慣習: 既存のコードベースやチームの知識資産が貧血モデルに寄っている場合、移行コストが障壁となります。
したがって、単純な情報管理システムには貧血モデルが適しており、金融計算や複雑なワークフローが存在するシステムには充血モデル(DDD)が有効です。
3. 事例:仮想ウォレットシステムの設計
ここでは、入金、出金、送金、残高照会、履歴確認という 5 つの機能を備えた仮想ウォレットを考えます。
3.1 貧血モデルでの実装イメージ
サービス層で全てのロジックを制御します。ウォレットオブジェクト自体には計算能力を持たせず、サービス側で残高を取得・検証・更新する手順を書きます。
public class WalletDto {
private String walletId;
private BigDecimal currentBalance;
// ガイガーメソッドのみ
}
public class WalletOperationService {
@Transactional
public void withdraw(String walletId, BigDecimal amount) {
WalletDto dto = walletRepository.selectById(walletId);
if (dto.currentBalance.compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
// 状態更新ロジック
dto.currentBalance = dto.currentBalance.subtract(amount);
walletRepository.update(dto);
// 帳簿記録
logTransaction(walletId, TransactionType.WITHDRAW, amount);
}
}
この方式は可読性は高いですが、ビジネスルール(例:残高不足時のチェックなど)がサービスクラスに散在しやすく、ドメインオブジェクトの状態整合性を保つ責任が呼び出し側に委ねられてしまいます。
3.2 充血モデル(DDD)での実装イメージ
ウォレット自体に「お金を出す」「入れる」という振る舞いを定義します。サービス層はこの振る舞いを呼び出す役割に限られます。
public class DigitalWallet { // ドメインエンティティ
private final String walletId;
private BigDecimal balance;
public void withdraw(BigDecimal amount) {
validateAmount(amount);
if (this.balance.compareTo(amount) < 0) {
throw new InsufficientFundsException();
}
this.balance = this.balance.subtract(amount);
}
public BigDecimal getAvailableBalance() { return balance; }
// その他の内部規則(凍結、引き上げ等)もここで管理
}
public class WalletFacadeService {
@Transactional
public void processWithdrawal(String walletId, BigDecimal amount) {
DigitalWallet wallet = walletRepository.load(walletId);
// ドメインオブジェクトが自身の不変条件を保証する
wallet.withdraw(amount);
// サービスは永続化とログのみ担当
walletRepository.save(wallet);
eventPublisher.publish(new MoneyWithdrawnEvent(walletId, amount));
}
}
もし将来的に「マイナス金利許容」や「資金ロック機能」が必要になった場合、貧血モデルでは複数のサービスメソッドを修正する必要がありますが、充血モデルであれば `DigitalWallet` クラス内部に新しい振る舞いやプロパティを追加するだけで済みます。拡張性と保守性が向上することが示されています。
4. アーキテクチャ的な考察点
4.1 サービス層の存必要性
DDD ではドメイン層が重厚になりますが、サービス層を完全に削除すべきではありません。以下の理由で残す必要があります。
- インフラへの依存遮断: ドメインモデルはデータベースやフレームワークに依存しないことが理想です。リポジトリとの通信、トランザクション境界の指定は Service レイヤが担います。
- オーケストレーション: 複数のドメインオブジェクトを跨ぐ処理(例:送金における支払元と受取先の双方操作)は、一つのドメインオブジェクト内で行うのは困難なため、ファサードサービスで調整します。
- 横断関心: ログ出力、通知、セキュリティチェックなどは技術的な要件であり、ドメインロジックとは切り離して管理するのが適切です。
4.2 インフラ層と API 層の扱い
一方で、リポジトリのエンティティや API の VO については充血モデルにする必要はありません。これらはデータの移送や DB マッピングの媒体であり、業務ルールを持ちません。これらを過剰に設計すると、単なるボイラープレートコードを増やすことになります。適切なアーキテクチャでは、境界ごとにモデルの豊かさを使い分けることが重要です。