A quote-to-settlement remittance system: a sender gets a locked FX quote, executes an order against it, funds move through a (mocked) external payout network, and the system settles or compensates on failure. Built as a Spring Boot service to work through the pieces a real remittance backend needs: a double-entry ledger, an async payout saga, retries/circuit-breaking/dead-lettering, per-user and per-corridor rate limiting, and basic tracing across the saga.
POST /api/quotes -> locked FX quote, cached in Redis with a TTL
POST /api/orders -> claims the quote, creates an order (ORDER_CREATED)
background poller then drives it through:
ORDER_CREATED -> FUNDS_RESERVED -> PAYOUT_IN_PROGRESS -> SETTLED
-> FAILED -> COMPENSATED -> DEAD_LETTERED
GET /api/orders/{id} -> poll for status
Funds are reserved (debit sender, credit a PENDING_SETTLEMENT account) in one DB transaction before the
payout call, and reversed in one DB transaction if the payout ultimately fails, so money is never left in
limbo. See LedgerService for the double-entry posting logic and PayoutSagaScheduler/SagaBatchRunner
for the saga orchestration.
Java 17, Spring Boot 3.3, Maven. Postgres (orders, ledger, dead letters) via Spring Data JPA + Flyway. Redis (quote cache, quote claim, rate limiting) via Spring Data Redis. Resilience4j (retry with backoff + jitter, circuit breaker) around the mock payout provider. Micrometer + OpenTelemetry for tracing.
Architecture is package-by-feature (quote, order, ledger, payout, saga, deadletter,
ratelimit, observability) with a small in-process CQRS mediator (common.CommandBus/QueryBus) so
every saga step is a Command dispatched through the bus - which is also what gives every step a tracing
span and MDC context for free, via TracingCommandBus.
docker compose up -d # Postgres + Redis
./mvnw spring-boot:run # migrations run automatically on startupApp listens on :8080. Swagger UI at /swagger-ui.html.
QUOTE=$(curl -s -X POST localhost:8080/api/quotes -H 'Content-Type: application/json' \
-d '{"userId":"u1","sourceCurrency":"USD","destCurrency":"PHP","sourceAmount":100}')
QUOTE_ID=$(echo "$QUOTE" | jq -r .quoteId)
ORDER=$(curl -s -X POST localhost:8080/api/orders -H 'Content-Type: application/json' \
-d "{\"quoteId\":\"$QUOTE_ID\",\"userId\":\"u1\"}")
ORDER_ID=$(echo "$ORDER" | jq -r .orderId)
watch -n2 "curl -s localhost:8080/api/orders/$ORDER_ID | jq ."Two demo wallets (u1, u2) are seeded with $10,000 USD. The mock payout provider fails about 15% of the
time; to see the failure path deterministically instead of waiting on the roll:
curl -X POST localhost:8080/api/dev/orders/$ORDER_ID/force-failure # call right after creating the order
curl -s localhost:8080/api/ops/dead-letters | jq . # once it reaches DEAD_LETTERED./mvnw test # unit tests
./mvnw verify # + integration tests (Testcontainers: Postgres, Redis - needs Docker running)Spans print as structured log lines by default (LoggingSpanExporter, zero setup). Point
OTEL_EXPORTER_OTLP_ENDPOINT at a real collector (Jaeger/Tempo/otel-collector) and flip
management.otlp.tracing.export.enabled=true to export there instead.
- The ledger doesn't model cross-currency conversion:
PENDING_SETTLEMENTandEXTERNAL_CLEARINGare denominated in the sender's currency, representing funds handed off to the payout network. The destination-currency amount is carried on the order for display/audit, not double-entried. A real system would need an FX/treasury layer for that. PENDING_SETTLEMENT/EXTERNAL_CLEARINGare hot rows under pessimistic locking - fine at this scale, a real high-throughput system would shard or batch them.- The saga poller processes batches sequentially, single instance.
SELECT ... FOR UPDATE SKIP LOCKEDis there so it's already safe to run multiple instances; parallelizing within a batch is a small addition, not required for this to be correct.