A production-quality banking ledger system comprising a React 19 frontend and a Java 21 / Spring Boot 3.5 backend. Implements double-entry accounting, distributed locking, JWT authentication, a tiered fee engine, exchange rate integration, and Quartz-scheduled payments.
- Live Frontend: https://banking-ledger-seven.vercel.app/
- Live Backend API: https://doobbl97fb0lb.cloudfront.net/api/v1/actuator/health
Built as a portfolio project targeting backend engineering roles at Tier-1 financial institutions.
| Layer | Technology |
|---|---|
| Language | Java 21 (virtual threads ready) |
| Framework | Spring Boot 3.5.11 |
| Database | MySQL 8.4 (JPA / Hibernate 6) |
| Cache & Locks | Redis 7.2 via Redisson 3.27.2 |
| Security | Spring Security + JWT (jjwt 0.12.5, HS384) |
| Scheduler | Quartz 2.x (in-memory dev, JDBC prod) |
| HTTP Client | Spring WebFlux WebClient |
| Observability | Spring Boot Actuator + Micrometer |
| Build | Maven 3.9+ |
HTTP Request
├── MdcRequestLoggingFilter (requestId injected into every log line)
├── JwtAuthenticationFilter (validates Bearer token, checks blacklist)
└── SecurityFilterChain (RBAC: ADMIN / USER)
↓
Controller (thin — validates input, delegates)
↓
Service (@Transactional, @PreAuthorize, business logic)
├── DistributedLockService (Redisson RLock — outer concurrency guard)
├── LedgerService (double-entry accounting engine)
├── FeeEngine (tiered fee calculation)
├── ExchangeRateService (WebClient + Redis cache)
└── TransactionStateMachine (PENDING → AUTHORIZED → SETTLED / FAILED)
↓
Repository (Spring Data JPA → MySQL)
Every financial transaction produces exactly two immutable LedgerEntry rows:
Transfer $500 from ACC-000001 → ACC-000002
ledger_entries:
account=ACC-000001 type=DEBIT amount=500.00 balance_after=4500.00
account=ACC-000002 type=CREDIT amount=500.00 balance_after=5500.00
Both entries commit atomically via @Transactional. If either fails, both roll back. Money is never created or destroyed.
Three guards prevent double-spending:
- Redisson distributed lock — outer guard, acquired at controller level before
@Transactionalopens. Prevents concurrent requests across multiple app instances. - DB pessimistic lock (
SELECT FOR UPDATE) — inner guard, acquired inside the transaction in consistent ID order to prevent deadlock. - Optimistic lock (
@Versionon entities) — catches the rare case where two transactions slip through both locks and try to modify the same row.
src/main/java/com/bankingcore/bankingledger/
├── config/ Spring configuration beans
├── controller/ REST controllers (thin layer)
├── domain/
│ ├── entity/ JPA entities (User, Account, Transaction, LedgerEntry, ...)
│ ├── enums/ Domain enums (Role, TransactionStatus, EntryType, ...)
│ └── repository/ Spring Data JPA repositories
├── dto/
│ ├── request/ Validated request DTOs
│ └── response/ Response DTOs (never expose entities directly)
├── exception/ Domain exception hierarchy + GlobalExceptionHandler
├── security/
│ ├── filter/ JwtAuthenticationFilter, MdcRequestLoggingFilter, SecurityExceptionHandler
│ └── service/ JwtService, UserDetailsServiceImpl
└── service/ Business logic (LedgerService, FeeEngine, ExchangeRateService, ...)
- Java 21+
- Maven 3.9+
- MySQL 8.x running locally
- Docker (for Redis only)
Connect to your local MySQL as root and run:
CREATE DATABASE banking_ledger CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
GRANT ALL PRIVILEGES ON banking_ledger.* TO 'your_user'@'localhost';
FLUSH PRIVILEGES;docker compose up -dThis starts Redis 7.2 on port 6380 with password authentication and AOF persistence.
cp .env.example .envEdit src/main/resources/application-development.yml and set:
spring:
datasource:
username: your_mysql_user
password: your_mysql_passwordThe development profile activates automatically. All other config has working defaults.
mvn spring-boot:runSpring Boot auto-creates all tables on first start (ddl-auto: update).
The app starts at: http://localhost:8080/api/v1
This application is deployed in a hybrid cloud topology to optimize cost, reliability, and security:
- Frontend: Hosted on Vercel (with automated CI/CD builds on git pushes).
- Backend API: Hosted on AWS ECS Fargate with RDS MySQL 8.4 and ElastiCache Redis 7.2 databases.
- SSL/HTTPS Proxy: Serviced by Amazon CloudFront. Because Vercel serves the React client over HTTPS, modern browsers restrict calling the default HTTP load balancer due to Mixed Content blocking. Placing CloudFront in front of the Application Load Balancer (ALB) terminates SSL/HTTPS using a wildcard
*.cloudfront.netcertificate at zero cost (no custom domain registration needed). - Networking: Configured via Terraform inside a custom VPC with a NAT-less topology. The ALB and ECS containers span multiple public subnets (ECS ingress is restricted strictly to ALB security groups) to enable cheap, direct image downloads from ECR, while DB instances are isolated inside private subnets.
The AWS infrastructure is fully managed via Terraform:
- Initialize and apply the configuration:
cd terraform terraform init terraform apply -auto-approve - Build, tag, and push the backend container to AWS ECR using the output repository URL.
- Perform a rolling update to download the new image and start the container tasks:
aws ecs update-service --cluster banking-ledger-cluster --service banking-ledger-service --force-new-deployment
To avoid consuming AWS credits when the demo is not actively in use, you can destroy all AWS resources and spin them back up later.
Warning
Running terraform destroy will delete all cloud databases, meaning any accounts or transactions created on the demo will be permanently wiped.
Run this command from the terraform/ directory:
terraform destroy -auto-approveBecause AWS dynamically assigns hostnames on creation, the new endpoints (CloudFront, ALB, RDS, Redis) will have different URLs.
- Run
terraform apply -auto-approveinsideterraform/. - Grab the newly generated secure HTTPS CloudFront URL from the output (e.g.,
https://xxxx.cloudfront.net). - Update
VITE_API_BASE_URLin your Vercel Dashboard (or updatefrontend/.env.productionand push to git). - Run a Redeploy on Vercel so the frontend compiles with the new API endpoint.
All endpoints are prefixed with /api/v1. Protected endpoints require Authorization: Bearer <token>.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /auth/register |
None | Register new user |
| POST | /auth/login |
None | Login, receive JWT pair |
| POST | /auth/refresh |
None | Refresh access token |
| POST | /auth/logout |
JWT | Blacklist current token |
Register:
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{"username":"alice","email":"alice@test.com","password":"Password1",
"firstName":"Alice","lastName":"Smith"}'Login:
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"Password1"}'| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /accounts |
JWT | Open new account |
| GET | /accounts |
JWT | List my accounts |
| GET | /accounts/{number} |
JWT | Get account details |
| POST | /admin/accounts/{number}/freeze |
ADMIN | Freeze account |
| POST | /admin/accounts/{number}/activate |
ADMIN | Activate account |
| DELETE | /admin/accounts/{number} |
ADMIN | Close account |
Open an account:
curl -X POST http://localhost:8080/api/v1/accounts \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"accountType":"CHECKING","currency":"USD","initialDeposit":"1000.00"}'| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /transactions/transfer |
JWT | Transfer between accounts |
| GET | /transactions/{id} |
JWT | Get transaction detail |
| GET | /accounts/{number}/transactions |
JWT | Paginated transaction list |
| GET | /accounts/{number}/statement |
JWT | Paginated ledger entries |
| POST | /admin/transactions/deposit |
ADMIN | Deposit funds |
Transfer (with idempotency key):
curl -X POST http://localhost:8080/api/v1/transactions/transfer \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceAccountNumber": "ACC-000001",
"destinationAccountNumber": "ACC-000002",
"amount": "500.00",
"currency": "USD",
"description": "Rent payment",
"idempotencyKey": "unique-client-uuid-here"
}'Fee tiers (applied automatically on every transfer):
| Amount | Rate |
|---|---|
| < $1,000 | 1.50% |
| $1,000 – $9,999 | 1.00% |
| ≥ $10,000 | 0.50% |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| POST | /scheduled-payments |
JWT | Create recurring payment |
| GET | /scheduled-payments |
JWT | List my scheduled payments |
| DELETE | /scheduled-payments/{id} |
JWT | Cancel scheduled payment |
Create a monthly recurring payment:
curl -X POST http://localhost:8080/api/v1/scheduled-payments \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"sourceAccountNumber": "ACC-000001",
"destinationAccountNumber": "ACC-000002",
"amount": "1200.00",
"currency": "USD",
"description": "Monthly rent",
"cronExpression": "0 0 9 1 * ?"
}'Cron format: seconds minutes hours dayOfMonth month dayOfWeek
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /exchange-rates/{from}/{to} |
JWT | Get current rate (cached 60 min) |
| DELETE | /admin/exchange-rates/{from}/{to}/cache |
ADMIN | Evict cached rate |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /users/me |
JWT | Get own profile |
| GET | /admin/users |
ADMIN | List all users |
| GET | /admin/users/{id} |
ADMIN | Get user by ID |
| POST | /admin/users/{id}/promote |
ADMIN | Promote to ADMIN |
| POST | /admin/users/{id}/lock |
ADMIN | Lock user account |
| DELETE | /admin/users/{id} |
ADMIN | Soft-delete user |
curl http://localhost:8080/api/v1/actuator/health# Requires HTTP Basic: actuator_admin / ActuatorDev@55
curl -u actuator_admin:ActuatorDev@55 \
http://localhost:8080/api/v1/actuator/metrics/banking.transactions.settled.total
curl -u actuator_admin:ActuatorDev@55 \
http://localhost:8080/api/v1/actuator/metrics/banking.fees.collected.totalcurl -u actuator_admin:ActuatorDev@55 \
http://localhost:8080/api/v1/actuator/prometheusAll HTTP requests are logged with:
requestId— unique UUID per request (also returned asX-Request-Idresponse header)userId— authenticated username- Method, URI, status code, duration
All errors follow RFC 7807 Problem Detail:
{
"type": "https://banking-ledger.io/errors/insufficient-funds",
"title": "Insufficient Funds",
"status": 422,
"detail": "Account ACC-000001 has insufficient funds. Available: 100.00, Required: 500.00.",
"instance": "/api/v1/transactions/transfer",
"timestamp": "2026-03-22T16:00:00Z",
"requestId": "a1b2c3d4-..."
}Why BigDecimal everywhere?
double cannot represent 0.1 exactly in binary floating-point. 0.1 + 0.2 == 0.30000000000000004 in Java. On financial amounts, this causes rounding errors that accumulate across millions of transactions. BigDecimal is exact. Every monetary field in the codebase uses BigDecimal with RoundingMode.HALF_EVEN (banker's rounding).
Why immutable LedgerEntry?
Banking regulations require a complete, unalterable audit trail. If a transaction was wrong, you create a REVERSAL with new entries in the opposite direction. The @PreUpdate hook on LedgerEntry throws IllegalStateException if Hibernate tries to issue an UPDATE — making mutation impossible at the application layer.
Why distributed locks outside @Transactional?
Spring @Transactional works through a proxy. Calling a @Transactional method from within the same class bypasses the proxy — the annotation is silently ignored. The distributed lock is acquired at the controller level, which then calls ledgerService.transfer() through the injected Spring proxy. This ensures: Redis lock held → DB transaction open → DB pessimistic lock held — all three guards active simultaneously.
Why soft delete?
No user or account row is ever physically deleted. Soft deletion sets deleted = true and deleted_at = now(). All repositories use @SQLRestriction("deleted = false") to filter deleted records automatically. This preserves audit trails and allows account recovery.
mvn testMIT