This document outlines the complete plan to migrate the Finance Bank Back-Office System from its current in-memory storage (HashMap-based repositories) to a PostgreSQL relational database using JDBC (Java Database Connectivity).
The integration follows the existing Clean Architecture — only the Repository Layer will change. The Service Layer, Domain Models, and Presentation Layer remain untouched.
| Concern | In-Memory (Current) | PostgreSQL + JDBC |
|---|---|---|
| Data Persistence | ❌ Lost on restart | ✅ Permanent |
| Concurrency | ❌ Not thread-safe | ✅ ACID transactions |
| Scalability | ❌ Limited by RAM | ✅ Scales to millions of records |
| Reporting | ❌ Manual iteration | ✅ Full SQL power |
| Audit Logs | ❌ Volatile | ✅ Durable audit trail |
| Production-Ready | ❌ No | ✅ Yes |
Presentation Layer (JavaFX)
↓
Service Layer (BankService, AuthenticationService, AuthorizationService)
↓
Repository Layer ← ONLY THIS LAYER CHANGES
↓
JDBC Data Access Layer (NEW)
↓
PostgreSQL Database
Zero changes to:
- Domain models (
Customer,Account,Transaction,Employee) - Service layer business logic
- JavaFX controllers and views
CREATE TABLE employees (
system_id VARCHAR(50) PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
national_id VARCHAR(14) NOT NULL UNIQUE,
email VARCHAR(255),
phone VARCHAR(20),
role VARCHAR(20) NOT NULL
CHECK (role IN ('CS', 'TELLER', 'MANAGER')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);CREATE TABLE customers (
system_id VARCHAR(50) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
national_id VARCHAR(14) NOT NULL UNIQUE,
email VARCHAR(255),
phone VARCHAR(20),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_customers_national_id ON customers(national_id);CREATE TABLE accounts (
account_number VARCHAR(16) PRIMARY KEY,
account_type VARCHAR(10) NOT NULL
CHECK (account_type IN ('SAVINGS', 'CURRENT')),
balance NUMERIC(15, 2) NOT NULL DEFAULT 0.00,
overdraft_limit NUMERIC(15, 2) DEFAULT 0.00,
owner_id VARCHAR(50) NOT NULL
REFERENCES customers(system_id) ON DELETE RESTRICT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_accounts_owner_id ON accounts(owner_id);CREATE TABLE transactions (
transaction_id VARCHAR(50) PRIMARY KEY,
account_number VARCHAR(16) NOT NULL
REFERENCES accounts(account_number) ON DELETE RESTRICT,
transaction_type VARCHAR(15) NOT NULL
CHECK (transaction_type IN ('DEPOSIT', 'WITHDRAWAL', 'TRANSFER')),
amount NUMERIC(15, 2) NOT NULL,
fee NUMERIC(15, 2) NOT NULL DEFAULT 0.00,
total NUMERIC(15, 2) NOT NULL,
balance_after NUMERIC(15, 2) NOT NULL,
performed_by_id VARCHAR(50) NOT NULL,
performed_by_name VARCHAR(255) NOT NULL,
performed_by_role VARCHAR(20) NOT NULL,
timestamp TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_transactions_account ON transactions(account_number);
CREATE INDEX idx_transactions_employee ON transactions(performed_by_id);
CREATE INDEX idx_transactions_timestamp ON transactions(timestamp DESC);<!-- PostgreSQL JDBC Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>42.7.1</version>
</dependency>
<!-- HikariCP Connection Pool (recommended) -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
<version>5.1.0</version>
</dependency>src/main/resources/database.properties
db.url=jdbc:postgresql://localhost:5432/financebank
db.username=bank_user
db.password=secure_password
db.pool.size=10
db.pool.timeout=30000
db.pool.idle-timeout=600000
db.pool.max-lifetime=1800000package com.finance.bank.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import java.io.InputStream;
import java.util.Properties;
public class DatabaseConfig {
private static HikariDataSource dataSource;
static {
try (InputStream input = DatabaseConfig.class
.getClassLoader()
.getResourceAsStream("database.properties")) {
Properties props = new Properties();
props.load(input);
HikariConfig config = new HikariConfig();
config.setJdbcUrl(props.getProperty("db.url"));
config.setUsername(props.getProperty("db.username"));
config.setPassword(props.getProperty("db.password"));
config.setMaximumPoolSize(
Integer.parseInt(props.getProperty("db.pool.size", "10"))
);
config.setConnectionTimeout(
Long.parseLong(props.getProperty("db.pool.timeout", "30000"))
);
dataSource = new HikariDataSource(config);
} catch (Exception e) {
throw new RuntimeException("Failed to initialize database connection pool", e);
}
}
public static DataSource getDataSource() {
return dataSource;
}
public static void close() {
if (dataSource != null && !dataSource.isClosed()) {
dataSource.close();
}
}
}package com.finance.bank.repository;
import com.finance.bank.exception.DuplicateNationalIdException;
import com.finance.bank.exception.InvalidNationalIdException;
import com.finance.bank.model.Customer;
import com.finance.bank.config.DatabaseConfig;
import com.finance.bank.util.IdGenerator;
import com.finance.bank.util.NationalIdValidator;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class CustomerRepository {
public Customer save(String name, String nationalId)
throws DuplicateNationalIdException, InvalidNationalIdException {
NationalIdValidator.validateNationalId(nationalId);
String sql = """
INSERT INTO customers (system_id, name, national_id)
VALUES (?, ?, ?)
""";
String systemId = IdGenerator.generateCustomerId();
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, systemId);
stmt.setString(2, name);
stmt.setString(3, nationalId);
stmt.executeUpdate();
return new Customer(systemId, name, nationalId);
} catch (SQLException e) {
if (e.getSQLState().equals("23505")) { // unique_violation
throw new DuplicateNationalIdException(
"Customer with National ID " + nationalId + " already exists"
);
}
throw new RuntimeException("Failed to save customer", e);
}
}
public Customer findByNationalId(String nationalId) {
String sql = "SELECT * FROM customers WHERE national_id = ?";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, nationalId);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return mapRowToCustomer(rs);
}
return null;
} catch (SQLException e) {
throw new RuntimeException("Failed to find customer", e);
}
}
public List<Customer> findAll() {
String sql = "SELECT * FROM customers ORDER BY created_at DESC";
List<Customer> customers = new ArrayList<>();
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
customers.add(mapRowToCustomer(rs));
}
return customers;
} catch (SQLException e) {
throw new RuntimeException("Failed to retrieve customers", e);
}
}
private Customer mapRowToCustomer(ResultSet rs) throws SQLException {
return new Customer(
rs.getString("system_id"),
rs.getString("name"),
rs.getString("national_id"),
rs.getString("email"),
rs.getString("phone")
);
}
}package com.finance.bank.repository;
import com.finance.bank.exception.DuplicateAccountException;
import com.finance.bank.model.*;
import com.finance.bank.config.DatabaseConfig;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class AccountRepository {
public void save(Account account) throws DuplicateAccountException {
String sql = """
INSERT INTO accounts (account_number, account_type, balance, overdraft_limit, owner_id)
VALUES (?, ?, ?, ?, ?)
""";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, account.getAccountNumber());
stmt.setString(2, account.getAccountType().name());
stmt.setBigDecimal(3, account.getBalance());
if (account instanceof CurrentAccount current) {
stmt.setBigDecimal(4, current.getOverdraftLimit());
} else {
stmt.setBigDecimal(4, java.math.BigDecimal.ZERO);
}
stmt.setString(5, account.getOwner().getSystemId());
stmt.executeUpdate();
} catch (SQLException e) {
if (e.getSQLState().equals("23505")) {
throw new DuplicateAccountException(
"Account " + account.getAccountNumber() + " already exists"
);
}
throw new RuntimeException("Failed to save account", e);
}
}
public void updateBalance(String accountNumber, java.math.BigDecimal newBalance) {
String sql = "UPDATE accounts SET balance = ? WHERE account_number = ?";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setBigDecimal(1, newBalance);
stmt.setString(2, accountNumber);
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to update account balance", e);
}
}
public Account findByNumber(String accountNumber) {
String sql = """
SELECT a.*, c.name, c.national_id, c.email, c.phone
FROM accounts a
JOIN customers c ON a.owner_id = c.system_id
WHERE a.account_number = ?
""";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, accountNumber);
ResultSet rs = stmt.executeQuery();
if (rs.next()) {
return mapRowToAccount(rs);
}
return null;
} catch (Exception e) {
throw new RuntimeException("Failed to find account", e);
}
}
public boolean exists(String accountNumber) {
String sql = "SELECT 1 FROM accounts WHERE account_number = ?";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, accountNumber);
ResultSet rs = stmt.executeQuery();
return rs.next();
} catch (SQLException e) {
throw new RuntimeException("Failed to check account existence", e);
}
}
public List<Account> findAll() {
String sql = """
SELECT a.*, c.name, c.national_id, c.email, c.phone
FROM accounts a
JOIN customers c ON a.owner_id = c.system_id
ORDER BY a.created_at DESC
""";
List<Account> accounts = new ArrayList<>();
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
accounts.add(mapRowToAccount(rs));
}
return accounts;
} catch (Exception e) {
throw new RuntimeException("Failed to retrieve accounts", e);
}
}
private Account mapRowToAccount(ResultSet rs) throws Exception {
Customer owner = new Customer(
rs.getString("owner_id"),
rs.getString("name"),
rs.getString("national_id"),
rs.getString("email"),
rs.getString("phone")
);
String type = rs.getString("account_type");
if ("SAVINGS".equals(type)) {
SavingsAccount acc = new SavingsAccount(rs.getString("account_number"), owner);
acc.setBalance(rs.getBigDecimal("balance"));
return acc;
} else {
CurrentAccount acc = new CurrentAccount(
rs.getString("account_number"),
owner,
rs.getBigDecimal("overdraft_limit")
);
acc.setBalance(rs.getBigDecimal("balance"));
return acc;
}
}
public void clear() {
// For test environment only
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DELETE FROM transactions");
stmt.execute("DELETE FROM accounts");
} catch (SQLException e) {
throw new RuntimeException("Failed to clear accounts", e);
}
}
}package com.finance.bank.repository;
import com.finance.bank.model.*;
import com.finance.bank.config.DatabaseConfig;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class TransactionRepository {
public void save(Transaction transaction) {
String sql = """
INSERT INTO transactions (
transaction_id, account_number, transaction_type,
amount, fee, total, balance_after,
performed_by_id, performed_by_name, performed_by_role,
timestamp
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, transaction.getTransactionId());
stmt.setString(2, transaction.getAccountNumber());
stmt.setString(3, transaction.getType().name());
stmt.setBigDecimal(4, transaction.getAmount());
stmt.setBigDecimal(5, transaction.getFee());
stmt.setBigDecimal(6, transaction.getTotal());
stmt.setBigDecimal(7, transaction.getBalanceAfter());
stmt.setString(8, transaction.getPerformedByEmployeeId());
stmt.setString(9, transaction.getPerformedByEmployeeName());
stmt.setString(10, transaction.getPerformedByRole().name());
stmt.setTimestamp(11, Timestamp.from(transaction.getTimestamp()));
stmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException("Failed to save transaction", e);
}
}
public List<Transaction> findByAccountNumber(String accountNumber) {
String sql = """
SELECT * FROM transactions
WHERE account_number = ?
ORDER BY timestamp ASC
""";
List<Transaction> result = new ArrayList<>();
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql)) {
stmt.setString(1, accountNumber);
ResultSet rs = stmt.executeQuery();
while (rs.next()) {
result.add(mapRowToTransaction(rs));
}
return result;
} catch (SQLException e) {
throw new RuntimeException("Failed to retrieve transactions", e);
}
}
public List<Transaction> findAll() {
String sql = "SELECT * FROM transactions ORDER BY timestamp DESC";
List<Transaction> result = new ArrayList<>();
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
PreparedStatement stmt = conn.prepareStatement(sql);
ResultSet rs = stmt.executeQuery()) {
while (rs.next()) {
result.add(mapRowToTransaction(rs));
}
return result;
} catch (SQLException e) {
throw new RuntimeException("Failed to retrieve all transactions", e);
}
}
public int count() {
String sql = "SELECT COUNT(*) FROM transactions";
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
Statement stmt = conn.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
return rs.next() ? rs.getInt(1) : 0;
} catch (SQLException e) {
throw new RuntimeException("Failed to count transactions", e);
}
}
private Transaction mapRowToTransaction(ResultSet rs) throws SQLException {
// Reconstruct a lightweight Transaction for read-only display
return new TransactionRecord(
rs.getString("transaction_id"),
TransactionType.valueOf(rs.getString("transaction_type")),
rs.getBigDecimal("amount"),
rs.getBigDecimal("fee"),
rs.getBigDecimal("balance_after"),
rs.getString("account_number"),
rs.getString("performed_by_id"),
rs.getString("performed_by_name"),
EmployeeRole.valueOf(rs.getString("performed_by_role")),
rs.getTimestamp("timestamp").toInstant()
);
}
public void clear() {
try (Connection conn = DatabaseConfig.getDataSource().getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("DELETE FROM transactions");
} catch (SQLException e) {
throw new RuntimeException("Failed to clear transactions", e);
}
}
}Critical operations (deposit/withdraw) must be wrapped in database transactions to guarantee consistency.
// Example: Atomic deposit — balance update + transaction record in one DB transaction
public Transaction deposit(Employee employee, String accountNumber, BigDecimal amount)
throws InvalidAmountException {
authorizationService.ensureCanDeposit(employee);
try (Connection conn = DatabaseConfig.getDataSource().getConnection()) {
conn.setAutoCommit(false); // Begin JDBC transaction
try {
// 1. Lock and fetch account row
Account account = accountRepository.findByNumberForUpdate(accountNumber, conn);
account.deposit(amount);
// 2. Update balance in DB
accountRepository.updateBalance(accountNumber, account.getBalance(), conn);
// 3. Record transaction
Transaction tx = new Transaction(
TransactionType.DEPOSIT, amount, BigDecimal.ZERO,
account.getBalance(), account, employee
);
transactionRepository.save(tx, conn);
conn.commit(); // Commit both writes atomically
return tx;
} catch (Exception e) {
conn.rollback(); // Rollback on any failure
throw e;
}
} catch (SQLException e) {
throw new RuntimeException("Database error during deposit", e);
}
}-- Run as postgres superuser
CREATE DATABASE financebank;
CREATE USER bank_user WITH PASSWORD 'secure_password';
GRANT ALL PRIVILEGES ON DATABASE financebank TO bank_user;psql -U bank_user -d financebank -f schema.sqlAdd postgresql and HikariCP to pom.xml (see Section 5.1).
Create src/main/resources/database.properties (see Section 5.2).
Add DatabaseConfig.java to com.finance.bank.config package (see Section 5.3).
Replace all three repository classes with JDBC versions. The interfaces remain identical — BankService does not change.
private BankService() {
// Before: in-memory repositories
// this.customerRepository = new CustomerRepository();
// After: JDBC repositories (same interface, different implementation)
this.customerRepository = new CustomerRepository(); // now uses JDBC internally
this.accountRepository = new AccountRepository();
this.transactionRepository = new TransactionRepository();
// Services unchanged
this.authorizationService = new AuthorizationService();
this.accountService = new AccountService(authorizationService, accountRepository);
this.transactionService = new TransactionService(
authorizationService, accountService, transactionRepository
);
}INSERT INTO employees (system_id, username, password_hash, national_id, role)
VALUES
(gen_random_uuid()::text, 'manager', 'manager123', '29505051234567', 'MANAGER'),
(gen_random_uuid()::text, 'teller', 'teller123', '29505051234568', 'TELLER'),
(gen_random_uuid()::text, 'cs', 'cs123456', '29505051234569', 'CS');
⚠️ Important: Replace plain-text passwords with BCrypt hashes before any production deployment.
All existing JUnit tests in BankingSystemTest.java should pass without modification. The BankService.reset() method now issues DELETE statements instead of clearing HashMaps.
com.finance.bank/
├── app/
│ └── BankEmployeeCLI.java
├── config/ ← NEW
│ └── DatabaseConfig.java
├── exception/ (unchanged)
├── model/ (unchanged)
├── presentation/ ← JavaFX layer
│ ├── MainApp.java
│ └── controllers/
├── repository/ ← JDBC implementations
│ ├── CustomerRepository.java
│ ├── AccountRepository.java
│ └── TransactionRepository.java
├── service/ (unchanged)
│ ├── BankService.java
│ ├── AuthenticationService.java
│ ├── AuthorizationService.java
│ ├── AccountService.java
│ └── TransactionService.java
├── util/ (unchanged)
└── view/ (unchanged)
src/main/resources/
├── database.properties ← NEW
└── schema.sql ← NEW
| Enhancement | Description |
|---|---|
| Password Hashing | Replace plain-text passwords with BCrypt via spring-security-crypto |
| Flyway Migrations | Version-controlled schema evolution with org.flywaydb:flyway-core |
| Spring Data JPA | Replace raw JDBC with JPA repositories for reduced boilerplate |
| Spring Boot | Add REST API layer for web/mobile client support |
| Docker Compose | Containerize PostgreSQL for reproducible dev/test environments |
| Read Replicas | Separate read/write connections for reporting queries |
| Audit Table | Dedicated audit_log table capturing every state change |
| Soft Deletes | Add deleted_at column instead of hard deletes for compliance |
| Phase | What Changes | What Stays the Same |
|---|---|---|
| Config | Add DatabaseConfig, database.properties |
— |
| Schema | New PostgreSQL tables | — |
| Repositories | JDBC implementations | Public interfaces (same method signatures) |
| Services | Only constructor wiring | All business logic |
| Models | — | All domain objects |
| Tests | reset() uses DELETE |
All assertions, all test scenarios |
| JavaFX UI | — | All controllers and views |
The key principle is interface stability: because BankService depends on repository method signatures — not implementations — swapping HashMap storage for PostgreSQL storage requires zero changes to any layer above the repository.
Finance Bank Engineering Team — PostgreSQL JDBC Integration Plan v1.0