Bank Account Transfer (JAVA)
example/transfer implements the account aggregate in Java and coordinates cross-account transfer with a Wow stateless Saga. This page describes only behavior proven by current source, tests, and runtime OpenAPI.
Bank Transfer Process
On success, the source available balance decreases, its locked amount returns to zero, and the target balance increases. If the target is frozen, the Saga returns the locked amount to the source. This is event-driven compensation, not a cross-aggregate database transaction.
Run Example
Start with the infrastructure-free domain check:
./gradlew :example-transfer-domain:checkExpect BUILD SUCCESSFUL.
The current example-transfer-server application mainClass names a missing ExampleServer, so ./gradlew :example-transfer-server:run currently fails with ClassNotFoundException. This documentation task does not change Gradle. Start the same distribution with the real TransferExampleServer:
mkdir -p example/transfer/example-transfer-server/logs
./gradlew :example-transfer-server:installDist
java \
-Dserver.port=8080 \
-Dspring.config.location=file:example/transfer/example-transfer-server/src/main/resources/application.yaml \
-cp 'example/transfer/example-transfer-server/build/install/example-transfer-server/lib/*' \
me.ahoo.wow.example.transfer.server.TransferExampleServerExpect Netty started on port 8080 and Started TransferExampleServer. The sample uses in-memory command/event buses, EventStore, and SnapshotStore, so account data disappears when the process exits.
Auto-Generated API Endpoints
Current runtime /v3/api-docs exposes:
| Operation | Method and path | operationId |
|---|---|---|
| Create account | POST /account/create_account | transfer.account.create_account |
| Prepare transfer | POST /account/{id}/prepare | transfer.account.prepare |
| Read state | GET /account/{id}/state | Generated state route |
These routes also match Transfer.http. They are not inferred from the transfer-service name.
curl -X POST http://localhost:8080/account/create_account \
-H 'Content-Type: application/json' \
-H 'Command-Wait-Stage: PROCESSED' \
-H 'Command-Aggregate-Id: sourceId' \
-H 'Command-Request-Id: source-create-1' \
-d '{"name":"source","balance":100}'
curl -X POST http://localhost:8080/account/create_account \
-H 'Content-Type: application/json' \
-H 'Command-Wait-Stage: PROCESSED' \
-H 'Command-Aggregate-Id: targetId' \
-H 'Command-Request-Id: target-create-1' \
-d '{"name":"target","balance":0}'
curl -X POST http://localhost:8080/account/sourceId/prepare \
-H 'Content-Type: application/json' \
-H 'Command-Wait-Stage: PROCESSED' \
-H 'Command-Request-Id: transfer-1' \
-d '{"to":"targetId","amount":10}'All three commands should return succeeded=true and stage=PROCESSED. The transfer result reports source aggregate version 2. After the Saga completes:
curl http://localhost:8080/account/sourceId/state
curl http://localhost:8080/account/targetId/stateExpect source balanceAmount=90, lockedAmount=0 and target balanceAmount=10.
Module Division
| Module | Responsibility | Exact source |
|---|---|---|
example-transfer-api | Account commands, events, and published language | TransferService.java, api package |
example-transfer-domain | Account decisions, event sourcing, Saga, and tests | Account.java, TransferSaga.java |
example-transfer-server | Spring Boot entry point and WebFlux/OpenAPI wiring | TransferExampleServer.java, application.yaml |
Domain Modeling
The minimal domain decision is: lock the source first, enter the target second, then confirm or unlock.
| Command | Event | State result |
|---|---|---|
CreateAccount | AccountCreated | Initialize name and balanceAmount |
Prepare | AmountLocked, Prepared | Decrease available balance and increase lockedAmount |
Entry | AmountEntered or EntryFailed | Increase target balance, or leave target unchanged |
Confirm | Confirmed | Decrease source lockedAmount |
UnlockAmount | AmountUnlocked | Return lockedAmount to balanceAmount |
FreezeAccount / UnfreezeAccount | AccountFrozen / AccountUnfrozen | Toggle frozen |
State Aggregate Root (AccountState) Modeling
AccountState changes balanceAmount, lockedAmount, and frozen only in onSourcing. AmountLocked moves money from available to locked; Confirmed removes locked money; AmountUnlocked removes locked money and restores available money.
Command Aggregate Root (Account) Modeling
Account never mutates state directly. Prepare rejects a frozen source or insufficient balance before returning AmountLocked, then Prepared. Entry returns the ErrorInfo event EntryFailed for a frozen target, selecting the Saga's unlock branch.
Transfer Process Manager (TransferSaga)
TransferSaga has only three mappings:
Prepared -> Entry(targetId, sourceId, amount)
AmountEntered -> Confirm(sourceId, amount)
EntryFailed -> UnlockAmount(sourceId, amount)There is no extra process state. The event history and two account states are the audit evidence.
Unit Testing
AccountSpec verifies creation, locking, entry, frozen rejection, and insufficient balance. TransferSagaSpec verifies all three event-to-command mappings.
Failure behavior is part of the contract: Prepare throws IllegalStateException and preserves balance for a frozen source or insufficient funds; a frozen target emits EntryFailed, then unlocks the source; repeated freeze/unfreeze is rejected. HTTP succeeded=false and errorMsg should match these test assertions.