Audit every transfer attempt, rejected ones included

Java · Spring Boot · advanced · modification

Compliance wants a trail of every transfer attempt, including the ones we turn down, so this adds a `transfer_audit` table (Flyway V14), the `TransferAudit` entity and its repository. `TransferService.transfer` now wraps the existing logic, moved unchanged into a private `move` method. A completed transfer saves its `COMPLETED` row inside the transfer's transaction, so the row and the balance changes commit together. A rejected one is caught, recorded through `recordRejection`, which is marked `REQUIRES_NEW` so the `REJECTED` row is written in a transaction of its own, and the exception is rethrown so the controller still answers 422.

Banking backend on Spring Boot 3.3.5 (Java 21), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16 (read committed), Flyway for schema changes with `spring.jpa.hibernate.ddl-auto=validate` and `spring.jpa.open-in-view=false`. Transactions are Spring's default proxy-based `@Transactional` (no AspectJ weaving); `TransferService` is a singleton bean that only `TransferController` calls. Existing and unchanged: `TransferController` (`POST /api/transfers`, `@Valid` body, the signed-in `BankUser`'s `customerId`), `TransferRequest` (`fromAccountId` and `toAccountId` non-null, `amount` positive with at most 2 decimals and at most 1,000,000.00), `TransferResult`, `RejectionReason`, `TransferRejectedException` (a `RuntimeException` annotated `@ResponseStatus(UNPROCESSABLE_ENTITY)`), `AccountNotFoundException` (a `RuntimeException`, `@ResponseStatus(NOT_FOUND)`), the `Account` entity (`balance` is `numeric(19,2)` and stays far below its range) and `AccountRepository.findForUpdate` (`@Lock(PESSIMISTIC_WRITE)`, a row write lock: `SELECT ... FOR NO KEY UPDATE` on PostgreSQL). Spring's default rollback rules apply: an unchecked exception leaving a `@Transactional` method rolls its transaction back. Error bodies are Spring Boot's default with `server.error.include-message=never`. The Hikari pool (20 connections) is sized for two connections per request at the server's request concurrency, which the platform's other `REQUIRES_NEW` writers already rely on. The `Clock` bean is `Clock.systemUTC()`.

Requirements

Files touched

--- src/main/java/com/acme/bank/transfer/TransferService.java
 import com.acme.bank.account.AccountNotFoundException;
 import com.acme.bank.account.AccountRepository;
+import java.time.Clock;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
 import org.springframework.transaction.annotation.Transactional;
 
 
     private final AccountRepository accounts;
+    private final TransferAuditRepository audits;
+    private final Clock clock;
 
-    public TransferService(AccountRepository accounts) {
+    public TransferService(AccountRepository accounts, TransferAuditRepository audits, Clock clock) {
         this.accounts = accounts;
+        this.audits = audits;
+        this.clock = clock;
     }
 
     @Transactional
     public TransferResult transfer(long customerId, TransferRequest req) {
+        try {
+            TransferResult result = move(customerId, req);
+            audits.save(TransferAudit.completed(customerId, req, clock.instant()));
+            return result;
+        } catch (TransferRejectedException e) {
+            recordRejection(customerId, req, e.getReason());
+            throw e;
+        }

Review this PR

Java practice