アプリケーション層でリレーションを扱う

アプリケーション層でリレーションを扱う

一般的なサーバーアプリケーションでは、エンティティ間の関係はデータベースの外部キーとJPAの関連関係で表現されることが多くあります。この方式は強力です。親のない子の保存を防ぎ、親を削除した際に子の削除を伝播させることができ、オブジェクトグラフに沿って保存と削除をまとめて処理することもできます。

しかし、実際のドメインにおける関係の変化は、単純なリポジトリレベルの関係処理よりも広いものです。あるエンティティが削除されると別のエンティティも削除しなければならない場合がありますが、場合によっては空の親を先に作成したり、兄弟エンティティを同時に作成したり、関連する値を再計算したりする必要があります。この記事では、Vizendの基本プロジェクトにおけるJPO構造を出発点として、なぜ関係をアプリケーションレベルで扱おうとするのか、そしてその補完方法を整理します。

1. JPOを簡潔に保つということ

Vizendの基本プロジェクトのJPOは、概して簡潔です。JPOはJPA Entityですが、関係を豊富に表現するORMモデルというよりは、ドメインオブジェクトを永続化するための保存モデルに近いものです。つまり、JPOがドメイン全体のオブジェクトグラフを内包するのではなく、1つのrowを保存し、それを再びドメインオブジェクトに変換する役割に集中します。

一般的なJPAモデルでは、親と子をオブジェクト参照で関連付け、親が子の一覧を永続コレクションとして所有するようにできます。しかし、Vizendの基本モデルでは、子が親のidを@FieldSourceIdとして持ち、親は子の一覧をtransientにします。そしてJPOは、この構造を関係マッピングなしに単純なフィールド中心で保存します。

class Board {
    private String id;
    private String name;
    private transient List<Card> cards;
}

class Card {
    private String id;

    @FieldSourceId
    private String boardId;

    private String title;
    private transient List<Comment> comments;
}

class Comment {
    private String id;

    @FieldSourceId
    private String cardId;
}

この構造の意図は、保存モデルを単純に保ち、DBレベルの関係処理に強く依存するよりも、アプリケーションコードで関係を解釈して処理しようとする方向性を示すことにあります。

2. リポジトリではなくアプリケーションが関係を扱う理由

最大の理由の1つは、DBベンダーと運用環境が固定されていないことです。基本プロジェクトやコード生成ベースのプロジェクトでは、最終的にどのDBで実行されるかが、最初から確定していない場合があります。外部キー制約はほとんどのRDBMSがサポートしていますが、制約の生成方法、参照アクションの詳細な動作、DDLの自動生成、migration戦略、triggerの使用方法は、DBと運用環境によって異なります。

リポジトリレベルの関係処理にドメインルールを強く依存すると、プロジェクトで選択できるDBが限られます。特定のDBのconstraint、trigger、cascade actionの動作を前提にドメインルールを設計すると、別のDBへ移行する際に予想以上の違いが発生する可能性があります。そのため、基本モデルでは特定のDB機能に依存するよりも、アプリケーションコードで関係ルールを解釈できるよう、単純なid参照を維持するほうが有利です。

2つ目の理由は、関係の変化が削除の伝播よりも広いことです。リポジトリレベルの関係処理は、親のない子の保存を防いだり、親が削除されたときに子も同時に削除したりすることには強みがあります。しかし、実際のドメインルールはそれだけにとどまりません。

例えば、Aを削除するとBも削除しなければならない場合は、リポジトリレベルの参照アクションやJPA remove cascadeである程度表現できます。しかし、Bを保存しようとした際に親Aが存在しないため空のAを先に作成しなければならない場合、Aが作成されたら兄弟Bも同時に作成しなければならない場合、Aの値が変更または削除されたことでBの値を再計算しなければならない場合は、単純な外部キーアクションでは表現が困難です。

データベースtriggerを使用すれば、一部の処理をDB内部へ移すことはできます。しかし、そうするとドメインロジックがアプリケーションコードではなくデータベース内に隠れてしまいます。テストや追跡が難しくなり、サービス境界を分割することも困難になります。そのため、アプリケーションが成長するほど、関係の変化はリポジトリの自動動作よりもユースケースとドメインルールに近いものになります。

3. アプリケーションコードで解決する際に生じる問題

関係をアプリケーションコードで扱うと、ドメインルールをより明示的に表現できます。Boardの削除時にCardを削除し、Cardの削除時にCommentを削除するロジックは、コードから読み取れます。単純な削除だけでなく、履歴の記録、状態の検証、権限の確認、projectionの更新といったドメイン上の振る舞いも同時に組み込めます。

問題は、関係処理のコードが次第に分散していくことです。最初はBoardの削除時にCardだけを削除すればよいとします。その後、Cardの下にCommentが追加されると、Cardの削除ロジックにCommentの削除が追加されます。さらにNotificationRule、DashboardWidget、ActivityLogなどの概念が増えると、削除の伝播コードはますます大きくなります。

1つ目の問題は漏れです。新しい参照フィールドが追加されても、既存の削除ロジックは自動的にそれを認識できません。開発者がその関係を覚えておき、削除ロジックに反映する必要があります。漏れがあると孤児データが残ります。

2つ目の問題は複数経路です。AからBを経由してCに到達する経路と、AからDを経由してCに到達する経路が同時に存在すると、Aの削除処理でCに2回到達する可能性があります。削除は1回だけ行われるべきですが、各handlerやactionが独立して後続の削除を呼び出すと、同じCの削除が2回呼び出される危険があります。

A -> B -> C
A -> D -> C

3つ目の問題は、影響範囲を把握することの難しさです。Aを削除する前に、どのエンティティが同時に影響を受けるのかを把握するのは困難です。リポジトリレベルの関係処理であれば、DBやJPAが定められたルールを実行しますが、アプリケーションコードのcascadeでは処理経路が複数のactionやhandlerに分散しています。

これらの問題は、リポジトリレベルの関係処理を使用しなかったために生じたというよりも、関係の伝播をアプリケーションコードで扱いながら、その実行フローを一貫して管理していなかったために生じた問題です。

4. 解決の方向性:DataEventとCascadeContext

Flow、Logic、PolicyHandler、CascadeContextに分け、それぞれの役割を分離して実行するようにしました。

Flow creates execution context
Logic performs its own CUD
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions

Flowはユースケースのエントリーポイントです。ここで1つの実行範囲を作り、CascadeContextを生成します。Logicは実際のCQRSを実行します。自身のエンティティを作成、変更、削除し、DataEventを発行します。PolicyHandlerはDataEventを受け取り、関連するLogicを呼び出します。

例えばBoardの削除リクエストが入ると、Flowはcontextを作成してBoardLogicを呼び出します。BoardLogicは、Boardの削除とBoardRemovedイベントの発行だけを担当します。BoardRemovedイベントを受け取ったBoardPolicyHandlerはCardの一覧を検索し、CardLogicを呼び出します。その後、CardLogicはCardRemovedイベントを発行し、CardPolicyHandlerがCommentLogicを呼び出します。

この構造では、各Logicは自分のものだけを処理します。BoardLogicはCardを知りません。CardLogicはCommentを知りません。関係の伝播はPolicyHandlerが担当します。

イベントベースの伝播で最も注意すべき点は、重複処理です。同じエンティティに複数の経路から到達する可能性があるためです。これを防ぐため、1つの実行フロー内ですでに処理したactionを記憶するCascadeContextを設けます。

public class CascadeContext {
    private final Set<String> completed = new HashSet<>();

    public boolean enter(Class<?> type, String id, String action) {
        String key = type.getName() + ":" + id + ":" + action;
        return completed.add(key);
    }
}

PolicyHandlerは、次のLogicを呼び出す前にcontextを確認します。すでに処理済みのactionであればスキップし、初めて遭遇したactionであれば次のLogicを呼び出します。

public class BoardPolicyHandler {
    public void onBoardRemoved(DataEvent event) {
        CascadeContext context = cascadeContextHolder.current();
        List<Card> cards = cardStore.findByBoardId(event.entityId());

        for (Card card : cards) {
            if (!context.enter(Card.class, card.getId(), "REMOVE")) {
                continue;
            }

            cardLogic.removeCard(card.getId());
        }
    }
}

これにより、AからBを経由してCに到達し、AからDを経由してCに到達する構造でも、Cは1回だけ処理されます。同じactionが2回目に入ってくると、context.enterはfalseを返し、handlerは次へ進みます。

この構造を適用する際は、Springイベントが同期的に実行されるという前提を明確にする必要があります。同じtransaction内で伝播させるには、一般的な同期EventListenerのフローを使用する必要があります。非同期イベントやcommit後のイベントを使用すると、同じCascadeContextとtransactionを維持することが難しくなります。

5. メタデータで関係を観察する

CascadeContextは実行の重複を防ぎます。しかし、どのような関係が存在し、どの関係が処理され、どの関係が漏れているのかを把握するには、別途オブザーバビリティが必要です。ここではFieldSourceIdとRelationPolicyを併用できます。

FieldSourceIdは参照関係を発見するために使用できます。例えばCard.boardIdにFieldSourceIdが付いていれば、アプリケーションはCardがBoardを参照していることを把握できます。この情報をスキャンすれば、参照グラフを作成できます。

ReferenceGraph

Board
Card.boardId

Card
Comment.cardId

しかし、参照グラフだけでは、親のCUDが子にどのような影響を与えるのかは分かりません。Boardが削除されたときにCardを削除するのか、Card.boardIdを空にするのか、それとも削除を阻止するのかは、別途定めるポリシーです。

これを実現するため、PolicyHandlerのメソッドにRelationPolicyのようなメタデータを持たせることができます。このアノテーションはhandlerの動作を代替するものではありません。実際の動作はmethod bodyに残ります。アノテーションは、このhandlerがどのような関係の伝播を担当するのかを示すメタデータです。

@RelationPolicy(
    source = Board.class,
    event = DataEventType.REMOVED,
    target = Card.class
)
public void onBoardRemoved(DataEvent event) {
    List<Card> cards = cardStore.findByBoardId(event.entityId());

    for (Card card : cards) {
        cardLogic.removeCard(card.getId());
    }
}

このようにすれば、アプリケーションは2種類のグラフを持つことができます。参照グラフは、誰が誰を参照しているのかを示します。伝播グラフは、どのイベントがどの対象に影響を与えるのかを示します。

ReferenceGraph
FieldSourceId based graph

PropagationGraph
RelationPolicy based graph

2つのグラフを比較すれば、影響範囲、欠落したポリシー、重複経路、循環経路を検査できます。例えば、参照グラフにはCardからNotificationRuleへ向かう関係があるのに、伝播グラフにはCardRemovedイベントに対するNotificationRuleのポリシーがない場合、漏れとみなせます。反対に、AからBを経由してCに到達する経路と、AからDを経由してCに到達する経路がどちらも存在する場合、グラフ分析の段階で複数経路を事前に警告できます。

Impact
Missing policy
Duplicate path
Cycle

この構造の利点は、ドメインロジックをアノテーションに隠すことなく、関係処理のオブザーバビリティを得られることです。FieldSourceIdは参照メタデータであり、RelationPolicyは伝播メタデータです。そして実際のドメイン上の反応は、PolicyHandlerのmethod bodyに残ります。

最後に、この構造はdry-runを追加することで、影響範囲の事前確認へ拡張できます。CascadeContextにdryRunフラグとimpact一覧を追加し、Logicがdry-runの場合は実際のリポジトリ変更だけを省略し、DataEventはそのまま発行します。これにより、実際の実行と同じイベントチェーンをたどりながら、DBを変更せずに、どのエンティティが影響を受けるのかを計算できます。ただし、dry-runはすべてのDataEvent handlerが同じCascadeContextルールに従うという前提がある場合にのみ信頼できるため、影響範囲を制御できる領域から慎重に導入するほうが安全です。

6. おわりに

データベースの外部キーとJPA cascadeは、関係を扱う強力なツールです。しかし、すべての関係の変化がリポジトリレベルのライフサイクル伝播で説明できるわけではありません。アプリケーションが大きくなるほど、関係の変化は削除の伝播を越えて、生成時の補正、兄弟の生成、再計算、projectionの更新といったドメインルールへ拡張されます。

Vizendの基本JPOが簡潔に保たれ、子が親のidをFieldSourceIdとして保持し、親の子一覧をtransientとして組み立てる方式は、この文脈で理解できます。関係をDB/JPAに強く委譲せず、アプリケーションコードで扱える余地を残す選択です。

もちろん、この選択には責任が伴います。DBが防いでいた整合性をアプリケーションが直接管理し、伝播漏れや重複実行を防止しなければなりません。そのために、Flow、Logic、PolicyHandler、CascadeContextの役割を分け、FieldSourceIdとRelationPolicyで関係を観察する構造について説明しました。

Logic changes only its own entity
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
FieldSourceId describes reference graph
RelationPolicy describes propagation graph

この構造の目的は、リポジトリレベルの関係処理をアプリケーションコードでそのまま模倣することではありません。関係の変化の意味をドメインコード内に残しながら、その実行フローを一貫して管理しようとする試みです。

お読みいただきありがとうございました。

HHkk

Site footer