Skip to content

feat: Introducing payment domain with orchestration-saga for synchronous payment/refund flow#41

Merged
leonroars merged 7 commits into
mainfrom
refactor_payment_reservation
Apr 3, 2026
Merged

feat: Introducing payment domain with orchestration-saga for synchronous payment/refund flow#41
leonroars merged 7 commits into
mainfrom
refactor_payment_reservation

Conversation

@leonroars
Copy link
Copy Markdown
Owner

@leonroars leonroars commented Feb 12, 2026

한국어 (Korean)

PR Summary

본 PR은 SLAM(공연 좌석 예매 시스템) 의 핵심 비즈니스 로직인 결제 및 환불 프로세스 구현을 다룹니다.

결제는 포인트 차감 → 예약 확정이 하나의 흐름으로 이루어지지만, 각 도메인이 독립 트랜잭션을 가지므로 중간 단계 실패 시 데이터 불일치가 발생할 수 있습니다. 이 문제를 다음과 같이 해결했습니다.

  • Orchestration Saga: 각 도메인의 독립 트랜잭션을 순차 실행하고, 실패 시 보상 트랜잭션으로 롤백. 보상마저 실패하면 Persistent Log에 기록하여 스케줄러가 재시도.
  • 동기 처리: 결제 성공/실패를 사용자에게 즉시 응답해야 하므로, 비동기 이벤트 방식 대신 동기 오케스트레이션을 채택.
  • Semantic Lock: 결제 진행 중 예약 만료 스케줄러와의 경합을 방지하기 위해 PAYMENT_PENDING 중간 상태 도입.
  • Interface 기반 모듈 간 통신: 도메인 간 의존을 공개 인터페이스로 제한하여, MSA 전환 시 구현체 교체만으로 대응 가능.

Key Architectural Decisions

1. Modular Monolith 내 도메인 격리를 위한 트랜잭션 전략

  • 문제: 오케스트레이터(PaymentOrchestrator)에 @Transactional을 적용할 경우, 서로 다른 도메인의 DB 트랜잭션이 하나로 묶여 강한 결합이 발생하고, 향후 물리적 분리가 불가능해지는 문제가 있습니다.
  • 결정:
    • No Global Transaction: 오케스트레이터 레벨에서는 트랜잭션을 관리하지 않습니다.
    • Independent Transactions: 각 도메인 서비스(PointService, ReservationService)가 독자적인 트랜잭션 경계를 가집니다.
    • 실패 시 명시적인 보상 트랜잭션(Compensation Transaction) 을 호출하여 원자성을 보장합니다.

2. Synchronous Orchestration (동기식 오케스트레이션) 채택

  • 배경: 결제는 사용자가 즉각적인 성공/실패 피드백을 필요로 하는 도메인입니다.
  • 결정: 비동기 이벤트 기반(Choreography) 방식 대신, 오케스트레이터가 순차적으로 타 도메인을 호출하고 결과를 즉시 반환하는 동기식 방식을 채택하여 UX 요구사항을 충족시켰습니다.

3. 동시성 제어를 위한 Semantic Lock (의미론적 락)

  • 문제: 결제 진행 중 '예약 만료 스케줄러'가 동시에 실행되어, 결제 중인 좌석을 만료(EXPIRED) 처리하는 Race Condition 가능성이 식별되었습니다.
  • 결정: Reservation 도메인에 PAYMENT_PENDING 상태를 도입했습니다. 결제 진입 시 상태를 변경하여 스케줄러가 해당 건을 건너뛰도록 처리함으로써, 별도의 락(Lock) 없이도 데이터 정합성을 확보했습니다.

4. Public API & Facade를 통한 경계 강화

  • 구조: 각 모듈은 api 패키지의 인터페이스와 DTO만을 외부에 노출합니다.
  • 구현: Facade 레이어를 통해 도메인 내부 구현과 예외(Exception)가 외부로 유출되지 않도록 차단했습니다. 이는 모듈 간 참조를 인터페이스에만 의존하게 하여, 추후 물리적 서버 분리 시 코드 수정 비용을 최소화합니다.

Reliability & Consistency (안정성 확보)

실패에 대한 보상 처리의 신뢰성 (Resilience)

  • 보상 트랜잭션(예: 결제 실패 후 포인트 복구) 자체가 실패하는 상황을 대비해 Persistent Log & Scheduler 전략을 도입했습니다.
  • 실패한 보상 내역과 금액을 DB 로그(compensation_tx_log)에 기록하고, 별도 스케줄러가 이를 재시도하여 결과적 일관성(Eventual Consistency) 을 보장합니다.

트랜잭션과 재시도(Retry)의 분리

  • Spring @Retryable을 트랜잭션이 시작되는 Service 레이어가 아닌, Facade 레이어에 배치했습니다.
  • 트랜잭션 내부 예외 발생 시 해당 트랜잭션은 재사용이 불가능(rollback-only)하므로, Facade에서 재시도 시마다 새로운 트랜잭션을 생성하도록 유도하여 복구 성공률을 높였습니다.

Related Documents

  • ADR: Cross-Domain Payment Orchestration Strategy (첨부된 상세 설계 문서 참고)
English

PR Summary

This PR implements the core payment and refund process for SLAM (Concert Seat Reservation System).

The payment flow involves point deduction → reservation confirmation as a single sequence, but since each domain maintains independent transactions, data inconsistency can occur when an intermediate step fails. This was addressed with the following design decisions:

  • Orchestration Saga: Domain transactions are executed sequentially, with compensating transactions for rollback on failure. If compensation itself fails, it is persisted to a log and retried by a background scheduler.
  • Synchronous processing: Since users need immediate success/failure feedback for payments, synchronous orchestration was chosen over asynchronous event-driven processing.
  • Semantic Lock: A PAYMENT_PENDING intermediate status prevents race conditions between in-progress payments and the reservation expiry scheduler.
  • Interface-based inter-module communication: Cross-domain dependencies are restricted to public interfaces, enabling MSA migration by swapping implementations without modifying business logic.

Key Architectural Decisions

1. Transaction Strategy for Domain Isolation

  • Challenge: Applying @Transactional at the Orchestrator level would merge database transactions from different domains, creating tight coupling and hindering future physical separation.
  • Decision:
    • No Global Transaction: The PaymentOrchestrator does not manage transactions.
    • Independent Transactions: Each domain service (PointService, ReservationService) maintains its own transaction boundary.
    • Atomicity is ensured via explicit Compensation Transactions in case of failure.

2. Synchronous Orchestration

  • Context: Payment flows require immediate success/failure feedback for the user.
  • Decision: Instead of an asynchronous event-driven (Choreography) approach, a synchronous orchestration pattern was adopted. The orchestrator sequentially invokes domain services and returns the result immediately to satisfy UX requirements.

3. Semantic Lock for Concurrency Control

  • Problem: A race condition was identified where the "Reservation Expiry Scheduler" could expire a seat while a payment was in progress.
  • Decision: Introduced a PAYMENT_PENDING state in the Reservation domain acting as a Semantic Lock. Upon payment entry, the reservation status is updated, ensuring the scheduler skips these records. This secures data consistency without the need for distributed locks.

4. Boundary Enforcement via Public API & Facade

  • Structure: Each module exposes only Interfaces and DTOs located in its api package.
  • Implementation: The Facade layer prevents internal implementation details and domain-specific exceptions from leaking out. By relying solely on interfaces for inter-module communication, the cost of future physical separation is minimized.

Reliability & Consistency

Resilience in Compensation Logic

  • To handle scenarios where compensation transactions (e.g., restoring points after a failure) themselves fail, a Persistent Log & Scheduler strategy was introduced.
  • Failed compensations are recorded in a DB log (compensation_tx_log), and a separate scheduler retries them to ensure Eventual Consistency.

Separation of Retry and Transaction

  • Spring @Retryable is applied at the Facade layer, not the Service layer where transactions begin.
  • Since a transaction marked as rollback-only cannot be reused, placing retries at the Facade ensures that each retry attempt initiates a fresh transaction, improving recovery success rates.

Related Documents

  • ADR: Cross-Domain Payment Orchestration Strategy (Please refer to the attached document for detailed design rationale)

Summary by CodeRabbit

  • New Features
    • Added end-to-end payment and refund endpoints with payment status responses and paginated user payment history.
  • Reliability Improvements
    • Automatic compensation and scheduled retry for failed point adjustments to improve payment/refund reliability.
  • Documentation
    • Added architecture decision record describing the payment orchestration, compensation, and retry behavior.
  • Tests
    • Added comprehensive unit and integration tests covering payment flows and retry behavior.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Feb 12, 2026

📝 Walkthrough

Walkthrough

Adds a payment orchestration flow with compensation logging and retrying, module APIs for point and reservation domains, JPA persistence and repository implementations, controller endpoints and DTOs, plus unit and integration tests; enables Spring Retry at application and test levels.

Changes

Cohort / File(s) Summary
Application Retry Enablement
src/main/java/com/slam/concertreservation/Hhp7ConcertReservationApplication.java, src/test/java/com/slam/concertreservation/.../Hhp7ConcertReservationApplicationTests.java, src/test/java/com/slam/concertreservation/TestHhp7ConcertReservationApplication.java
Added @EnableRetry import/annotation to enable Spring Retry in application and test launcher.
Orchestrator & Scheduler
src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java, src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java
New PaymentOrchestrator coordinating payment/refund flows and compensation logging; scheduled CompensationTxRetryScheduler retries pending compensation logs every minute.
Domain Models & Enums
src/main/java/com/slam/concertreservation/domain/payment/model/...
Added domain models: Payment, CompensationTxLog (with lifecycle methods), and enums PaymentStatus, CompensationTxStatus.
Repositories (Domain + JPA)
src/main/java/com/slam/concertreservation/domain/payment/repository/PaymentRepository.java, .../CompensationTxLogRepository.java, src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/*Repository.java
Defined repository interfaces and Spring Data JPA repository contracts with query methods for status, retry count, user/reservation filters, and time ranges.
JPA Entities & Impl
src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java, .../CompensationTxLogJpaEntity.java, src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java, .../CompensationTxLogRepositoryImpl.java
Added JPA entities with toDomain/fromDomain conversion and repository implementation classes that map between domain and JPA entities.
Services
src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java, .../CompensationTxLogService.java
PaymentService manages payment lifecycle (initiate/complete/fail/refund) and queries; CompensationTxLogService manages compensation log creation and status transitions and exposes retriable queries.
Point Module API & Facade
src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java, .../PointOperationResult.java, src/main/java/com/slam/concertreservation/domain/point/application/PointModuleFacade.java, src/main/java/com/slam/concertreservation/domain/point/service/PointService.java
Defined PointModuleApi and PointOperationResult; implemented PointModuleFacade with @Retryable (3 attempts, exponential backoff) and @Recover handling; minor imports update in PointService.
Reservation Module API & Facade
src/main/java/com/slam/concertreservation/domain/reservation/api/..., src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java
Added ReservationModuleApi, ReservationOperationResult and facade that wraps ReservationService calls into result records.
Controller & DTOs
src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java, src/main/java/com/slam/concertreservation/interfaces/dto/PaymentProcessResponse.java, .../ReservationConfirmResponse.java, .../ReservationCancelResponse.java
Added payment endpoints (process, refund, fetch, list) and DTOs with static factory mapping from domain models.
Tests
src/test/java/com/slam/concertreservation/domain/... (many test files)
Added unit tests for PaymentOrchestrator, PaymentService, CompensationTxLogService, CompensationTxRetryScheduler; integration tests for PointModuleFacade retry behavior and facade unit tests for reservation facade.
Docs
docs/detailed_docs/en/adr-payment-saga-module-api.md
Added ADR documenting orchestration-based saga, compensation logging, retry behavior, and module API contracts.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Orchestrator as PaymentOrchestrator
    participant PaymentSvc as PaymentService
    participant PointApi as PointModuleApi
    participant ReservationApi as ReservationModuleApi
    participant CompLogSvc as CompensationTxLogService

    Client->>Orchestrator: processPayment(userId, price, reservationId)
    Orchestrator->>PaymentSvc: initiate(userId, price, reservationId)
    PaymentSvc-->>Orchestrator: Payment(PENDING)
    Orchestrator->>PointApi: decreaseUserPointBalance(userId, price)
    alt point deduction fails
        PointApi-->>Orchestrator: PointOperationResult(failed)
        Orchestrator->>PaymentSvc: fail(payment)
        PaymentSvc-->>Orchestrator: Payment(FAILED)
        Orchestrator-->>Client: PaymentProcessResponse(FAILED)
    else point deduction succeeds
        PointApi-->>Orchestrator: PointOperationResult(success)
        Orchestrator->>ReservationApi: confirmReservation(reservationId)
        alt reservation confirmation fails
            ReservationApi-->>Orchestrator: ReservationOperationResult(failed)
            Orchestrator->>PointApi: increaseUserPointBalance(userId, price)
            alt compensation succeeds
                PointApi-->>Orchestrator: PointOperationResult(success)
            else compensation fails
                PointApi-->>Orchestrator: PointOperationResult(failed)
                Orchestrator->>CompLogSvc: log(userId,reservationId,paymentId,price)
            end
            Orchestrator->>PaymentSvc: fail(payment)
            PaymentSvc-->>Orchestrator: Payment(FAILED)
            Orchestrator-->>Client: PaymentProcessResponse(FAILED)
        else reservation confirmation succeeds
            ReservationApi-->>Orchestrator: ReservationOperationResult(success)
            Orchestrator->>PaymentSvc: complete(payment)
            PaymentSvc-->>Orchestrator: Payment(COMPLETED)
            Orchestrator-->>Client: PaymentProcessResponse(COMPLETED)
        end
    end
Loading
sequenceDiagram
    participant Scheduler as CompensationTxRetryScheduler
    participant CompLogSvc as CompensationTxLogService
    participant PointApi as PointModuleApi

    Scheduler->>Scheduler: wait (fixed delay 1m)
    Scheduler->>CompLogSvc: getAllRetriables()
    CompLogSvc-->>Scheduler: List<CompensationTxLog>
    loop each pending log
        alt price > 0
            Scheduler->>PointApi: increaseUserPointBalance(userId, price)
        else price < 0
            Scheduler->>PointApi: decreaseUserPointBalance(userId, abs(price))
        end
        alt operation succeeds
            PointApi-->>Scheduler: PointOperationResult(success)
            Scheduler->>CompLogSvc: markAsCompleted(log)
        else operation fails
            PointApi-->>Scheduler: PointOperationResult(failed)
            Scheduler->>CompLogSvc: markAsFailed(log)
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Poem

🐇 I hopped in with a retrying heart,
Logs and compensations play their part.
Points rebound when steps go wrong,
Scheduler hums a patient song—
Payments settle, the system hops along.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 3.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title clearly and specifically describes the main change: introducing a payment domain with an orchestration-saga pattern for synchronous payment/refund flows, which aligns directly with the extensive changes across multiple payment-related files and the comprehensive ADR documentation.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor_payment_reservation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/java/com/slam/concertreservation/domain/point/service/PointService.java (1)

54-57: ⚠️ Potential issue | 🟡 Minor

Stray semicolon on line 57.

There's an extra semicolon after the orElseThrow(...) statement closing parenthesis.

Proposed fix
         UserPointBalance userPointBalance = userPointBalanceRepository.getBalanceByUserId(userId)
                 .orElseThrow(() -> new UnavailableRequestException(ErrorCode.USER_NOT_FOUND,
                         "해당 회원이 존재하지 않으므로 잔액 조회가 불가합니다."));
-        ;
🤖 Fix all issues with AI agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java`:
- Around line 27-31: In CompensationTxRetryScheduler, guard the price==0 case so
it doesn't fall through to decreaseUserPointBalance with 0: update the method
that currently branches on log.getPrice() (using log.getPrice(),
pointModuleApi.increaseUserPointBalance and
pointModuleApi.decreaseUserPointBalance) to explicitly check for price == 0
first, log a warning or error mentioning the unexpected zero-price compensation
(include identifying info like log.getUserId()/log id), and skip calling the
pointModuleApi in that case; keep the existing increase/decrease branches for
positive/negative prices unchanged.
- Around line 19-38: retryFailedCompensations() only reprocesses PENDING because
getAllRetriables() excludes FAILED and markAsFailed() flips status to FAILED, so
logs get only one retry; change the flow so retries continue until a max: either
(A) modify compensationTxLogService.getAllRetriables() to return entries with
status PENDING or FAILED whose retryCount < maxRetries (configurable) and leave
markAsFailed() to only increment retryCount and optionally set status=FAILED
only when retryCount >= maxRetries, or (B) keep getAllRetriables() returning
only PENDING but change markAsFailed(CompensationTxLog) to increment and persist
a retryCount and keep status=PENDING until retryCount exceeds maxRetries then
set FAILED; update CompensationTxLog to track retryCount and ensure retry cap is
enforced before calling markAsCompleted()/final failure.

In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`:
- Around line 52-76: Before creating a PENDING refund in processRefund, fetch
and validate the current reservation and payment state: call
reservationModuleApi.getReservation(reservationId) (or equivalent) and verify
it's in a refundable state (e.g., CONFIRMED) and fetch the existing payment via
paymentService.findByReservationId(reservationId) to ensure a completed charge
exists; if validation fails, return a failure response without calling
paymentService.initiate, pointModuleApi.increaseUserPointBalance,
reservationModuleApi.cancelReservation, or paymentService.refund. Keep existing
compensation logic (compensatePointIncrease and paymentService.fail) for
downstream failures but only after the initial pre-checks pass.
- Around line 48-49: Wrap the final persistence calls
paymentService.complete(initiatedPayment) and
paymentService.refund(initiatedPayment) in a try-catch inside
PaymentOrchestrator so a thrown exception doesn't leave the system with deducted
points and a PENDING payment; on catch, perform a compensating action such as
retrying the persistence (with a limited retry loop/backoff) or
creating/flagging a reconciliation/compensation record (e.g., via a new
paymentRepository.createCompensationEntry or
paymentService.markForReconciliation) and log the full error and context, then
return an appropriate failure response instead of letting the exception escape;
ensure PaymentProcessResponse.from is only called after the persistence
succeeds.
- Line 6: PaymentOrchestrator currently imports and returns the presentation DTO
PaymentProcessResponse, leaking presentation concerns into the
domain/application layer; change the orchestrator to return a domain-level
Payment (or other domain result object) instead of PaymentProcessResponse,
remove the import of
com.slam.concertreservation.interfaces.dto.PaymentProcessResponse from
PaymentOrchestrator, update the orchestrator method signature(s) (e.g.,
processPayment/orchestratePayment) and any internal returns to produce the
domain Payment, and move the mapping to PaymentProcessResponse into the
controller layer so controllers call PaymentOrchestrator to get the domain
Payment and translate it to the DTO for the API response.

In `@src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java`:
- Around line 96-98: The withStatus method currently copies the existing
paymentId and createdAt which violates the append-only design; change
Payment.withStatus to create a new TSID paymentId and set createdAt to now (or
otherwise generate a new creation timestamp) so calling withStatus produces a
new entity/row instead of triggering a JPA merge — update the Payment.withStatus
implementation to return new Payment(newPaymentId, this.userId,
this.reservationId, this.price, paymentStatus, newCreatedAt) and keep the
existing Javadoc about append-only behavior.

In
`@src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java`:
- Around line 13-15: Add transactional boundaries to state-mutating methods in
CompensationTxLogService: annotate the methods log(), markAsFailed(), and
markAsCompleted() with `@Transactional` so their updates around
compensationTxLogRepository.save(...) execute atomically; ensure imports/use of
org.springframework.transaction.annotation.Transactional and keep the
class-level annotations (Service, RequiredArgsConstructor) unchanged.
- Around line 34-36: getAllRetriables() currently returns all CompensationTxLog
entries with CompensationTxStatus.PENDING, which allows logs with retryCount >=
MAX_RETRY_COUNT to be retried indefinitely; update getAllRetriables() to only
return logs whose retryCount is less than MAX_RETRY_COUNT (e.g., call a
repository method like
findAllByStatusAndRetryCountLessThan(CompensationTxStatus.PENDING,
MAX_RETRY_COUNT) or filter the result list by log.getRetryCount() <
MAX_RETRY_COUNT) so the scheduler and retry logic never pick up logs that have
reached the retry limit.

In
`@src/main/java/com/slam/concertreservation/domain/point/api/PointModuleApi.java`:
- Line 3: Remove the unused import of
com.slam.concertreservation.interfaces.dto.UserPointBalanceResponse from
PointModuleApi to avoid inverting the dependency direction; update the
PointModuleApi interface (class/interface name: PointModuleApi) so it does not
reference types from interfaces.dto — either remove any usages of
UserPointBalanceResponse in the interface methods or replace them with
domain-level types or simple primitives/value objects defined in the domain
layer and perform mapping at the boundary (controller/adapter) instead.

In
`@src/main/java/com/slam/concertreservation/domain/reservation/api/ReservationModuleApi.java`:
- Around line 3-4: Remove the unused imports ReservationCancelResponse and
ReservationConfirmResponse from ReservationModuleApi and eliminate the
dependency from the domain layer to the interfaces.dto package; instead define
domain-level response types or use existing domain value objects inside
ReservationModuleApi (or return primitives/entities) and perform mapping to
interfaces.dto in the interfaces/adapters layer (i.e., move any references to
ReservationCancelResponse/ReservationConfirmResponse out of ReservationModuleApi
and into the controller/mapper).

In
`@src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java`:
- Around line 25-27: In ReservationModuleFacade update the two catch blocks that
call ReservationOperationResult.fail(reservationId, e.getMessage()) to guard
against a null message by passing a non-null fallback (e.g., use e.getMessage()
if not null otherwise e.getClass().getSimpleName()); ensure both occurrences are
changed so ReservationOperationResult.fail always receives a non-null string.

In
`@src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java`:
- Around line 9-10: Add an explicit `@Table` annotation to the PaymentJpaEntity
class to specify the intended DB table name (consistent with other entities like
ReservationJpaEntity/UserJpaEntity) and import jakarta.persistence.Table; update
the class declaration for PaymentJpaEntity to include `@Table`(name = "payment")
(or the project’s canonical table name) so Hibernate doesn't default to
payment_jpa_entity.

In
`@src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java`:
- Around line 236-244: The processRefund handler in ConcertReservationController
accepts a path variable paymentId but never uses it; change processRefund to
fetch the original Payment by paymentId (via your
PaymentRepository/PaymentService) and derive userId, price, and reservationId
from that entity before calling
paymentOrchestrator.processRefund(payment.getUserId(), payment.getPrice(),
payment.getReservationId()); alternatively, if you must accept request params,
validate they match the fetched Payment and return 400 on mismatch—update the
method signature and logic in processRefund accordingly and remove or reconcile
the unused request params to ensure the URL paymentId actually controls which
payment is refunded.

In
`@src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java`:
- Around line 21-32: The static factory method ReservationConfirmResponse.from
is missing the mapping for seatNumber (so seatNumber stays default 0); update
the builder call in ReservationConfirmResponse.from to set
.seatNumber(reservation.getSeatNumber()) (or convert appropriately if types
differ) alongside the other fields—mirror the fix already used in
ReservationCancelResponse to ensure the DTO carries the actual seat number from
the Reservation entity.

In
`@src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java`:
- Around line 124-136: The test shouldNotRetry_WhenBusinessException currently
has no assertions; update it to assert the operation failed and that the service
was called only once (no retries). After invoking
pointModuleApi.increaseUserPointBalance(userId, amount), assert that the
returned PointOperationResult indicates failure (e.g., isSuccess() is false) and
its error code equals ErrorCode.INVALID_REQUEST, and add Mockito verification
verify(pointService, times(1)).increaseUserPointBalance(userId, amount) to
ensure no retry occurred; use the existing symbols PointOperationResult,
BusinessRuleViolationException, pointService.increaseUserPointBalance, and
pointModuleApi.increaseUserPointBalance.
🧹 Nitpick comments (20)
src/test/java/com/slam/concertreservation/Hhp7ConcertReservationApplicationTests.java (1)

8-8: Unused import of EnableRetry.

@EnableRetry is imported but never applied to this test class. Since the test loads Hhp7ConcertReservationApplication.class (which already carries @EnableRetry), retry is already enabled in this test context. Consider removing the unused import to keep the file clean.

Proposed fix
-import org.springframework.retry.annotation.EnableRetry;
src/main/java/com/slam/concertreservation/domain/payment/model/CompensationTxLog.java (2)

86-95: markAsFailed() does not enforce MAX_RETRY_COUNT.

MAX_RETRY_COUNT is defined in this class but never enforced here — retryCount can be incremented beyond 3 if markAsFailed() is called without external guards. Consider adding a check inside markAsFailed() (or incrementRetryCount()) to prevent exceeding the limit, keeping the invariant within the domain model.

Proposed fix
     public CompensationTxLog markAsFailed() {
         if(this.status == CompensationTxStatus.COMPLETED) {
             throw new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "이미 완료 처리된 보상 트랜잭션입니다.");
         }
+        if(this.retryCount >= MAX_RETRY_COUNT) {
+            throw new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "최대 재시도 횟수를 초과한 보상 트랜잭션입니다.");
+        }
         this.status = CompensationTxStatus.FAILED;
         this.incrementRetryCount();
         return this;
     }

47-58: LocalDateTime.now() in create() hinders testability.

Using LocalDateTime.now() directly couples the domain model to the system clock. Consider accepting a Clock or LocalDateTime parameter to make unit tests deterministic. This is a minor concern given the codebase style.

src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/CompensationTxLogJpaEntity.java (1)

22-33: CompensationTxStatus.valueOf() will throw IllegalArgumentException on corrupt data.

If the status column contains an unexpected string, valueOf throws an unchecked exception. This is typically acceptable when the write path consistently uses .name(), but consider whether defensive handling is warranted for a compensation log table that may be manually updated during incident response.

src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/CompensationTxLogJpaRepository.java (1)

8-25: Consider adding a combined query for the retry scheduler.

The compensation retry scheduler likely needs to find logs that are FAILED AND have retryCount < MAX_RETRY_COUNT. Currently this requires either two separate queries or in-memory filtering. A combined derived query would be more efficient.

Proposed addition
+    List<CompensationTxLogJpaEntity> findAllByStatusAndRetryCountLessThan(
+            String status, int maxRetryCount);

Also consider adding database indexes on status and retryCount columns if the compensation log table is expected to grow, as the scheduler will query this table periodically.

#!/bin/bash
# Check how the scheduler queries compensation logs
rg -n 'findAll|compensationTxLog' --type=java -g '*Scheduler*' -g '*RepositoryImpl*' -C 3
src/test/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogServiceUnitTest.java (1)

33-36: Prefer @ExtendWith(MockitoExtension.class) over manual openMocks.

MockitoAnnotations.openMocks(this) returns an AutoCloseable that is never closed here, leaking resources. The idiomatic JUnit 5 approach is to annotate the class with @ExtendWith(MockitoExtension.class) and remove the setUp method entirely.

Proposed fix
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;

+@ExtendWith(MockitoExtension.class)
 class CompensationTxLogServiceUnitTest {

     `@Mock`
     private CompensationTxLogRepository compensationTxLogRepository;

     `@InjectMocks`
     private CompensationTxLogService compensationTxLogService;

-    `@BeforeEach`
-    void setUp() {
-        MockitoAnnotations.openMocks(this);
-    }
src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java (1)

19-19: No distributed-lock guard — concurrent scheduler instances may double-process logs.

If the application runs with multiple instances, overlapping scheduler invocations could process the same PENDING log simultaneously, leading to duplicate point adjustments. Consider using a distributed lock (e.g., ShedLock, database advisory lock) or a SELECT … FOR UPDATE SKIP LOCKED pattern.

src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java (1)

1-33: Consider extracting a shared base or common DTO.

ReservationConfirmResponse and ReservationCancelResponse are structurally identical — same fields, same mapping logic. This duplication increases the surface for bugs like the seatNumber omission. A shared base record or a single ReservationResponse with the status field differentiating them would reduce maintenance burden.

src/test/java/com/slam/concertreservation/domain/payment/service/PaymentServiceUnitTest.java (1)

35-38: Prefer @ExtendWith(MockitoExtension.class) for consistency and correctness.

Other test classes in this PR (e.g., CompensationTxRetrySchedulerTest) use @ExtendWith(MockitoExtension.class). Using MockitoAnnotations.openMocks(this) is the older pattern and leaks the returned AutoCloseable since it's never closed.

Proposed fix
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+@ExtendWith(MockitoExtension.class)
 class PaymentServiceUnitTest {
 
     `@Mock`
     private PaymentRepository paymentRepository;
 
     `@InjectMocks`
     private PaymentService paymentService;
-
-    `@BeforeEach`
-    void setUp() {
-        MockitoAnnotations.openMocks(this);
-    }
src/main/java/com/slam/concertreservation/domain/payment/repository/CompensationTxLogRepository.java (1)

29-29: Naming inconsistency: missing "By" prefix.

findAllCreatedAtBetween doesn't follow the naming convention used elsewhere (e.g., PaymentRepository.findAllByCreatedAtBetween). Should be findAllByCreatedAtBetween for consistency.

Proposed fix
-    List<CompensationTxLog> findAllCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime);
+    List<CompensationTxLog> findAllByCreatedAtBetween(LocalDateTime startDateTime, LocalDateTime endDateTime);
src/main/java/com/slam/concertreservation/domain/payment/model/Payment.java (1)

52-61: No input validation in create() factory.

create silently accepts null userId, null reservationId, or negative price. Consider adding precondition checks (e.g., Objects.requireNonNull, price > 0) to fail fast with a clear error rather than persisting invalid data.

src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java (1)

251-255: Inconsistent return type: missing ResponseEntity wrapper.

Every other endpoint in this controller returns ResponseEntity<...>, but getPayments returns a raw Page<PaymentProcessResponse>. This inconsistency can affect content negotiation and HTTP status code control.

Proposed fix
 `@GetMapping`("/payments")
-public Page<PaymentProcessResponse> getPayments(`@RequestParam` Long userId, Pageable pageable) {
-    return paymentService.getPaymentHistoryOfUser(userId, pageable)
-            .map(PaymentProcessResponse::from);
+public ResponseEntity<Page<PaymentProcessResponse>> getPayments(`@RequestParam` Long userId, Pageable pageable) {
+    Page<PaymentProcessResponse> page = paymentService.getPaymentHistoryOfUser(userId, pageable)
+            .map(PaymentProcessResponse::from);
+    return ResponseEntity.ok(page);
 }
src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/PaymentRepositoryImpl.java (1)

3-4: Unused imports: ErrorCode and UnavailableRequestException.

These imports are not referenced anywhere in the class.

Proposed fix
-import com.slam.concertreservation.common.error.ErrorCode;
-import com.slam.concertreservation.common.exceptions.UnavailableRequestException;
src/main/java/com/slam/concertreservation/domain/payment/service/PaymentService.java (2)

33-39: Inconsistent @Transactional on fail and refund — both are missing it.

initiate and complete are annotated with @Transactional, but fail and refund are not, despite all four performing the same paymentRepository.save(...) operation. This inconsistency could lead to subtle issues if these methods are extended or called in a context where a transaction boundary is expected.

Proposed fix
+    `@Transactional`
     public Payment fail(Payment payment) {
         return paymentRepository.save(payment.withStatus(PaymentStatus.FAILED));
     }

+    `@Transactional`
     public Payment refund(Payment payment) {
         return paymentRepository.save(payment.withStatus(PaymentStatus.REFUNDED));
     }

41-67: Read-only queries could benefit from @Transactional(readOnly = true).

The query methods (getPaymentById, getPaymentHistoryOfUser, etc.) perform reads only. Adding @Transactional(readOnly = true) enables Hibernate to skip dirty-checking and can improve performance, especially for paginated queries.

src/test/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacadeUnitTest.java (1)

25-28: openMocks returns an AutoCloseable that is never closed.

MockitoAnnotations.openMocks(this) returns an AutoCloseable. Not closing it can leak mock state between tests. Consider using @ExtendWith(MockitoExtension.class) instead, which handles lifecycle automatically—consistent with idiomatic JUnit 5 + Mockito usage.

♻️ Suggested refactor
-import org.mockito.MockitoAnnotations;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;

+@ExtendWith(MockitoExtension.class)
 class ReservationModuleFacadeUnitTest {

     `@Mock`
     private ReservationService reservationService;

     `@InjectMocks`
     private ReservationModuleFacade reservationModuleFacade;

-    `@BeforeEach`
-    void setUp() {
-        MockitoAnnotations.openMocks(this);
-    }
src/test/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestratorUnitTest.java (1)

48-51: Same openMocks leak as in the other test file—prefer @ExtendWith(MockitoExtension.class).

See the same suggestion as in ReservationModuleFacadeUnitTest.

src/main/java/com/slam/concertreservation/domain/payment/service/CompensationTxLogService.java (2)

24-32: markAsFailed/markAsCompleted mutate the caller's object as a side effect.

These methods call compensationTxLog.markAsFailed() / markAsCompleted() on the passed-in domain object before saving, so the caller's reference is also mutated regardless of whether the save succeeds. This is a subtle contract that could lead to inconsistent state if the save fails. Consider either documenting this behavior or operating on a copy.


38-41: Using UnavailableRequestException for a missing resource feels semantically off.

The exception name suggests the request itself is unavailable (e.g., rate-limited), but here it indicates a resource was not found. If a more specific ResourceNotFoundException exists or is planned, it would be a better fit. Minor naming concern—not blocking.

src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/impl/CompensationTxLogRepositoryImpl.java (1)

43-49: Use .name() instead of .toString() for enum-to-string conversion to match the entity's persistence logic.

The fromDomain() method in CompensationTxLogJpaEntity uses .name() to persist the enum. For consistency and clarity, the query should also use .name() rather than .toString(), which is the standard practice for enum-based persistence.

-        return compensationTxLogJpaRepository.findAllByStatus(compensationTxStatus.toString())
+        return compensationTxLogJpaRepository.findAllByStatus(compensationTxStatus.name())

Comment on lines +19 to +38
@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()));
}

if (result.success()) {
compensationTxLogService.markAsCompleted(log);
} else {
compensationTxLogService.markAsFailed(log);
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 -A3

Repository: 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 -40

Repository: leonroars/slam

Length of output: 5690


Compensation logs get only one retry — FAILED status is never re-fetched.

getAllRetriables() queries only PENDING logs. When a retry fails, markAsFailed() sets status to FAILED, so the log is never picked up again. This means the scheduler provides a single additional attempt, not continuous retries. For an eventual-consistency mechanism, consider either keeping logs in PENDING with an incremented retry count and a retry cap, or querying both PENDING and FAILED with a configurable max retry limit.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java`
around lines 19 - 38, retryFailedCompensations() only reprocesses PENDING
because getAllRetriables() excludes FAILED and markAsFailed() flips status to
FAILED, so logs get only one retry; change the flow so retries continue until a
max: either (A) modify compensationTxLogService.getAllRetriables() to return
entries with status PENDING or FAILED whose retryCount < maxRetries
(configurable) and leave markAsFailed() to only increment retryCount and
optionally set status=FAILED only when retryCount >= maxRetries, or (B) keep
getAllRetriables() returning only PENDING but change
markAsFailed(CompensationTxLog) to increment and persist a retryCount and keep
status=PENDING until retryCount exceeds maxRetries then set FAILED; update
CompensationTxLog to track retryCount and ensure retry cap is enforced before
calling markAsCompleted()/final failure.

Comment on lines +27 to +31
if (log.getPrice() > 0) {
result = pointModuleApi.increaseUserPointBalance(log.getUserId(), log.getPrice());
} else {
result = pointModuleApi.decreaseUserPointBalance(log.getUserId(), Math.abs(log.getPrice()));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Edge case: price == 0 falls into the else branch.

If log.getPrice() is 0, it will call decreaseUserPointBalance with amount 0. While likely a no-op, consider guarding with an explicit check or logging a warning, since a zero-price compensation log is likely a bug upstream.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/CompensationTxRetryScheduler.java`
around lines 27 - 31, In CompensationTxRetryScheduler, guard the price==0 case
so it doesn't fall through to decreaseUserPointBalance with 0: update the method
that currently branches on log.getPrice() (using log.getPrice(),
pointModuleApi.increaseUserPointBalance and
pointModuleApi.decreaseUserPointBalance) to explicitly check for price == 0
first, log a warning or error mentioning the unexpected zero-price compensation
(include identifying info like log.getUserId()/log id), and skip calling the
pointModuleApi in that case; keep the existing increase/decrease branches for
positive/negative prices unchanged.

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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Presentation-layer DTO (PaymentProcessResponse) leaks into the domain/application layer.

PaymentOrchestrator lives in domain.payment.application but imports and returns interfaces.dto.PaymentProcessResponse. This couples the domain layer to the presentation layer, violating the layered architecture boundaries this PR establishes for other modules (Point, Reservation expose only api interfaces and DTOs).

The orchestrator should return a domain object (e.g., Payment) and let the controller map to the response DTO.

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 statements

Then 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
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
at line 6, PaymentOrchestrator currently imports and returns the presentation
DTO PaymentProcessResponse, leaking presentation concerns into the
domain/application layer; change the orchestrator to return a domain-level
Payment (or other domain result object) instead of PaymentProcessResponse,
remove the import of
com.slam.concertreservation.interfaces.dto.PaymentProcessResponse from
PaymentOrchestrator, update the orchestrator method signature(s) (e.g.,
processPayment/orchestratePayment) and any internal returns to produce the
domain Payment, and move the mapping to PaymentProcessResponse into the
controller layer so controllers call PaymentOrchestrator to get the domain
Payment and translate it to the DTO for the API response.

Comment on lines +48 to +49
// 4. 결제 확정
return PaymentProcessResponse.from(paymentService.complete(initiatedPayment));
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

No error handling if the final complete/refund step throws.

If paymentService.complete(initiatedPayment) on line 49 (or paymentService.refund on line 75) throws an exception, points have already been deducted and the reservation confirmed, but the payment record stays PENDING. There is no compensation path for this scenario.

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
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
around lines 48 - 49, Wrap the final persistence calls
paymentService.complete(initiatedPayment) and
paymentService.refund(initiatedPayment) in a try-catch inside
PaymentOrchestrator so a thrown exception doesn't leave the system with deducted
points and a PENDING payment; on catch, perform a compensating action such as
retrying the persistence (with a limited retry loop/backoff) or
creating/flagging a reconciliation/compensation record (e.g., via a new
paymentRepository.createCompensationEntry or
paymentService.markForReconciliation) and log the full error and context, then
return an appropriate failure response instead of letting the exception escape;
ensure PaymentProcessResponse.from is only called after the persistence
succeeds.

Comment on lines +52 to +76
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));
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Refund flow doesn't validate current reservation/payment state before proceeding.

processRefund accepts userId, price, and reservationId as raw parameters without verifying the reservation is in a refundable state (e.g., CONFIRMED) or that a completed payment exists for it. A caller could trigger a refund for an already-cancelled reservation, causing points to be credited without a valid charge to reverse.

Consider fetching and validating the existing payment/reservation state before initiating the refund.

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/payment/application/PaymentOrchestrator.java`
around lines 52 - 76, Before creating a PENDING refund in processRefund, fetch
and validate the current reservation and payment state: call
reservationModuleApi.getReservation(reservationId) (or equivalent) and verify
it's in a refundable state (e.g., CONFIRMED) and fetch the existing payment via
paymentService.findByReservationId(reservationId) to ensure a completed charge
exists; if validation fails, return a failure response without calling
paymentService.initiate, pointModuleApi.increaseUserPointBalance,
reservationModuleApi.cancelReservation, or paymentService.refund. Keep existing
compensation logic (compensatePointIncrease and paymentService.fail) for
downstream failures but only after the initial pre-checks pass.

Comment on lines +25 to +27
catch (Exception e) {
return ReservationOperationResult.fail(reservationId, e.getMessage());
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

e.getMessage() may be null.

Some exceptions (e.g., bare NullPointerException) can have a null message. If ReservationOperationResult.fail doesn't handle null gracefully, this could cause issues downstream. Consider using a fallback:

e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()

Also applies to: 36-38

🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/domain/reservation/application/ReservationModuleFacade.java`
around lines 25 - 27, In ReservationModuleFacade update the two catch blocks
that call ReservationOperationResult.fail(reservationId, e.getMessage()) to
guard against a null message by passing a non-null fallback (e.g., use
e.getMessage() if not null otherwise e.getClass().getSimpleName()); ensure both
occurrences are changed so ReservationOperationResult.fail always receives a
non-null string.

Comment on lines +9 to +10
@Entity
public class PaymentJpaEntity {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing @Table annotation — entity will map to an unintended table name.

Other entities in this project (e.g., ReservationJpaEntity, UserJpaEntity) explicitly declare @Table(name = "..."). Without it, Hibernate's default naming strategy will derive the table name from the class name (e.g., payment_jpa_entity), which is almost certainly not the intended table name.

Proposed fix
 `@Entity`
+@Table(name = "PAYMENT")
 public class PaymentJpaEntity {

Also add the import:

import jakarta.persistence.Table;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Entity
public class PaymentJpaEntity {
import jakarta.persistence.Table;
`@Entity`
`@Table`(name = "PAYMENT")
public class PaymentJpaEntity {
🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/infrastructure/persistence/jpa/entities/PaymentJpaEntity.java`
around lines 9 - 10, Add an explicit `@Table` annotation to the PaymentJpaEntity
class to specify the intended DB table name (consistent with other entities like
ReservationJpaEntity/UserJpaEntity) and import jakarta.persistence.Table; update
the class declaration for PaymentJpaEntity to include `@Table`(name = "payment")
(or the project’s canonical table name) so Hibernate doesn't default to
payment_jpa_entity.

Comment on lines +236 to +244
@PostMapping("/payments/{paymentId}/refund")
public ResponseEntity<PaymentProcessResponse> processRefund(
@PathVariable Long paymentId,
@RequestParam Long userId,
@RequestParam Integer price,
@RequestParam Long reservationId) {
PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId);
return ResponseEntity.ok(response);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

paymentId path variable is accepted but never used.

The endpoint POST /payments/{paymentId}/refund captures paymentId from the path but passes only userId, price, and reservationId to the orchestrator. This means:

  1. The API contract misleadingly implies a specific payment is being refunded.
  2. A caller can supply any paymentId in the URL with no effect, which is confusing and error-prone.

The paymentId should be used to look up the original payment and derive userId, price, and reservationId from it, or the endpoint design should be reconsidered.

Proposed fix: use paymentId to look up original payment
 `@PostMapping`("/payments/{paymentId}/refund")
 public ResponseEntity<PaymentProcessResponse> processRefund(
-        `@PathVariable` Long paymentId,
-        `@RequestParam` Long userId,
-        `@RequestParam` Integer price,
-        `@RequestParam` Long reservationId) {
-    PaymentProcessResponse response = paymentOrchestrator.processRefund(userId, price, reservationId);
+        `@PathVariable` Long paymentId) {
+    Payment originalPayment = paymentService.getPaymentById(paymentId);
+    PaymentProcessResponse response = paymentOrchestrator.processRefund(
+            originalPayment.getUserId(),
+            originalPayment.getPrice(),
+            originalPayment.getReservationId());
     return ResponseEntity.ok(response);
 }
🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/interfaces/ConcertReservationController.java`
around lines 236 - 244, The processRefund handler in
ConcertReservationController accepts a path variable paymentId but never uses
it; change processRefund to fetch the original Payment by paymentId (via your
PaymentRepository/PaymentService) and derive userId, price, and reservationId
from that entity before calling
paymentOrchestrator.processRefund(payment.getUserId(), payment.getPrice(),
payment.getReservationId()); alternatively, if you must accept request params,
validate they match the fetched Payment and return 400 on mismatch—update the
method signature and logic in processRefund accordingly and remove or reconcile
the unused request params to ensure the URL paymentId actually controls which
payment is refunded.

Comment on lines +21 to +32
public static ReservationConfirmResponse from(Reservation reservation) {
return ReservationConfirmResponse.builder()
.id(String.valueOf(reservation.getId()))
.userId(String.valueOf(reservation.getUserId()))
.seatId(String.valueOf(reservation.getSeatId()))
.concertScheduleId(String.valueOf(reservation.getConcertScheduleId()))
.price(reservation.getPrice())
.status(reservation.getStatus().name())
.expiredAt(reservation.getExpiredAt())
.createdAt(reservation.getCreatedAt())
.build();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Same seatNumber mapping omission as ReservationCancelResponse.

seatNumber is declared but never set in the builder — it will always be 0.

🐛 Proposed fix
         return ReservationConfirmResponse.builder()
                 .id(String.valueOf(reservation.getId()))
                 .userId(String.valueOf(reservation.getUserId()))
                 .seatId(String.valueOf(reservation.getSeatId()))
+                .seatNumber(reservation.getSeatNumber())
                 .concertScheduleId(String.valueOf(reservation.getConcertScheduleId()))
                 .price(reservation.getPrice())
                 .status(reservation.getStatus().name())
🤖 Prompt for AI Agents
In
`@src/main/java/com/slam/concertreservation/interfaces/dto/ReservationConfirmResponse.java`
around lines 21 - 32, The static factory method ReservationConfirmResponse.from
is missing the mapping for seatNumber (so seatNumber stays default 0); update
the builder call in ReservationConfirmResponse.from to set
.seatNumber(reservation.getSeatNumber()) (or convert appropriately if types
differ) alongside the other fields—mirror the fix already used in
ReservationCancelResponse to ensure the DTO carries the actual seat number from
the Reservation entity.

Comment on lines +124 to +136
@Test
@DisplayName("비즈니스 예외는 재시도하지 않는다")
void shouldNotRetry_WhenBusinessException() {
// given
Long userId = 1L;
int amount = 500;

when(pointService.increaseUserPointBalance(userId, amount))
.thenThrow(new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "비즈니스 규칙 위반"));

// when
PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Test has no assertions — it will always pass regardless of behavior.

shouldNotRetry_WhenBusinessException assigns the result but never asserts on it and never verifies the call count. This means the test passes even if the retry behavior is broken.

🐛 Proposed fix: add assertions matching the decrease counterpart
         // when
         PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount);
+
+        // then — 비즈니스 실패이므로 1회만 호출
+        assertFalse(result.success());
+        verify(pointService, times(1)).increaseUserPointBalance(userId, amount);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Test
@DisplayName("비즈니스 예외는 재시도하지 않는다")
void shouldNotRetry_WhenBusinessException() {
// given
Long userId = 1L;
int amount = 500;
when(pointService.increaseUserPointBalance(userId, amount))
.thenThrow(new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "비즈니스 규칙 위반"));
// when
PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount);
}
`@Test`
`@DisplayName`("비즈니스 예외는 재시도하지 않는다")
void shouldNotRetry_WhenBusinessException() {
// given
Long userId = 1L;
int amount = 500;
when(pointService.increaseUserPointBalance(userId, amount))
.thenThrow(new BusinessRuleViolationException(ErrorCode.INVALID_REQUEST, "비즈니스 규칙 위반"));
// when
PointOperationResult result = pointModuleApi.increaseUserPointBalance(userId, amount);
// then — 비즈니스 실패이므로 1회만 호출
assertFalse(result.success());
verify(pointService, times(1)).increaseUserPointBalance(userId, amount);
}
🤖 Prompt for AI Agents
In
`@src/test/java/com/slam/concertreservation/domain/point/application/PointModuleFacadeRetryIntegrationTest.java`
around lines 124 - 136, The test shouldNotRetry_WhenBusinessException currently
has no assertions; update it to assert the operation failed and that the service
was called only once (no retries). After invoking
pointModuleApi.increaseUserPointBalance(userId, amount), assert that the
returned PointOperationResult indicates failure (e.g., isSuccess() is false) and
its error code equals ErrorCode.INVALID_REQUEST, and add Mockito verification
verify(pointService, times(1)).increaseUserPointBalance(userId, amount) to
ensure no retry occurred; use the existing symbols PointOperationResult,
BusinessRuleViolationException, pointService.increaseUserPointBalance, and
pointModuleApi.increaseUserPointBalance.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
docs/detailed_docs/en/adr-payment-saga-module-api.md (1)

114-114: Clarify total eventual-consistency window.

Line 114 is slightly ambiguous operationally. Consider stating the worst-case total delay explicitly (e.g., “up to ~3 minutes across 3 retry intervals, plus execution time”).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/detailed_docs/en/adr-payment-saga-module-api.md` at line 114, Update the
“Eventual consistency” sentence about CompensationTxLog to explicitly state the
worst-case total delay: mention that with 3 retry attempts at 60 seconds per
retry the system can be inconsistent for up to ~180 seconds (≈3 minutes) plus
the actual compensation execution time, e.g., replace or augment the existing
line referencing CompensationTxLog to read something like “up to ~3 minutes
across 3 retry intervals (60s each), plus execution time” so operators clearly
see the total eventual-consistency window.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/detailed_docs/en/adr-payment-saga-module-api.md`:
- Line 107: The wording on line 107 is redundant; replace "Module API interfaces
serve as explicit contracts." with the tighter phrasing "Module APIs serve as
explicit contracts." and ensure the following sentence ("Cross-domain
dependencies are visible and auditable in the interface definitions.") still
reads fluently—adjust "interface definitions" to "API definitions" if needed so
both sentences consistently use "API" instead of "interface".
- Around line 73-76: The ADR currently contradicts itself about
OptimisticLockingFailureException: the table at line referencing "Excluded
exceptions" lists OptimisticLockingFailureException as non-retryable, while
later paragraphs (around the text referencing retries and transient optimistic
lock conflicts, including mentions near the `@Recover` behavior) treat optimistic
lock failures as transient and recommend retry; pick one consistent policy and
update all occurrences accordingly—either remove
OptimisticLockingFailureException from the excluded list and change the
descriptions around retry/@Recover to mark optimistic locks as transient and
retriable, or keep it excluded and rewrite the later paragraphs (and any
`@Recover` behavior text) to state optimistic lock conflicts are
deterministic/non-retriable; be sure to update the table entry for Excluded
exceptions, the paragraphs discussing transient optimistic lock conflicts, and
any references to `@Recover` to reflect the chosen policy and use the exact symbol
OptimisticLockingFailureException where applicable.

---

Nitpick comments:
In `@docs/detailed_docs/en/adr-payment-saga-module-api.md`:
- Line 114: Update the “Eventual consistency” sentence about CompensationTxLog
to explicitly state the worst-case total delay: mention that with 3 retry
attempts at 60 seconds per retry the system can be inconsistent for up to ~180
seconds (≈3 minutes) plus the actual compensation execution time, e.g., replace
or augment the existing line referencing CompensationTxLog to read something
like “up to ~3 minutes across 3 retry intervals (60s each), plus execution time”
so operators clearly see the total eventual-consistency window.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bdf3101e-ad06-4a33-8145-74556f301377

📥 Commits

Reviewing files that changed from the base of the PR and between 525d7b9 and 78a0d2d.

📒 Files selected for processing (1)
  • docs/detailed_docs/en/adr-payment-saga-module-api.md

Comment on lines +73 to +76
| 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Retry policy is internally contradictory for optimistic locking failures.

Line 73 excludes OptimisticLockingFailureException from 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
-| Excluded exceptions | `BusinessRuleViolationException`, `OptimisticLockingFailureException` |
+| Excluded exceptions | `BusinessRuleViolationException` |

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
Verify each finding against the current code and only fix it if needed.

In `@docs/detailed_docs/en/adr-payment-saga-module-api.md` around lines 73 - 76,
The ADR currently contradicts itself about OptimisticLockingFailureException:
the table at line referencing "Excluded exceptions" lists
OptimisticLockingFailureException as non-retryable, while later paragraphs
(around the text referencing retries and transient optimistic lock conflicts,
including mentions near the `@Recover` behavior) treat optimistic lock failures as
transient and recommend retry; pick one consistent policy and update all
occurrences accordingly—either remove OptimisticLockingFailureException from the
excluded list and change the descriptions around retry/@Recover to mark
optimistic locks as transient and retriable, or keep it excluded and rewrite the
later paragraphs (and any `@Recover` behavior text) to state optimistic lock
conflicts are deterministic/non-retriable; be sure to update the table entry for
Excluded exceptions, the paragraphs discussing transient optimistic lock
conflicts, and any references to `@Recover` to reflect the chosen policy and use
the exact symbol OptimisticLockingFailureException where applicable.


### Positive

- **Clear domain boundaries**: Module API interfaces serve as explicit contracts. Cross-domain dependencies are visible and auditable in the interface definitions.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

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”.
Context: ... - Clear domain boundaries: Module API interfaces serve as explicit contracts. Cross-doma...

(ACRONYM_TAUTOLOGY)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/detailed_docs/en/adr-payment-saga-module-api.md` at line 107, The
wording on line 107 is redundant; replace "Module API interfaces serve as
explicit contracts." with the tighter phrasing "Module APIs serve as explicit
contracts." and ensure the following sentence ("Cross-domain dependencies are
visible and auditable in the interface definitions.") still reads
fluently—adjust "interface definitions" to "API definitions" if needed so both
sentences consistently use "API" instead of "interface".

@leonroars leonroars merged commit b4e899a into main Apr 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant