1. はじめに
マイクロサービスアーキテクチャ(MSA)環境では、各ドメインサービスが独立したビジネス責任とデータベースを持ちます。本システムの勤怠および休暇ドメイン(Timecard/Leaveサービス)も独自のライフサイクルを実行しますが、ユーザーの休暇申請(LeavePlan)や特殊勤務申請などの主要な業務プロセスには、全社共通ドメインである「外部電子決裁サービス(Approval Service)」との有機的な連携が不可欠です。従来のプロセスは、内部サービス内で承認状態を独自に処理する構造でしたが、全社的な電子決裁の標準化に伴い、外部決裁サービスとの連携パイプラインを構築する必要がありました。しかし、MSA環境で外部決裁システムとの通信を単純な同期(Synchronous)方式で処理すると、決裁サーバーの応答遅延や障害が内部サービスのトランザクションブロッキングとして波及する問題が発生します。また、ユーザーによる申請の提出後、決裁ラインで承認(Approved)、却下(Rejected)、または取り下げ(Withdrawn)が行われる時点は非同期であるため、両サービス間のデータの最終的な一貫性(Eventual Consistency)を保証するアーキテクチャが求められました。本稿では、MSA環境で外部決裁サービスを連携する過程において、timecard-facadeのインバウンド層にApprovalStatusHandlerを構築し、SubmitLeaveRequestTaskおよびLeavePlanTaskと結合することで、非同期イベントベースの決裁状態同期パイプラインを完成させた実務上の実装事例を紹介します。
2. 技術選定の背景
外部決裁サービスとの連携アーキテクチャを設計する際に解決すべき主要な課題は、次のとおりです。
-
障害分離(Fault Isolation)および非同期分離:外部決裁システムのトラフィック急増やメンテナンスが勤怠サービスの可用性を損なわないよう、決裁完了時点の結果を非同期イベントメッセージとして受信する構成を採用しました。
-
外部識別子(approvalId)ベースのドメインマッピング:外部決裁サービスで生成された決裁文書識別子(approvalId)を、内部の休暇申請エンティティ(LeavePlan)に外部キー(Reference Key)形式でバインドし、後続の非同期状態変更イベントを受信した際に対象ドメインを正確に検索できるよう設計しました。
-
イベントの冪等性および状態遷移の検証:ネットワーク再送によって同一の決裁完了イベントが重複して流入しても内部データが汚染されないよう、エンティティレベルの冪等性ガード(State-based Guard)を設計しました。
そのため、システム間の結合度を最小限に抑えながら外部サービスの状態変化を安全に追跡し、分散環境におけるデータの整合性を確保できるソリューションとして、インバウンドイベントハンドラーに基づく状態同期パターンを導入することになりました。
3. 実際の適用プロセスおよびトラブルシューティング
本プロジェクトにおける実際の決裁連携パイプラインの設計思想に基づき、外部決裁サービスと内部ドメインを安全に連携したプロセスを、具体的なコードブロックとともに説明します。
3.1 決裁サービスへの申請要求および識別子マッピング(SubmitLeaveRequestTask)
ユーザーが休暇申請を提出すると、SubmitLeaveRequestTaskは渡された外部決裁文書識別子(approvalId)の有無を検証し、初期状態をInApproval(決裁処理中)に設定します。妥当性検証(残日数の確認、期間の重複チェック)を行った後、approvalIdをLeavePlanエンティティ内に永続化することで、後から非同期で到達する決裁結果イベントを追跡できる一方向マッピング構造を完成させます。
@Service
@RequiredArgsConstructor
public class SubmitLeaveRequestTask {
private final LeavePlanLogic leavePlanLogic;
private final LeavePermitLogic leavePermitLogic;
public String submitLeaveRequest(String citizenId, LocalDate startDate, LocalDate endDate,
LeaveType leaveType, double leaveDays, String approvalId) {
LeavePermit leavePermit = getLeavePermit(citizenId, startDate.getYear());
leavePermit.planLeave(leaveDays);
leavePermitLogic.modifyLeavePermit(leavePermit);
LeavePlanCdo leavePlanCdo = LeavePlanCdo.builder()
.citizenId(citizenId)
.leaveType(leaveType)
.planStatus(StringUtils.hasText(approvalId) ? PlanStatus.InApproval : PlanStatus.Planned)
.approvalId(approvalId)
.build();
return leavePlanLogic.registerLeavePlan(leavePlanCdo);
}
}
3.2 決裁状態イベントの受信およびルーティング(ApprovalStatusHandler)
外部電子決裁システムで決裁ライン上の最終承認、却下、または申請の取り下げなどの状態変更が発生すると、インバウンドコンシューマーチャネルを通じてイベントペイロードが流入します。ApprovalStatusHandlerは、外部決裁サービスのイベント仕様が内部ドメインコアに直接入り込まないよう隔離するアダプターの役割を果たし、受信した決裁状態(status)に応じて適切なドメインTaskメソッドへ処理をルーティングします。
@Component
@RequiredArgsConstructor
public class ApprovalStatusHandler {
private final LeavePlanTask leavePlanTask;
public void handleApprovalStatusChanged(ApprovalStatusEventPayload payload) {
String approvalId = payload.getDocumentId();
ApprovalStatus status = payload.getStatus();
switch (status) {
case Approved -> leavePlanTask.approveLeavePlanByApprovalId(approvalId, payload.getApproverId());
case Rejected -> leavePlanTask.rejectLeavePlanByApprovalId(approvalId, payload.getRejectReason());
case Cancel -> leavePlanTask.cancelLeavePlanByApprovalId(approvalId);
default -> {
log.info("Approval transient state received: {}", status);
}
}
}
}
3.3 ドメインの冪等性検証および状態更新(LeavePlanTask)
LeavePlanTaskでは、外部決裁識別子(approvalId)を基準に内部ドメインエンティティを検索します。ネットワークの再試行やメッセージの重複送信によって、すでに完了した承認イベントが再び流入してもシステムが誤動作しないよう、エンティティの現在の状態(PlanStatus.Approved)を事前に確認する冪等性ガード(State-based Guard)を実行します。有効な遷移に対してのみ状態変更と承認メタデータを永続化し、最終承認完了ドメインイベントを発行して後続プロセスを安全にトリガーします。
@Component
@RequiredArgsConstructor
public class LeavePlanTask {
private final LeavePlanStore leavePlanStore;
private final EventPublisher eventPublisher;
@Transactional
public void approveLeavePlanByApprovalId(String approvalId, String approverId) {
LeavePlan leavePlan = leavePlanStore.findByApprovalId(approvalId)
.orElseThrow(() -> new NoSuchElementException("연동된 휴가 건이 없습니다: " + approvalId));
if (leavePlan.getPlanStatus() == PlanStatus.Approved) {
return;
}
leavePlan.setPlanStatus(PlanStatus.Approved);
leavePlan.setApproverId(approverId);
leavePlan.setApprovedTime(System.currentTimeMillis());
leavePlanStore.update(leavePlan);
eventPublisher.publish(new LeavePlanEvent(leavePlan.getId(), leavePlan.getRequesterId(), PlanStatus.Approved));
}
}
3.4 トラブルシューティング:決裁状態の変化とDB Check制約の不一致の解決
決裁サービス連携の初期段階で、ユーザーが決裁申請を提出した後に内部ドメインの状態を遷移させる過程で、DBレベルのCHECK(plan_status IN (...))制約違反エラーが発生しました。従来の内部状態マシンスキーマには、独自の決裁基準に基づく状態値のみが定義されており、外部決裁サービスとの連携によって追加された詳細なライフサイクル状態(InApproval)が不足していたためです。これを解決するため、Flywayマイグレーションスクリプトを適用してDB制約を最新の決裁ライフサイクルに合わせて同期し、LeavePlanTaskに例外状態のロールバックガードを追加することで、実行時のデータ整合性を確保しました。
4. 適用結果および成果
社内の主要な勤怠および休暇ドメインに非同期決裁連携イベントハンドラーアーキテクチャを適用した結果、次のような技術的成果を達成しました。
-
MSAシステム間の結合度緩和および障害分離:外部電子決裁サービスのトラフィック急増や一時的な障害が勤怠サービスの基本機能に波及しない、完全な障害分離性を確保しました。
-
分散データの整合性保証:外部決裁識別子(approvalId)ベースの冪等ハンドラー設計により、ネットワーク再試行によるイベントの重複受信や処理漏れを発生させることなく、決裁文書と内部ドメインデータ間の一貫性を安定的に維持しました。
-
標準連携アーキテクチャの確立:ApprovalStatusHandlerを階層化・モジュール化し、今後、特殊勤務や出張など決裁サービスとの連携が必要な新規ドメインを拡張する際にも一貫して再利用できる社内標準の連携パターンを構築しました。
5. 結論
今回の決裁連携イベントハンドラーの実装は、MSA環境で外部サービスと通信する際にドメイン間の結合度を下げ、データの整合性を安定的に維持するバックエンドアーキテクチャ設計の重要性を実感するきっかけとなりました。
単純な同期式API呼び出しを避け、非同期イベントハンドラー(ApprovalStatusHandler)とエンティティレベルの冪等状態更新設計を導入することで、システム間の障害分離と最終的な一貫性(Eventual Consistency)の両方を実現できました。今回のプロジェクトを通じて確立した外部サービス連携パターンと状態同期のノウハウを社内標準ガイドラインとして資産化し、全社分散システムのアーキテクチャ安定性をさらに一段階向上させることに貢献したいと考えています。
* 参考資料
-
エンタープライズ統合パターン - イベント駆動コンシューマーおよびメッセージディスパッチャー
-
Chris Richardson - マイクロサービスパターン(Sagaおよび非同期メッセージング)
-
Spring Framework公式ドキュメント - IntegrationおよびEvent Listeners
D.Hyeok