Tooling note: Developed with Claude and GitHub Copilot as coding assistants. Architecture decisions, trade-off analysis, and design choices are my own —
documented in the What would I do differently? section below.
- What is this?
- Why did I build this?
- What would I do differently?
- Architecture Overview
- Patterns Demonstrated
A production-style, event-driven backend system that processes wallet-to-wallet money transfers. It demonstrates how to build financially safe, distributed systems using patterns commonly used in fintech and enterprise backends.
I didn't have a production problem to solve — I built this to go beyond theoretical understanding - working code forces you to confront edge cases that blog posts skip. Tech companies (especially fintech and Series B–D startups) consistently ask for domain experience with event-driven systems, distributed consistency, and financial safety guarantees. Rather than just reading about these patterns, I built a working system that exercises them end-to-end.
My Goal: Be able to walk a technical interviewer through every decision — why outbox over direct Kafka publish, why optimistic locking over pessimistic, what happens when Kafka is down — with working code as evidence.
Since this was AI-assisted, I want to be transparent about the trade-offs:
-
Start with failing tests, not working code. AI tends to generate implementation-first. In production, I'd write the transfer test (with expected balances and idempotency assertions) first, then implement until green.
-
Replace
OutboxPublisherpolling with CDC. The current polling approach is simple but not optimal. At scale, I'd use Change Data Capture (CDC) via Debezium to stream the outbox table to Kafka — eliminating polling latency and the scheduled thread. I kept polling here because it's easier to understand and debug. -
Add a rounding strategy to the
Moneyvalue object. Currently it strips trailing zeros but doesn't enforce banker's rounding (HALF_EVEN). In production financial code, this matters for compliance. -
Add distributed tracing (OpenTelemetry). The Correlation ID filter is a start, but proper trace propagation through Kafka headers would make debugging the async flow much easier in production.
-
Reconsider the audit service circuit breaker pattern. The current circuit breaker enriches audit logs synchronously (blocking on another service), which is illustrative rather than critical-path. A better approach might be to join wallet data asynchronously, or denormalize it into the event payload at publish time.
┌─────────────────────────────────────────────────────────────┐
│ Wallet Service (port 8080) │
│ │
│ REST API → Wallet Service → Wallet Aggregate (DDD) │
│ │ │
│ ├── PostgreSQL (wallets + optimistic locking) │
│ ├── Redis (idempotency keys) │
│ └── Outbox Table (transactional event guarantee) │
└──────────────────────────┬──────────────────────────────────┘
│
Outbox Publisher (1s poll)
│
▼
Kafka Topic: wallet.transfer.events
│
┌───────────────┴────────────────┐
▼ ▼
Transfer Event Consumer Audit Service (port 8081)
Transfer Ledger Transfer Event Listener
Wallet Service Client
(Resilience4j: circuit breaker + retry)
Event Log (append-only audit trail)
| Layer | Technology |
|---|---|
| Language | Java 21(records, sealed interfaces, pattern matching) |
| Framework | Spring Boot 3.3 |
| Database | PostgreSQL |
| Messaging | Kafka |
| Resilience | Resilience4j (Circuit breaker + retry) |
| Cache | Redis |
| Migrations | Flyway |
| Observability | Micrometer + Prometheus + structured Logging (Logstash encoder) |
| API Docs | OpenAPI / Swagger UI |
| Testing | JUnit + Test containers + Awaitility |
| Build | Maven |
| Infra | Docker, Docker Compose |
Events are written to an outbox_events table in the same database transaction as the wallet balance update. A background poller publishes them to Kafka. This guarantees at-least-once delivery without distributed transactions (2PC).
The version field on the wallet entity prevents lost updates. If two concurrent transfers debit the same wallet, one will get a version conflict (HTTP 409) and can safely retry.
Every transfer requires an idempotency key header. Redis SETNX with a 60-minute TTL ensures the same request is never processed twice — even if the client retries due to a network timeout.
| Building Block | Implementation |
|---|---|
| Value Object | Money — immutable, self-validating, currency-aware |
| Aggregate | Wallet — owns the balance invariant, encapsulates debit/credit logic |
| Domain Events | TransferCompletedEvent — sealed interface, decoupled from infrastructure |
| Ports & Adapters | DomainEventPublisher interface — the domain has zero Kafka/Spring imports |
The audit service maintains an append-only event log derived entirely from Kafka events. Its state is fully reconstructible from the event stream — a lightweight form of event sourcing.
The audit service enriches events by calling the wallet service's REST API. If the wallet service is down, the circuit breaker opens after a 50% failure rate (5-call window), and the fallback returns "unavailable". The audit entry is still created regardless.
| Layer | Consistency Model |
|---|---|
| Wallet balance update | Strong consistency (synchronous) |
| Transfer ledger update | Eventual consistency (~1–2s lag via Kafka) |
| Audit log update | Eventual consistency (~1–2s lag via Kafka) |
Failed Kafka messages are retried 3 times with exponential backoff (1s → 2s → 4s). After exhaustion, they are routed to a .dlt topic for manual inspection.
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/wallets |
Create a wallet |
| GET | /api/v1/wallets/{id} |
Get wallet balance |
| POST | /api/v1/wallets/transfers |
Transfer money (Requires Idempotency-Key) |
| GET | /api/v1/ledger |
List transfer ledger (eventually consistent) |
| GET | /api/v1/ledger/{transferId} |
Get the ledger entry by transfer Id |
| GET | /actuator/health |
Health check (kafka + redis indicators) |
| GET | /actuator/prometheus |
Prometheus metrics |
| GET | /swagger-ui.html |
Swagger UI |
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/audit |
List all audit entries |
| GET | /api/v1/audit/wallet/{walletId} |
Audit entries for a specific wallet |
| GET | /actuator/health |
Health check (Circuit breaker state) |
# Start infrastructure
docker composes up -d
# Wait for kafka to be healthy
docker compose logs -f kafka # wait for kafka server started
# Terminal 1, Start wallet service
mvn spring-boot:run
# Terminal 2, Start audit service
cd audit-service; mvn spring-boot:run
| Scenario | Expected | How to test |
|---|---|---|
| Duplicate Transfer | 409 Conflict | Same Idempotency-Key twice |
| Insufficient Balance | 422 Unprocessable | Transfer more than wallet holds |
| Self-transfer | 400 Bad Request | Same wallet as source and target |
| Concurrent transfers | Some get 409 | 10 parallel requests to same wallet |
| Wallet not found | 404 | Random UUID in transfer |
| Wallet service down | Audit still records ( Owner unavailable ) | Stop wallet-service, trigger transfer via kafka |
- Wallet creation
- Transfers
- Idempotency rejection
- Insufficient balance
- Concurrent optimistic lock conflicts
- Eventual consistent ledger population