A REST API for managing bank accounts and money transfers built with Spring Boot.
I wanted to go beyond basic CRUD and tackle something that actually breaks in production — concurrent balance updates. When two clients transfer money at the same time, race conditions can corrupt balances or cause double-spend issues. That's a real problem that needs a real solution, not just a @PutMapping.
So I built this to explore a few things specifically:
- How pessimistic locking prevents concurrent write conflicts at the DB level
- How Kafka fits into a transactional flow (spoiler: it's not magic, ordering matters)
- Liquibase over
ddl-auto=update— something I want to use properly in team environments
The result is a small but realistic banking backend where transfers are atomic, events are asynchronous, and the schema is versioned.
flowchart TB
subgraph Client
UI[HTTP Client / Swagger UI]
end
subgraph API["Transaction API (Spring Boot)"]
C[Controllers]
S[Services]
R[Repositories]
D[Domain Entities]
C --> S --> R --> D
end
subgraph Security
JWT[JwtFilter + JwtUtil]
UDS[UserDetailsServiceImpl]
end
subgraph Messaging
KP[TransactionCreatedEventProducer]
KC[TransactionCreatedEventConsumer]
end
UI -->|REST + Bearer JWT| C
C --> JWT
JWT --> UDS
S -->|after transfer| KP
KP -->|publish| KF[(Kafka)]
KF -->|consume| KC
KC -->|update status| R
R --> PG[(PostgreSQL)]
Transfer flow step by step:
1. POST /transfer comes in
2. Both accounts locked in ID order (prevents deadlocks)
3. Balances updated inside a single @Transactional block
4. Transaction saved as PENDING
5. Event published to Kafka
6. Consumer picks it up → logs details → marks transaction COMPLETED
The PENDING → COMPLETED lifecycle is intentional — it makes the async nature of the flow visible and easier to debug.
| Language | Java 21 |
| Framework | Spring Boot 3.5 — Web, Security, Data JPA, Validation, Actuator |
| Database | PostgreSQL 16 |
| Migrations | Liquibase |
| Messaging | Apache Kafka 7.6 + Zookeeper |
| Auth | Spring Security + JWT (jjwt) |
| Docs | SpringDoc OpenAPI / Swagger UI |
| Build | Maven |
| Infra | Docker + Docker Compose |
You need Docker and Docker Compose v2. That's it.
git clone <repository-url>
cd transaction-api
cp .env.example .env # Windows: copy .env.example .env
docker compose up --buildThis starts PostgreSQL, Zookeeper, Kafka, and the app — in the right order with health checks.
| URL | |
|---|---|
| API | http://localhost:8080 |
| Swagger UI | http://localhost:8080/swagger-ui.html |
| Health | http://localhost:8080/actuator/health |
Quick test after startup:
# Register
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
# Login → copy the token from response
curl -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
# Create account
curl -X POST http://localhost:8080/api/accounts \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ownerId":1,"currency":"USD"}'To stop:
docker compose down # stop
docker compose down -v # stop + wipe volumesIf you want to run the app outside Docker but keep the infrastructure:
docker compose up postgres zookeeper kafka -d
./mvnw spring-boot:runAll endpoints except auth require Authorization: Bearer <token>.
Auth
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/register |
Create account, returns JWT |
| POST | /api/auth/login |
Login, returns JWT |
Accounts
| Method | Path | Description |
|---|---|---|
| POST | /api/accounts |
Create account |
| GET | /api/accounts/{id} |
Get by ID |
| GET | /api/accounts/owner/{ownerId} |
Get all accounts for a user |
Transactions
| Method | Path | Description |
|---|---|---|
| POST | /api/transactions/accounts/{id}/deposit |
Deposit |
| POST | /api/transactions/accounts/{id}/withdraw |
Withdraw |
| POST | /api/transactions/accounts/{id}/transfer |
Transfer (async via Kafka) |
| GET | /api/transactions |
History — filter by accountId, type, status, from, to |
Admin
| Method | Path | Description |
|---|---|---|
| GET | /api/admin/accounts |
All accounts (ROLE_ADMIN only) |
The app works out of the box with Docker Compose. If running standalone, these env vars matter:
Copy .env.example to .env before running Docker Compose. Secrets stay in .env (gitignored); Docker loads them via env_file.
| Variable | Default | Notes |
|---|---|---|
APP_JWT_SECRET |
— | Required. Minimum 32 characters |
SPRING_DATASOURCE_PASSWORD |
postgres |
DB password |
POSTGRES_DB |
transaction_db |
Database name |
SPRING_DATASOURCE_USERNAME |
postgres |
DB user |
SPRING_LOCAL_PORT / SPRING_DOCKER_PORT |
8080 |
Host and container port for the app |
POSTGRES_LOCAL_PORT / POSTGRES_DOCKER_PORT |
5432 |
Host and container port for PostgreSQL |
APP_JWT_EXPIRATION_MS |
3600000 |
Token TTL in ms |
SPRING_DATASOURCE_URL |
jdbc:postgresql://localhost:5432/transaction_db |
Local run only; Docker overrides to postgres host |
SPRING_KAFKA_BOOTSTRAP_SERVERS |
localhost:29092 |
Local run only; Docker overrides to kafka:9092 |
src/main/java/com/banking/transaction/
├── controller/ REST endpoints
├── service/ business logic, locking, transactions
├── repository/ JPA
├── domain/ entities and enums
├── dto/ request/response shapes
├── mapper/ entity ↔ DTO
├── security/ JWT filter and token utils
├── kafka/ producer and consumer
├── config/ Spring config
└── exception/ global error handler
src/main/resources/
├── application.yml
└── db/changelog/ Liquibase migrations
MIT