A production-grade financial ledger backend implementing double-entry bookkeeping with a blockchain-inspired tamper-proof audit trail.
Built with Java 17, Spring Boot 3, PostgreSQL, Redis, and Docker — designed for horizontal scaling behind an Nginx load balancer.
- Features
- Architecture
- Tech Stack
- Getting Started
- API Reference
- Engineering Deep Dives
- Security Summary
- Rate Limiting
- Testing
- CI/CD Pipeline
- Docker Services
- Project Structure
- Configuration
- Author
| Feature | Description |
|---|---|
| 🏦 Double-Entry Bookkeeping | Every transaction creates balanced debit and credit entries — the foundation of real-world accounting |
| 🔁 Idempotency | Unique referenceId per transaction prevents double-spending under network retries or duplicate requests |
| 🔒 Optimistic Locking | @Version-based concurrency control with @Retryable automatic retry — no pessimistic locks, no throughput bottlenecks |
| ⛓️ Blockchain Audit Trail | SHA-256 hash-chained ledger entries create a tamper-evident, forensically auditable history |
| 🛡️ Distributed Rate Limiting | Per-user Bucket4j token buckets synced via Redis — limits hold correctly across all app instances |
| ⚡ Redis Balance Caching | Balance lookups cached with 10-min TTL, owner-ID verification on every cache hit, graceful DB fallback |
| 🔐 JWT Authentication | Stateless auth via HttpOnly; Secure; SameSite=Strict cookies — tokens never exposed to JavaScript |
| 👮 Role-Based Access Control | ROLE_USER and ROLE_ADMIN enforced at method level via @PreAuthorize |
| 📊 Paginated Statements | Account history with configurable sort, pagination, and automatic CREDIT/DEBIT classification |
| ✅ Input Validation | Bean Validation on all request DTOs with a global @RestControllerAdvice — no stack traces ever reach clients |
| 🐳 Multi-Instance Deployment | Multi-stage Dockerfile, Compose orchestration, Nginx round-robin load balancer across two app instances |
| 🤖 CI/CD | GitHub Actions: test → build → artifact upload, triggered on every push and pull request |
| 📖 Swagger UI | Auto-generated interactive API documentation via SpringDoc OpenAPI |
┌──────────────────────┐
│ Client │
│ (Browser / Postman) │
└──────────┬───────────┘
│
┌──────────▼───────────┐
│ Nginx (Port 80) │
│ Load Balancer │
│ Round-Robin │
└────────┬──────┬──────┘
│ │
┌───────────▼──┐ ┌─▼────────────┐
│ App #1 │ │ App #2 │
│ Port 8081 │ │ Port 8082 │
│ Spring Boot │ │ Spring Boot │
│ + JWT Auth │ │ + JWT Auth │
└──────┬───┬───┘ └──┬────┬──────┘
│ │ │ │
┌───────────▼───▼────────▼────▼──────────┐
│ │
┌──────▼──────┐ ┌──────────▼──────┐
│ PostgreSQL │ │ Redis │
│ Port 5432 │ │ Port 6379 │
│ │ │ │
│ • users │ │ • Balance cache │
│ • accounts │ │ • Rate limit │
│ • ledger │ │ buckets │
│ • txns │ │ │
└──────────────┘ └─────────────────┘
Controllers → Rate Limit Check → Services → Repositories → PostgreSQL
↕
Redis
(cache + buckets)
- Controllers — HTTP layer, authentication extraction, rate limit enforcement
- Services — all business logic: double-entry writes, hash chaining, cache management, ownership checks
- Repositories — Spring Data JPA interfaces, zero boilerplate
- Redis — balance cache with owner-ID verification + distributed rate limit token buckets
| Relationship | Type | Detail |
|---|---|---|
users ↔ roles |
Many-to-Many | Linked via user_role junction table. A user can hold ROLE_USER, ROLE_ADMIN, or both. |
users → accounts |
One-to-Many | One user can own multiple accounts. Each account carries a user_id foreign key. |
transaction → ledger_entries |
One-to-Many | Every transaction generates exactly 2 ledger entries — one debit, one credit. |
accounts → ledger_entries |
One-to-Many | All ledger entries for an account form its full transaction history, linked by hash and previous_hash into a tamper-evident chain. |
| Layer | Technology | Version | Purpose |
|---|---|---|---|
| Language | Java | 17 LTS | Core runtime |
| Framework | Spring Boot | 3.3.5 | Application framework |
| Security | Spring Security + JJWT | 0.13 | Authentication and authorization |
| ORM | Spring Data JPA / Hibernate | — | Database access, optimistic locking |
| Database | PostgreSQL | 15-alpine | Primary ACID-compliant store |
| Cache | Redis + Lettuce | Alpine | Balance caching and rate limit state |
| Rate Limiting | Bucket4j | 8.16 | Distributed token-bucket algorithm |
| Retry | Spring Retry | — | Automatic retry on optimistic lock failures |
| API Docs | SpringDoc OpenAPI | 2.6.0 | Swagger UI generation |
| DTO Mapping | ModelMapper | 3.2.4 | Entity ↔ DTO conversion |
| Build | Maven + Wrapper | — | Reproducible dependency management |
| Containerisation | Docker (multi-stage) | — | Lean production images |
| Orchestration | Docker Compose | — | Multi-service local deployment |
| Load Balancer | Nginx | Alpine | Round-robin traffic distribution |
| CI/CD | GitHub Actions | — | Automated test and build pipeline |
| Testing | JUnit 5 + Mockito | — | Unit and service layer tests |
| Logging | LogBack | Slf4j | Debug level console logging |
- Docker and Docker Compose
- (Optional for local dev) Java 17 + Maven
# Clone the repository
git clone https://github.com/mayank1008-tech/CoreLedgerEngine.git
cd CoreLedgerEngine
# Start the full stack: 2 app instances, PostgreSQL, Redis, Nginx, pgAdmin
docker compose up --build| URL | Service |
|---|---|
http://localhost |
Application (via Nginx load balancer) |
http://localhost:8081 |
App instance 1 (direct) |
http://localhost:8082 |
App instance 2 (direct) |
http://localhost/swagger-ui.html |
Interactive API documentation |
http://localhost:5050 |
pgAdmin — database UI (admin@admin.com / admin) |
# Start only the infrastructure dependencies
docker compose up postgres redis -d
# Build and run
./mvnw spring-boot:run
# Run tests
./mvnw clean test| Field | Value |
|---|---|
| Username | systemAdmin |
| Password | pass123 |
| Roles | ROLE_ADMIN + ROLE_USER |
CENTRAL_BANKaccount is also created automatically. It is the system counterparty for all deposits and withdrawals — no manual setup required.
# 1. Register
curl -X POST http://localhost/api/auth/signup \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "pass123", "email": "alice@test.com"}'
# 2. Login — copy the JWT token from the response
curl -X POST http://localhost/api/auth/signin \
-H "Content-Type: application/json" \
-d '{"username": "alice", "password": "pass123"}'
# 3. Create an account
curl -X POST http://localhost/api/account/create \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-d '{"accountName": "AliceSavings", "currency": "INR"}'Full interactive documentation at http://localhost/swagger-ui.html
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/api/auth/signup |
❌ | Register a new user |
POST |
/api/auth/signin |
❌ | Login and receive JWT token |
POST |
/api/auth/signout |
✅ | Logout, clear cookie |
GET |
/api/auth/user |
✅ | Get current user details |
GET |
/api/auth/username |
✅ | Get current username |
| Method | Endpoint | Auth | Rate Limit | Description |
|---|---|---|---|---|
POST |
/api/account/create |
✅ | General | Create a new bank account |
GET |
/api/account/list |
✅ | General | List all your accounts |
GET |
/api/account/balance/{id} |
✅ | General | Get balance (Redis cached) |
POST |
/api/deposit |
✅ | Transaction | Deposit funds from CENTRAL_BANK |
POST |
/api/withdraw |
✅ | Transaction | Withdraw funds to CENTRAL_BANK |
POST |
/api/transfer |
✅ | Transaction | Transfer between two accounts |
GET |
/api/statement/{id} |
✅ | General | Paginated account statement |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/api/admin/audit/{accountId} |
✅ ROLE_ADMIN |
Verify ledger hash chain integrity |
curl -X POST http://localhost/api/transfer \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <YOUR_JWT_TOKEN>" \
-d '{
"fromAccountId": "550e8400-e29b-41d4-a716-446655440000",
"toAccountId": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"amount": 1500.00,
"referenceId": "TXN-20260315-001"
}'{ "message": "Transfer successful", "status": true }GET /api/statement/{accountId} — example response
Query params: ?pageNumber=0&pageSize=10&sortBy=loggedAt&sortOrder=desc
{
"content": [
{
"amount": 1500.00,
"date": "2026-03-15T10:30:00",
"referenceId": "TXN-20260315-001",
"type": "CREDIT"
},
{
"amount": -500.00,
"date": "2026-03-14T15:20:00",
"referenceId": "TXN-20260314-002",
"type": "DEBIT"
}
],
"pageNumber": 0,
"pageSize": 10,
"totalElements": 47,
"totalPages": 5,
"lastPage": false
}GET /api/admin/audit/{accountId} — example responses
{ "status": "VALID", "brokenAt": null }
{ "status": "CORRUPTED: DATA MODIFIED", "brokenAt": "ledger-entry-uuid" }
{ "status": "CORRUPTED: BROKEN CHAIN", "brokenAt": "ledger-entry-uuid" }These are the non-trivial decisions in the project — the things worth explaining in an interview.
Every financial operation creates exactly two ledger entries — a debit and a credit. This is enforced at the code level inside a single @Transactional method; there is no code path that writes one entry without the other. If either write fails, the entire transaction rolls back.
Transfer ₹1,500 from Alice → Bob
LedgerEntry #1 │ account = Alice │ amount = −1,500 │ DEBIT
LedgerEntry #2 │ account = Bob │ amount = +1,500 │ CREDIT
Sum across all entries = 0 ✅ Money moved, not created.
- Deposits →
CENTRAL_BANK → User Account - Withdrawals →
User Account → CENTRAL_BANK - Transfers →
Account A → Account B
Every operation flows through the same double-entry path, including deposits and withdrawals. The system's total balance across all accounts always sums to zero — a fundamental accounting invariant that is never violated.
Every transaction request carries a client-supplied referenceId. A UNIQUE database constraint on this column means the same transaction can never be processed twice, regardless of retries or duplicate button clicks.
Request #1 → referenceId: "TXN-2026-001" → ✅ Processed, money moved
Request #2 → referenceId: "TXN-2026-001" → ❌ HTTP 409 Conflict
The check happens at two levels: an application-level lookup before processing (fast fail with a clear error), and the database UNIQUE constraint as an iron-clad guarantee against concurrent duplicate requests that slip through the application check simultaneously.
Pessimistic locking would serialize all transfers through a queue and destroy throughput. Instead, every Account entity carries a @Version field that Hibernate auto-increments on each update.
Thread A reads Account X → version = 0, balance = ₹1,000
Thread B reads Account X → version = 0, balance = ₹1,000
Thread A commits → UPDATE ... WHERE version = 0 → ✅ version → 1
Thread B commits → UPDATE ... WHERE version = 0 → ❌ ObjectOptimisticLockingFailureException
(version is now 1, not 0)
@Retryable catches the exception
→ Thread B re-reads → version = 1, balance = ₹900
→ Retries up to 3×, 1s backoff
→ ✅ Commits at version 2, balance = ₹800
Both transfers complete correctly. No balance lost. No serialization.
Every ledger entry stores a SHA-256 hash of its own content, chained to the previous entry's hash. This creates a cryptographic link that makes silent modification of historical records detectable.
Entry #1 │ prevHash = "MANK_1008" ← genesis constant
│ hash = SHA256(prevHash + amount + referenceId + timestamp)
│ = "abc123..."
Entry #2 │ prevHash = "abc123..." ← points to Entry #1
│ hash = SHA256(prevHash + amount + referenceId + timestamp)
│ = "def456..."
Entry #3 │ prevHash = "def456..." ← points to Entry #2
│ hash = "ghi789..."
Modify any historical entry → its hash changes → the next entry's prevHash no longer matches → chain breaks. The admin audit endpoint walks the entire chain and pinpoints the exact entry where corruption occurred.
Rate limiting uses the token bucket algorithm via Bucket4j, with buckets persisted in Redis rather than application memory. This is the critical design choice: limits hold correctly even when the same user's requests hit different app instances behind the load balancer.
General endpoints → 10 tokens / minute (greedy — bursts allowed)
Transaction endpoints → 1 token / minute (interval — strictly one per minute)
Redis key: rate_limit:{TYPE}:{USER_ID}
User hits app1 → consumes 1 token from Redis bucket
User hits app2 → same Redis bucket → 0 tokens left → HTTP 429
Circumventing the limit by round-robining between servers is impossible.
Balance lookups are cached in Redis with a 10-minute TTL. The cache stores the ownerId alongside the balance inside an AccountCacheDTO, and verifies ownership on every cache hit — preventing a subtle vulnerability where one user's cached balance could be read by a different authenticated user.
Cache key: balance:{accountId}
Cache value: AccountCacheDTO { ownerId: UUID, balance: BigDecimal }
Cache HIT
├─ ownerId matches authenticated user → return balance immediately ✅
└─ ownerId mismatch → HTTP 401 Unauthorized ❌
Cache MISS → query database → populate cache → return balance
Redis failure → log warning → query database → return balance
(graceful degradation, zero user impact)
Cache is invalidated for both sender and receiver accounts on every transfer.
JWTs are delivered as HttpOnly; Secure; SameSite=Strict cookies, not Authorization headers. This design choice means the token is never accessible via JavaScript — XSS attacks cannot steal it even if they execute on the page.
Sign in → Set-Cookie: jwtCookie=eyJ...; HttpOnly; Secure; SameSite=Strict
Browser stores and auto-sends the cookie on every subsequent request
document.cookie cannot read this token from JavaScript
Each request → AuthTokenFilter intercepts before controller
→ Validates JWT signature + expiry
→ Populates SecurityContext with authenticated identity
→ Controller runs with guaranteed authentication
Two roles enforced at method level via @PreAuthorize, with an additional ownership check inside every service method.
| Role | Permissions |
|---|---|
ROLE_USER |
Create accounts · transfer money · view own statements and balances |
ROLE_ADMIN |
All of the above · audit any account's blockchain chain |
Authorization operates at two distinct layers: Spring Security rejects the request before the method body runs, and the service layer explicitly verifies the authenticated user owns the target account — preventing privilege escalation even with a valid token.
The statement endpoint exposes ledger history with full pagination metadata, configurable sort field and direction, and a derived transaction type — positive amount = CREDIT, negative = DEBIT. No raw internal ledger entity structure leaks to the client.
GET /api/statement/{accountId}?pageNumber=0&pageSize=20&sortBy=loggedAt&sortOrder=desc
All request DTOs use Bean Validation (@NotBlank, @Size, @Positive, @Pattern). A @RestControllerAdvice global exception handler maps every exception type — validation failures, auth errors, ownership violations, optimistic lock conflicts, duplicate reference IDs, insufficient funds — to a consistent, structured JSON response. No stack traces, no internal state, no Spring error pages ever reach the client.
| Layer | Protection |
|---|---|
| Transport | JWT via HttpOnly; Secure; SameSite=Strict cookie — JavaScript cannot access the token |
| Passwords | BCrypt hashing at strength 10 |
| Access control | RBAC via @PreAuthorize — method-level enforcement before the method runs |
| Ownership | Every API call explicitly verifies the authenticated user owns the target account |
| Concurrency | Optimistic locking (@Version) prevents race conditions without serializing requests |
| Idempotency | Unique referenceId with DB UNIQUE constraint prevents duplicate transactions |
| Rate limiting | Per-user distributed limits via Redis — holds correctly across all app instances |
| Validation | Bean Validation on all DTOs, global exception handler for consistent error responses |
| Session | Fully stateless — no server-side sessions, no CSRF risk |
| Category | Limit | Refill Strategy | Endpoints |
|---|---|---|---|
| General | 10 req / min | Greedy (bursts allowed) | Balance, Statement, Account list, Create account |
| Transaction | 1 req / min | Interval (strict, no bursts) | Transfer, Deposit, Withdraw |
Limits are stored in Redis and shared across all application instances. A user cannot circumvent the transaction limit by alternating requests between app1 and app2.
Tests are written with JUnit 5 and Mockito, using @ExtendWith(MockitoExtension.class) for proper unit isolation. Service dependencies are mocked so tests exercise business logic without touching the database or Redis.
# Run all tests
./mvnw clean test
# Run a specific test class
./mvnw test -Dtest=AccountServiceImplTest
# Run a specific method
./mvnw test -Dtest=AccountServiceImplTest#transfer_shouldThrow_whenAmountIsZero| Test File | Framework | What is tested |
|---|---|---|
AccountServiceImplTest |
JUnit 5 + Mockito | Transfer, Deposit, Balance cache, Statement — 17 test cases |
AdminServiceImplTest |
JUnit 5 + Mockito | Blockchain audit: valid chain, broken chain, data modified |
AuthEntryPointJwtTest |
JUnit 5 + Mockito | JWT 401 error response format |
| Area | Scenarios |
|---|---|
| Transfer | Zero amount · negative amount · duplicate referenceId · account not found · unauthorized sender · insufficient funds · CENTRAL_BANK bypass · successful transfer with cache invalidation |
| Deposit | Account not found · unauthorized · CENTRAL_BANK account missing · successful delegation to transfer |
| Balance | Cache hit with owner match · cache hit with owner mismatch (throws 401) · cache miss with DB fallback and cache population · Redis read failure (falls back to DB) · Redis write failure (still returns balance) |
| Statement | Account not found · unauthorized access · correct CREDIT/DEBIT classification |
Techniques used:
@Spy @InjectMocks— verifies that deposit correctly delegates to the transfer methodArgumentCaptor— asserts exact values on entities saved to the repositorydoThrowonRedisConnectionFailureException— tests graceful Redis failure handling
Runs on every push to main / develop and on every pull request to main:
push / PR
│
▼
[Job 1: Unit Tests] — ./mvnw clean test (JDK 17 Temurin)
│ Upload test reports to GitHub
│
▼ (only if Job 1 passes)
[Job 2: Build JAR] — ./mvnw clean package -DskipTests
│
▼
[Upload Artifact] — JAR stored on GitHub Actions for inspection
Six containers, one command:
| Service | Image | Port | Purpose |
|---|---|---|---|
app1 |
Local multi-stage build | 8081 | Spring Boot instance 1 |
app2 |
Local multi-stage build | 8082 | Spring Boot instance 2 |
nginx |
nginx:alpine |
80 | Round-robin load balancer |
postgres |
postgres:15-alpine |
5432 | Primary relational database |
redis |
redis:alpine |
6379 | Cache + distributed rate limiting |
pgadmin |
dpage/pgadmin4 |
5050 | Database inspection UI |
The Dockerfile uses a multi-stage build: Maven compiles and packages in a full JDK image, then only the JAR is copied into a slim JRE image. The final image contains no build tooling and is significantly smaller.
src/main/java/com/example/ledgersystem/
├── LedgerSystemApplication.java # Entry point
├── DataSeeder.java # Seeds roles, systemAdmin, CENTRAL_BANK on startup
├── config/
│ └── AppConst.java # Pagination constants
├── controller/
│ ├── AccountController.java # Banking endpoints + rate limit enforcement
│ ├── AuthController.java # Signup, signin, signout
│ └── AdminController.java # Audit endpoint (ROLE_ADMIN only)
├── enums/
│ ├── RateLimitType.java # GENERAL, TRANSACTION
│ ├── TransactionStatus.java # PENDING, PROCESSING, COMPLETED, FAILED
│ └── TransactionType.java # TRANSFER, DEPOSIT, WITHDRAWAL, REVERSAL
├── Exceptions/
│ ├── APIexception.java
│ ├── AccountNotFound.java
│ ├── DuplicateTransactionException.java
│ ├── InsufficientFundsException.java
│ └── MyGlobalExceptionHandler.java # @RestControllerAdvice
├── model/
│ ├── Account.java # @Version for optimistic locking
│ ├── LedgerEntry.java # hash + prevHash for blockchain chain
│ ├── Transaction.java # Unique referenceId constraint
│ ├── Role.java # RBAC role entity
│ └── User.java # User with @ManyToMany roles
├── Payloads/
│ ├── AccountCacheDTO.java # Stored in Redis (balance + ownerId)
│ ├── AccountStatementDTO.java
│ ├── ApiResponse.java
│ ├── Auditresponse.java
│ ├── CreateAccountDTO.java
│ ├── DepositRequestDTO.java
│ ├── MoneyTransferDTO.java
│ ├── StatementResponse.java # Paginated response wrapper
│ └── WithdrawRequestDTO.java
├── repositories/
│ ├── AccountRepository.java
│ ├── LedgerEntryRepository.java
│ ├── RoleRepository.java
│ ├── TransactionRepository.java
│ └── UserRepository.java
├── Security/
│ ├── Config/
│ │ ├── RedisConfig.java # Lettuce + Bucket4j ProxyManager
│ │ └── WebSecurityConfig.java # Filter chain, CORS, session policy
│ ├── jwt/
│ │ ├── AuthEntryPointJwt.java # 401 response handler
│ │ ├── AuthTokenFilter.java # JWT extraction on every request
│ │ └── JwtUtils.java # Token generation, validation, parsing
│ └── Services/
│ ├── UserDetailsImpl.java # Spring Security UserDetails wrapper
│ └── UserDetailsServiceImpl.java # Loads user from DB by username
├── service/
│ ├── AccountService.java # Interface
│ ├── AccountServiceImpl.java # Core ledger business logic
│ ├── AdminService.java # Interface
│ ├── AdminServiceImpl.java # Hash chain audit verification
│ └── RateLimitingService.java # Bucket4j + Redis bucket resolution
└── utils/
├── AuthUtils.java # SecurityContext helpers
└── HashUtils.java # SHA-256 hashing
cp src/main/resources/application.properties.example src/main/resources/application.properties| Property | Description | Default |
|---|---|---|
spring.datasource.url |
PostgreSQL connection URL | jdbc:postgresql://localhost:5432/ledger_db |
spring.data.redis.host |
Redis hostname | localhost |
spring.data.redis.port |
Redis port | 6379 |
spring.app.jwtSecret |
JWT signing key (Base64-encoded) | — |
spring.app.jwtExpirationMs |
Token expiry in milliseconds | — |
spring.app.jwtCookieName |
Cookie name for JWT storage | — |
Mayank Jain
- GitHub: @mayank1008-tech
- Email: mj.mayank98@gmail.com
- Repository: CoreLedgerEngine
- LinkedIn: LinkedIn
Built from scratch. No shortcuts. Every design decision made for a reason.
