Skip to main content

Roadmap for building a banking app

A learning project that builds a banking app with React + Spring Boot, from a monolith to microservices across 3 phases. Banking is a good topic for the hardest parts of backend work: money movement has to be exactly right, security has several layers, and data has to stay consistent once the system is split apart.

Three-phase roadmap​

Phase 1: Monolith corePhase 2: Features and securityPhase 3: Microservices
ArchitectureModular monolithModular monolithMicroservices
FeaturesSign-up, sign-in, OTP, internal transfers, transaction historySimple KYC, financial reports, account managementInterbank transfers (simulated), asynchronous notifications
SecurityPassword hashing, JWT, HTTPSCSRF/XSS protection, data encryption (AES), CSPService-to-service authentication
DataACID transactionsFake data for testingRetry, transactional outbox, saga; event sourcing (optional)
DevOpsDocker, Docker ComposeCI/CD with GitHub Actions, a staging environmentCentralized logging and monitoring (ELK, Prometheus)
What you learnIntegrating React with Spring Boot, ACID transactions, DockerWeb security, CI/CD, generating fake dataMicroservice design, message queues, observing a distributed system

A modular monolith in phases 1–2 keeps the focus on the domain first; clear module boundaries make the split into services in phase 3 easier. When to split, and what it costs: see Microservices architecture.

Suggested stack​

ComponentChoiceWhy
BackendSpring BootA strong ecosystem for financial work: Spring Security, @Transactional, validation
FrontendReactA fast interactive SPA; it escapes content by default, which lowers XSS risk
DatabasePostgreSQLACID transactions, row locks, constraints (CHECK balance >= 0, foreign keys) — what a ledger needs
ContainersDocker, Docker ComposeThe same environment from a dev machine to production
Message queueRabbitMQ or KafkaAsynchronous communication, decoupling services, background work such as sending notifications
note

The original proposal suggested MongoDB. MongoDB does support multi-document transactions, but for a ledger — where you need strict constraints and aggregate queries — a relational database is the safer default.

Core features​

FeatureBackend (Spring Boot)Frontend (React)
Sign-up, sign-in, OTPSign-up and sign-in APIs, password hashing, creating and checking OTPs, issuing JWTsSign-up, sign-in, and OTP forms; signed-in state
Accounts and historyAccount-info API; transaction history with pagination and filtersAccount list and detail; a history table that can be filtered and sorted
Transfers and reportsTransfer API inside one ACID transaction; an aggregation API for reportsTransfer form with a confirmation step; reports as charts and tables

An OTP should expire quickly (a few minutes), be single-use, cap the number of wrong attempts, and be stored on the server only as a hash.

Security​

LayerMeasures
Authentication and authorizationJWT for a stateless API, stored in an HttpOnly, Secure, SameSite cookie; OAuth2 when sign-in goes through a third party
PasswordsNever store them in plain text; hash with Argon2id or bcrypt (each user already gets their own salt)
Encryption in transitTLS/HTTPS for every connection
Encryption at restEncrypt sensitive data (PII) with AES-256-GCM; the key lives in a secret manager or KMS, not in code and not in the same database
CSRFRequired when the JWT is in a cookie: Spring Security's CSRF token plus a SameSite cookie
XSSValidate and sanitize input, rely on React's escaping (avoid dangerouslySetInnerHTML), and turn on a Content Security Policy

Transactions and data consistency​

Inside one database, a transfer is all or nothing: debit A and credit B commit together, and a failure at any step rolls the whole thing back.

TransferService.java
@Transactional
public Transfer transfer(UUID fromId, UUID toId, BigDecimal amount, String idempotencyKey) {
// A repeated request (flaky network, the user clicks twice) returns the previous result
var existing = transfers.findByIdempotencyKey(idempotencyKey);
if (existing.isPresent()) return existing.get();

// Lock the two rows in a fixed id order to avoid a deadlock
var first = fromId.compareTo(toId) < 0 ? fromId : toId;
var second = first.equals(fromId) ? toId : fromId;
accounts.findByIdForUpdate(first); // SELECT ... FOR UPDATE
accounts.findByIdForUpdate(second);

var from = accounts.getReferenceById(fromId);
var to = accounts.getReferenceById(toId);
if (from.getBalance().compareTo(amount) < 0) throw new InsufficientFundsException();

from.debit(amount);
to.credit(amount);
return transfers.save(new Transfer(fromId, toId, amount, idempotencyKey));
}
  • Store money as BigDecimal (or as an integer in the smallest unit). Do not use double.
  • idempotencyKey has a UNIQUE constraint in the database, so two identical requests do not create two transfers.
  • Better still: double-entry bookkeeping — each transfer writes one debit line and one credit line, and the balance is the sum of the lines.

DevOps and testing​

AreaToolPurpose
CI/CDGitHub ActionsAutomatically build, test, package, and deploy
MonitoringPrometheus + GrafanaCPU, memory, request latency, error rate
LoggingELK stack or LokiCentralized logs for diagnosing incidents
SandboxA separate staging environmentTry new features without affecting dev or production
API mocksWireMock, Postman mock serverSimulate third-party APIs (payment gateways, other banks)
Fake dataDatafaker (Java), Faker.jsRealistic data covering many test scenarios

When you deploy to a VPS: see Ubuntu server setup.