A ledger-based wallet service built for high-traffic applications like gaming platforms or loyalty reward systems. This service manages application-specific virtual credits (Gold Coins, Diamonds, Loyalty Points) with full transactional integrity, concurrency safety, and idempotent operations.
- Swagger UI: Render URL
| Technology | Why |
|---|---|
| Java 21 + Spring Boot 4 | Mature ecosystem for building transactional backend services. @Transactional provides declarative transaction management, and Spring Data JPA eliminates boilerplate database code. |
| PostgreSQL 16 | Industry standard relational database with excellent ACID transaction support, row-level locking (SELECT ... FOR UPDATE), and robust concurrency handling critical for a financial service. |
| Docker + Docker Compose | One-command setup (docker-compose up) ensures the project run without installing Java or PostgreSQL locally. |
| Double-Entry Ledger | Instead of simply updating a balance column, every transaction creates two ledger entries (one debit, one credit). This provides full auditability and ensures the system is always internally consistent, the sum of all ledger entries across all accounts is always zero. |
Controller Layer (REST API)
↓ validates input, returns HTTP responses
Service Layer (Business Logic)
↓ handles transaction flows, locking, idempotency
Repository Layer (Database Access)
↓ JPA repositories with pessimistic locking support
PostgreSQL (ACID Transactions)
┌──────────────┐ ┌──────────────┐ ┌──────────────────┐
│ assets │ │ users │ │ accounts │
├──────────────┤ ├──────────────┤ ├──────────────────┤
│ id (PK) │ │ id (PK) │ │ id (PK) │
│ name │◄────┤ name │ │ user_id (FK) │──► users
│ code (UNIQUE)│ │ email(UNIQUE)│ │ asset_id (FK) │──► assets
│ created_at │ │ created_at │ │ type (USER/SYSTEM)│
└──────────────┘ └──────────────┘ │ balance │
│ created_at │
└──────────────────┘
│
▼
┌──────────────────────---┐ ┌──────────────────────┐
│ transactions │ │ ledger_entries │
├──────────────────────---┤ ├──────────────────────┤
│ id (PK) │ │ id (PK) │
│ type (TOPUP/BONUS/ │◄────│ transaction_id (FK) │
│ SPEND) │ │ account_id (FK) │──► accounts
│ idempotency_key(UNIQUE) │ │ type (CREDIT/DEBIT) │
│ status │ │ amount │
│ created_at │ │ created_at │
└──────────────────────---┘ └──────────────────────┘
Key design decisions:
accounts.balanceis a cached value for fast reads. The source of truth is always the ledger entries.- System accounts (Treasury) have
user_id = NULLandtype = SYSTEM. They act as the source/destination for all credit movements. - Every transaction creates exactly 2 ledger entries — one debit and one credit ensuring the system always sums to zero.
git clone https://github.com/Muskan244/InternalWalletService.git
cd internal-wallet-service
docker-compose up --buildThis automatically:
- Starts a PostgreSQL 16 database
- Runs the
seed.sqlscript (creates tables + inserts initial data) - Starts the Spring Boot application on port 8080
The API will be available at http://localhost:8080
Easily accessible through Swagger UI at http://localhost:8080/swagger-ui.html
Prerequisites: Java 21, PostgreSQL 16
# 1. Create the database
createdb wallet
# 2. Run the seed script
psql -d wallet -f src/main/resources/seed.sql
# 3. Start the application
./gradlew bootRunThe seed script creates the following initial state:
Asset Types:
| Code | Name |
|---|---|
| GOLD_COINS | Gold Coins |
| DIAMONDS | Diamonds |
| LOYALTY_POINTS | Loyalty Points |
Users:
| Name | |
|---|---|
| Aditi Singh | aditisingh@gmail.com |
| Priya Jha | priyajha@gmail.com |
Initial Balances:
- Aditi: 500 Gold Coins (backed by a seed TOPUP transaction + ledger entries)
- Priya: 300 Gold Coins (backed by a seed TOPUP transaction + ledger entries)
- Treasury (SYSTEM): -800 Gold Coins (net outflow to users, this is expected for a system account)
POST /api/v1/transactions
Handles all three transaction flows: top-up, bonus, and spend.
Request Body:
{
"type": "TOPUP",
"email": "aditisingh@gmail.com",
"assetCode": "GOLD_COINS",
"amount": 100,
"idempotencyKey": "unique-key-123"
}| Field | Type | Required | Description |
|---|---|---|---|
| type | String | Yes | TOPUP, BONUS, or SPEND |
| String | Yes | User's email address | |
| assetCode | String | Yes | Asset code (e.g., GOLD_COINS) |
| amount | Long | Yes | Amount (must be positive) |
| idempotencyKey | String | Yes | Unique key to prevent duplicate processing |
Success Response (200):
{
"transactionId": "550e8400-e29b-41d4-a716-446655440000",
"type": "TOPUP",
"amount": 100,
"balanceAfter": 600,
"status": "SUCCESS",
"createdAt": "2026-02-22T12:00:00Z"
}Error Responses:
400— Insufficient balance, invalid amount, missing required fields404— User not found, asset not found, account not found
GET /api/v1/wallet/balance?email={email}&assetCode={assetCode}
Success Response (200):
{
"email": "aditisingh@gmail.com",
"assetName": "Gold Coins",
"balance": 600
}GET /api/v1/transactions?email={email}
Returns all transactions for a user in reverse chronological order.
In a high-traffic system, two requests can arrive simultaneously to spend from the same wallet. Without protection:
- Both read balance as 100
- Both check "is 100 >= 80?" - both pass
- Both debit 80
- Balance goes to -60 - data corruption
Pessimistic Locking: Before modifying any account balance, the service acquires a row-level lock using SELECT ... FOR UPDATE (via JPA's @Lock(LockModeType.PESSIMISTIC_WRITE)). The second concurrent request blocks until the first completes, then reads the updated balance.
Request 1: BEGIN → LOCK account (acquires lock) → check balance → debit → COMMIT
Request 2: BEGIN → LOCK account (waits...) ────────────────────────────→ (acquires lock) → check balance → sees updated balance → proceeds
Deadlock Avoidance: Every transaction involves two accounts (e.g., user account + Treasury). If two transactions lock these accounts in different orders, a deadlock can occur. To prevent this, accounts are always locked in ascending UUID order regardless of the transaction direction. This eliminates circular wait conditions.
Network failures can cause clients to retry requests. Without protection, the same top-up could be applied multiple times.
Every request includes a client-generated idempotencyKey. Before processing:
- Check if a transaction with this key already exists in the database
- If yes → return the original result without reprocessing
- If no → process normally and store the key
The idempotency_key column has a UNIQUE constraint in the database as a safety net even if the application-level check has a race condition, the database prevents duplicate insertions.