-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Introducing payment domain with orchestration-saga for synchronous payment/refund flow #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d80ff9a
87cd310
0e87cf3
1b64b6b
0f908cc
525d7b9
78a0d2d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| # ADR: Payment Domain with Saga/Compensation Pattern and Inter-Domain Module APIs | ||
|
|
||
| ## Status | ||
|
|
||
| Accepted | ||
|
|
||
| --- | ||
|
|
||
| ## Context | ||
|
|
||
| The Concert Reservation System requires payment processing that coordinates multiple domains — **Payment**, **Point**, and **Reservation** — within a single business transaction. Specifically: | ||
|
|
||
| - **Processing a payment** involves: deducting user points, confirming a reservation, and recording the payment. | ||
| - **Processing a refund** involves: restoring user points, cancelling a reservation, and recording the refund. | ||
|
|
||
| These cross-domain operations introduce the following challenges: | ||
|
|
||
| 1. **Partial failure**: Any step in the multi-step flow can fail independently. Without proper handling, the system may end up in an inconsistent state (e.g., points deducted but reservation not confirmed). | ||
| 2. **Synchronous user expectation**: Payment is a user-facing operation where the user expects a deterministic result immediately upon request. The coordination mechanism must be able to return a definitive success or failure within the same request-response cycle. | ||
| 3. **Tight cross-domain coupling**: Prior to this change, domains called each other's services directly, creating hidden dependencies and making it difficult to reason about failure boundaries. | ||
| 4. **Transient failures under concurrency**: Point balance operations use optimistic locking, which is prone to transient conflicts during high-concurrency scenarios. | ||
|
|
||
| --- | ||
|
|
||
| ## Decision | ||
|
|
||
| ### 1. Saga/Compensation Pattern for Payment Orchestration | ||
|
|
||
| The `PaymentOrchestrator` coordinates multi-step payment and refund flows using the **orchestration-based saga pattern**. | ||
|
|
||
| **Payment Flow:** | ||
| 1. Create a `PENDING` payment record. | ||
| 2. Decrease user point balance via `PointModuleApi`. | ||
| 3. On failure at step 2: mark payment as `FAILED` and return (no compensation needed — no side effect has occurred). | ||
| 4. Confirm reservation via `ReservationModuleApi`. | ||
| 5. On success: mark payment as `COMPLETED`. | ||
| 6. On failure at step 4: execute compensating transaction (restore points), mark payment as `FAILED`. | ||
| 7. If compensation itself fails: persist a `CompensationTxLog` entry for deferred retry. | ||
|
|
||
| **Refund Flow:** | ||
| 1. Create a `PENDING` payment record. | ||
| 2. Increase user point balance via `PointModuleApi`. | ||
| 3. On failure at step 2: mark payment as `FAILED` and return (no compensation needed). | ||
| 4. Cancel reservation via `ReservationModuleApi`. | ||
| 5. On success: mark payment as `REFUNDED`. | ||
| 6. On failure at step 4: execute compensating transaction (deduct restored points), mark payment as `FAILED`. | ||
| 7. If compensation itself fails: persist a `CompensationTxLog` entry with negative price for deferred retry. | ||
|
|
||
| A `CompensationTxRetryScheduler` runs on a fixed 60-second interval, picking up all `PENDING` compensation logs and retrying them up to a maximum of 3 attempts. | ||
|
|
||
| ### 2. Module API Layer for Inter-Domain Communication | ||
|
|
||
| Each domain exposes a public contract through a **Module API** interface: | ||
|
|
||
| - `PointModuleApi` — declares `decreaseUserPointBalance()` and `increaseUserPointBalance()` | ||
| - `ReservationModuleApi` — declares `confirmReservation()` and `cancelReservation()` | ||
|
|
||
| Each interface is accompanied by: | ||
| - An **operation result record** (`PointOperationResult`, `ReservationOperationResult`) that wraps success/failure status and error codes. | ||
| - A **facade implementation** (`PointModuleFacade`, `ReservationModuleFacade`) that delegates to internal domain services and translates exceptions into result objects. | ||
|
|
||
| Callers (e.g., `PaymentOrchestrator`) depend **only on the interface**, not on concrete service classes. This enforces a clear boundary between domains and makes it straightforward to evolve each domain independently. | ||
|
|
||
| ### 3. Retry with Exponential Backoff (Point Domain) | ||
|
|
||
| `PointModuleFacade` applies Spring `@Retryable` to handle transient failures: | ||
|
|
||
| | Configuration | Value | | ||
| |---------------------|-----------------------------------------| | ||
| | Max attempts | 3 | | ||
| | Initial delay | 100 ms | | ||
| | Multiplier | 2.0 (100ms → 200ms → 400ms) | | ||
| | Excluded exceptions | `BusinessRuleViolationException`, `OptimisticLockingFailureException` | | ||
|
|
||
| Non-retryable exceptions (business rule violations, optimistic lock failures) are returned immediately as a failed result via the `@Recover` method, avoiding unnecessary retry on deterministic failures. | ||
|
|
||
| ### 4. Append-Only Payment Model | ||
|
|
||
| The `Payment` domain model follows an **append-only** design: | ||
|
|
||
| - No setters — state transitions produce a new instance via `withStatus()`. | ||
| - Each status change is persisted as a new record, preserving a complete audit trail. | ||
| - Statuses: `PENDING` → `COMPLETED` | `FAILED` | `REFUNDED`. | ||
|
|
||
| --- | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Choreography-Based Saga (Event-Driven) | ||
| - Each domain publishes events and reacts to events from other domains. No central coordinator — domains are fully decoupled. | ||
| - Rejected because payment processing requires a **synchronous, deterministic response** to the user. In a choreography saga, the outcome is resolved asynchronously through event propagation, making it impossible to return a definitive success or failure within the same request-response cycle. The orchestration approach keeps the entire flow within a single synchronous call, giving the user immediate feedback on their payment or refund request. | ||
|
|
||
| ### Direct Service-to-Service Calls (No API Layer) | ||
| - Simpler to implement — just inject and call the target domain's service directly. | ||
| - Rejected because it creates hidden cross-domain dependencies, makes failure handling inconsistent, and complicates future migration to a distributed architecture (e.g., microservices). | ||
|
|
||
| ### Fail-Fast Without Retry | ||
| - Simpler to reason about — every failure is immediately surfaced to the caller. | ||
| - Rejected for the Point domain because optimistic lock conflicts under concurrent reservations are **expected** transient failures. A brief exponential backoff significantly improves success rates without adding meaningful latency. | ||
|
|
||
| --- | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
|
|
||
| - **Clear domain boundaries**: Module API interfaces serve as explicit contracts. Cross-domain dependencies are visible and auditable in the interface definitions. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use non-redundant wording for API contracts. Line 107 can be tightened: “Module APIs serve as explicit contracts” (instead of “Module API interfaces…”). 🧰 Tools🪛 LanguageTool[style] ~107-~107: This phrase is redundant (‘I’ stands for ‘interfaces’). Use simply “APIs”. (ACRONYM_TAUTOLOGY) 🤖 Prompt for AI Agents |
||
| - **Fault-tolerant payment flows**: The compensation pattern ensures the system recovers from partial failures automatically, with scheduled retry as a safety net. | ||
| - **Audit trail**: Append-only payment records provide a complete history of state transitions for debugging and compliance. | ||
| - **Retry resilience**: Point operations gracefully handle transient concurrency conflicts without propagating failures to the user. | ||
|
|
||
| ### Negative / Trade-offs | ||
|
|
||
| - **Eventual consistency**: When compensation is deferred to `CompensationTxLog`, the system is temporarily inconsistent (up to 60 seconds per retry cycle, maximum 3 attempts). Monitoring and alerting on failed compensation logs is operationally required. | ||
| - **Operational overhead**: The `CompensationTxRetryScheduler` and `CompensationTxLog` table require monitoring to detect cases where all retry attempts are exhausted (manual intervention needed). | ||
| - **Asymmetric retry**: Only `PointModuleFacade` implements retry logic. `ReservationModuleFacade` uses simple exception handling — a single transient failure at the reservation step will trigger the compensation path rather than retrying in-place. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| package com.slam.concertreservation.domain.payment.application; | ||
|
|
||
| import com.slam.concertreservation.domain.payment.model.CompensationTxLog; | ||
| import com.slam.concertreservation.domain.payment.service.CompensationTxLogService; | ||
| import com.slam.concertreservation.domain.point.api.PointModuleApi; | ||
| import com.slam.concertreservation.domain.point.api.PointOperationResult; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.scheduling.annotation.Scheduled; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class CompensationTxRetryScheduler { | ||
|
|
||
| private final CompensationTxLogService compensationTxLogService; | ||
| private final PointModuleApi pointModuleApi; | ||
|
|
||
| @Scheduled(fixedDelay = 60000) // 1분 간격 | ||
| public void retryFailedCompensations() { | ||
| List<CompensationTxLog> targets = compensationTxLogService.getAllRetriables(); | ||
|
|
||
| for (CompensationTxLog log : targets) { | ||
| PointOperationResult result; | ||
|
|
||
| // 양수: 포인트 복구(증가), 음수: 포인트 회수(차감) | ||
| if (log.getPrice() > 0) { | ||
| result = pointModuleApi.increaseUserPointBalance(log.getUserId(), log.getPrice()); | ||
| } else { | ||
| result = pointModuleApi.decreaseUserPointBalance(log.getUserId(), Math.abs(log.getPrice())); | ||
| } | ||
|
Comment on lines
+27
to
+31
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Edge case: If 🤖 Prompt for AI Agents |
||
|
|
||
| if (result.success()) { | ||
| compensationTxLogService.markAsCompleted(log); | ||
| } else { | ||
| compensationTxLogService.markAsFailed(log); | ||
| } | ||
| } | ||
|
Comment on lines
+19
to
+38
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check the CompensationTxLogService implementation
echo "=== Finding CompensationTxLogService ==="
find . -type f -name "CompensationTxLogService.java" | head -5
echo "=== Searching for getAllRetriables method ==="
rg -n "getAllRetriables" --type=java -B2 -A10
echo "=== Searching for markAsFailed method ==="
rg -n "markAsFailed" --type=java -B2 -A10
echo "=== Searching for findAllByStatus usage in service ==="
rg -n "findAllByStatus" --type=java -B2 -A3Repository: leonroars/slam Length of output: 39115 🏁 Script executed: #!/bin/bash
# Check the full CompensationTxRetryScheduler implementation
echo "=== Full CompensationTxRetryScheduler.java ==="
cat -n src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java
echo ""
echo "=== Check if there are other scheduled methods or retry mechanisms ==="
rg -n "@Scheduled|retryCount|FAILED" src/main/java/com/slam/concertreservation/domain/payment --type=java -B1 -A1 | head -40Repository: leonroars/slam Length of output: 5690 Compensation logs get only one retry —
🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| package com.slam.concertreservation.domain.payment.application; | ||
|
|
||
| import com.slam.concertreservation.domain.payment.service.CompensationTxLogService; | ||
| import com.slam.concertreservation.domain.point.api.PointOperationResult; | ||
| import com.slam.concertreservation.domain.reservation.api.ReservationOperationResult; | ||
| import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion | 🟠 Major Presentation-layer DTO (
The orchestrator should return a domain object (e.g., Proposed approach-import com.slam.concertreservation.interfaces.dto.PaymentProcessResponse;
...
- public PaymentProcessResponse processPayment(Long userId, int price, Long reservationId) {
+ public Payment processPayment(Long userId, int price, Long reservationId) {
Payment initiatedPayment = paymentService.initiate(userId, price, reservationId);
PointOperationResult deduction = pointModuleApi.decreaseUserPointBalance(userId, price);
if (!deduction.success()) {
- return PaymentProcessResponse.from(paymentService.fail(initiatedPayment));
+ return paymentService.fail(initiatedPayment);
}
// ... similar changes for other return statementsThen in the controller: - PaymentProcessResponse response = paymentOrchestrator.processPayment(userId, price, reservationId);
- return ResponseEntity.ok(response);
+ Payment payment = paymentOrchestrator.processPayment(userId, price, reservationId);
+ return ResponseEntity.ok(PaymentProcessResponse.from(payment));Also applies to: 26-50 🤖 Prompt for AI Agents |
||
| import com.slam.concertreservation.domain.payment.model.Payment; | ||
| import com.slam.concertreservation.domain.payment.service.PaymentService; | ||
| import com.slam.concertreservation.domain.point.api.PointModuleApi; | ||
| import com.slam.concertreservation.domain.reservation.api.ReservationModuleApi; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class PaymentOrchestrator { | ||
|
|
||
| private final PaymentService paymentService; | ||
|
|
||
| /** 외부 모듈 인터페이스 **/ | ||
| private final ReservationModuleApi reservationModuleApi; | ||
| private final PointModuleApi pointModuleApi; | ||
|
|
||
| private final CompensationTxLogService compensationTxLogService; | ||
|
|
||
| public PaymentProcessResponse processPayment(Long userId, int price, Long reservationId) { | ||
| // 1. PENDING 상태의 Payment 생성 | ||
| Payment initiatedPayment = paymentService.initiate(userId, price, reservationId); | ||
|
|
||
| // 2. 포인트 차감 | ||
| PointOperationResult deduction = pointModuleApi.decreaseUserPointBalance(userId, price); | ||
| if (!deduction.success()) { | ||
| return PaymentProcessResponse.from(paymentService.fail(initiatedPayment)); | ||
| } | ||
|
|
||
| // 3. 예약 확정 | ||
| ReservationOperationResult confirmation = reservationModuleApi.confirmReservation(reservationId); | ||
| if (!confirmation.success()) { | ||
| compensatePointDeduction( | ||
| initiatedPayment.getUserId(), | ||
| initiatedPayment.getReservationId(), | ||
| initiatedPayment.getPaymentId(), | ||
| initiatedPayment.getPrice()); | ||
|
|
||
| return PaymentProcessResponse.from(paymentService.fail(initiatedPayment)); | ||
| } | ||
|
|
||
| // 4. 결제 확정 | ||
| return PaymentProcessResponse.from(paymentService.complete(initiatedPayment)); | ||
|
Comment on lines
+48
to
+49
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No error handling if the final If Consider wrapping the final persistence step in a try-catch that either retries or logs a compensation entry so the payment record can be reconciled. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| public PaymentProcessResponse processRefund(Long userId, int price, Long reservationId) { | ||
| // 1. PENDING 상태의 Payment 생성 | ||
| Payment initiatedRefund = paymentService.initiate(userId, price, reservationId); | ||
|
|
||
| // 2. 포인트 원상 복구 | ||
| PointOperationResult restoration = pointModuleApi.increaseUserPointBalance(userId, price); | ||
| if (!restoration.success()) { | ||
| return PaymentProcessResponse.from(paymentService.fail(initiatedRefund)); | ||
| } | ||
|
|
||
| // 3. 예약 취소 | ||
| ReservationOperationResult cancellation = reservationModuleApi.cancelReservation(reservationId); | ||
| if (!cancellation.success()) { | ||
| compensatePointIncrease( | ||
| initiatedRefund.getUserId(), | ||
| initiatedRefund.getReservationId(), | ||
| initiatedRefund.getPaymentId(), | ||
| initiatedRefund.getPrice()); | ||
|
|
||
| return PaymentProcessResponse.from(paymentService.fail(initiatedRefund)); | ||
| } | ||
|
|
||
| // 4. 환불 확정 | ||
| return PaymentProcessResponse.from(paymentService.refund(initiatedRefund)); | ||
| } | ||
|
Comment on lines
+52
to
+76
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Refund flow doesn't validate current reservation/payment state before proceeding.
Consider fetching and validating the existing payment/reservation state before initiating the refund. 🤖 Prompt for AI Agents |
||
|
|
||
| private void compensatePointDeduction(Long userId, Long reservationId, Long paymentId, int price) { | ||
| PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, price); | ||
| if (!result.success()) { | ||
| compensationTxLogService.log(userId, reservationId, paymentId, price); | ||
| } | ||
| } | ||
|
|
||
| private void compensatePointIncrease(Long userId, Long reservationId, Long paymentId, int price) { | ||
| PointOperationResult result = pointModuleApi.decreaseUserPointBalance(userId, price); | ||
| if (!result.success()) { | ||
| compensationTxLogService.log(userId, reservationId, paymentId, -price); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package com.slam.concertreservation.domain.payment.model; | ||
|
|
||
| import com.slam.concertreservation.common.error.ErrorCode; | ||
| import com.slam.concertreservation.common.exceptions.BusinessRuleViolationException; | ||
| import io.hypersistence.tsid.TSID; | ||
| import java.time.LocalDateTime; | ||
| import lombok.Getter; | ||
|
|
||
| /** | ||
| * 보상 트랜잭션 로그 모델 | ||
| */ | ||
| @Getter | ||
| public class CompensationTxLog { | ||
|
|
||
| public static final int MAX_RETRY_COUNT = 3; | ||
|
|
||
| private Long txLogId; | ||
| private Long paymentId; | ||
| private Long userId; | ||
| private Long reservationId; | ||
| private int price; | ||
| private CompensationTxStatus status; | ||
| private int retryCount; | ||
| private LocalDateTime createdAt; | ||
|
|
||
| private CompensationTxLog(){} | ||
| private CompensationTxLog( | ||
| Long txLogId, | ||
| Long paymentId, | ||
| Long userId, | ||
| Long reservationId, | ||
| int price, | ||
| CompensationTxStatus status, | ||
| int retryCount, | ||
| LocalDateTime createdAt) { | ||
| this.txLogId = txLogId; | ||
| this.paymentId = paymentId; | ||
| this.userId = userId; | ||
| this.reservationId = reservationId; | ||
| this.price = price; | ||
| this.status = status; | ||
| this.retryCount = retryCount; | ||
| this.createdAt = createdAt; | ||
| } | ||
|
|
||
|
|
||
| public static CompensationTxLog create(Long userId, Long reservationId, Long paymentId, int price){ | ||
| CompensationTxLog txLog = new CompensationTxLog(); | ||
| txLog.txLogId = TSID.fast().toLong(); | ||
| txLog.userId = userId; | ||
| txLog.reservationId = reservationId; | ||
| txLog.paymentId = paymentId; | ||
| txLog.price = price; | ||
| txLog.status = CompensationTxStatus.PENDING; | ||
| txLog.retryCount = 0; | ||
| txLog.createdAt = LocalDateTime.now(); | ||
| return txLog; | ||
| } | ||
|
|
||
| public static CompensationTxLog restore( | ||
| Long txLogId, | ||
| Long paymentId, | ||
| Long userId, | ||
| Long reservationId, | ||
| int price, | ||
| CompensationTxStatus status, | ||
| int retryCount, | ||
| LocalDateTime createdAt) { | ||
| return new CompensationTxLog( | ||
| txLogId, | ||
| paymentId, | ||
| userId, | ||
| reservationId, | ||
| price, | ||
| status, | ||
| retryCount, | ||
| createdAt | ||
| ); | ||
| } | ||
|
|
||
| public CompensationTxLog markAsCompleted() { | ||
| this.status = CompensationTxStatus.COMPLETED; | ||
| return this; | ||
| } | ||
|
|
||
| public CompensationTxLog markAsFailed() { | ||
| if(this.status == CompensationTxStatus.COMPLETED) { | ||
| throw new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "이미 완료 처리된 보상 트랜잭션입니다."); | ||
| } | ||
| this.status = CompensationTxStatus.FAILED; | ||
| this.incrementRetryCount(); | ||
| return this; | ||
| } | ||
|
|
||
| private void incrementRetryCount() {++this.retryCount;} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.slam.concertreservation.domain.payment.model; | ||
|
|
||
| public enum CompensationTxStatus { | ||
| COMPLETED, FAILED, PENDING | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Retry policy is internally contradictory for optimistic locking failures.
Line 73 excludes
OptimisticLockingFailureExceptionfrom retries, but Line 99 and Line 110 describe optimistic lock conflicts as transient and improved by retry. Please align these sections so the ADR reflects one consistent policy.Proposed ADR text adjustment
If optimistic locking is intentionally excluded, then Line 99 and Line 110 should be rewritten accordingly.
Also applies to: 99-100, 110-110
🤖 Prompt for AI Agents