This service manages user wallets with operations to create wallets, retrieve balances (current and historical), and post deposits, withdrawals, and transfers. It is designed to run multiple stateless instances concurrently without distributed locks, while preventing duplicates and preserving a complete audit trail.
- How to run the project
- Decisions
- Assumptions
- Architecture Overview
- Infrastructure
- Running tests
- Running the API locally
- Time tracking
Pre-requisites:
- Docker and Docker Compose.
- (Optional) Java 21 for unit/integration tests
- Clone this repo and run all commands in its root.
- Make sure your docker engine is running.
docker compose \
-f infra/docker-compose.yml \
-f infra/docker-compose.lb.yml \
-f infra/docker-compose.observability.yml \
-f infra/docker-compose.zipkin.yml \
up -d --build --scale app=3Credentials:
user: admin
password: admin
Run manually through the API documentation (has endpoints, schemas and errors). Here you can create wallets, do deposits, withdrawals and transfers, and check balances and ledgers.
Run a load test and do lots of requests:
K6_SCRIPT=transfers.js docker compose -f infra/docker-compose.k6.yml run --rm k6You can replace transfers.js with withdraws.js or deposits.js to test those operations.
- Given the importance of the project, I decided to skip authentication implementation in order to focus on consistency and performance.
- I found that idempotency is crucial for this project, so I implemented it using a combination of a unique request identifier and database transaction.
- I designed the system as stateless, which allows for easy scaling and load balancing.
- Future cleanup schedules should be implemented to clean old idempotency keys.
- There are two ways of fetching balance history: by informing desired date and time, and checking ledgers by time range filter.
- Single currency; amounts stored as DECIMAL(19,4).
- No overdrafts: balances must never go negative.
- Only “now” postings (no backdated/future-dated effective_at).
- All IDs server-generated UUID v4 (wallet_id, tx_id), except for idempotency_key.
- Idempotency-Key header is required for all POSTs; responses are cached for safe retries.
- Idempotency state machine is intentionally simple: only in_progress, succeeded and failed.
- An in_progress entry is considered stale after 5 seconds.
- If a retry arrives after that window, the stale in_progress is treated as abandoned and the retry proceeds (stale takeover). This ensures transient errors never block progress.
- The ledger is the audit source of truth.
- Each request that mutates state runs in a single ACID transaction:
- Validate input and Idempotency-Key.
- Check idempotency cache; if present and completed, return cached result.
- Execute atomic SQL (conditional updates + inserts).
- Insert append-only ledger entries.
- Store final response in idempotency_keys.
- Transfers update two wallets within one transaction.
- Docker Compose is used to orchestrate the services.
- Added observability stack (Grafana, Loki, Promtail) for metrics and logs.
- Added tracing stack (Micrometer, Zipkin) for distributed tracing.
- Nginx is used as a load balancer to distribute requests across multiple API instances.
- k6 is used for load testing the API.
- Testcontainers are used for integration tests.
Classic unit tests made with JUnit and Mockito.
./api/mvnw -f api/pom.xml testMade with Testcontainers, they run a real Postgres instance in a container.
./api/mvnw -f api/pom.xml -Dit.test=ConcurrencyIT failsafe:integration-test failsafe:verifyMade with k6, they target the load-balancer so it spreads across 3 instances.
K6_SCRIPT=transfers.js docker compose -f infra/docker-compose.k6.yml run --rm k6
K6_SCRIPT=deposits.js docker compose -f infra/docker-compose.k6.yml run --rm k6
K6_SCRIPT=withdraws.js docker compose -f infra/docker-compose.k6.yml run --rm k6docker compose \
-f infra/docker-compose.yml \
-f infra/docker-compose.lb.yml \
-f infra/docker-compose.observability.yml \
-f infra/docker-compose.zipkin.yml \
down- Run a Postgres in a container
docker compose -f infra/docker-compose.yml up db -d- Run the API locally:
./api/mvnw -f api/pom.xml spring-boot:run- Check documentation in local Swagger-UI
You may have to disable CORS validation in your browser.
[1h] - research (atomicity and idempotency patterns)
[2h] - development of transaction entities
[2h] - implementation of idempotency
[2h] - testing (unit, integration and load) and bugfix
[2h] - infra setup
[1h] - documentation and review