Back to blog
javabackendmysql

Building a Desktop Banking App With Java Swing

How I built a full-featured banking application with Java Swing, AWT, and MySQL — account management, transactions, and a clean MVC architecture, all without a framework.

TS
Tharun Sai Putta
January 15, 2024
7 min read

Desktop GUI development feels like a relic in a world of React and Flutter, but when I decided to build a banking application as part of my academic work, I chose Java Swing deliberately. Not because it's trendy — it very much isn't — but because it forces you to think clearly about separation of concerns without a framework doing the heavy lifting for you.

This post is a retrospective on what I built, what broke, and what I'd do differently.

Architecture Overview

The application follows a three-layer architecture. I didn't call it MVC at the time — I just tried not to make a mess — but looking back, that's essentially what it is:

┌─────────────────────────────────────┐
│         Presentation Layer          │
│  (Swing JFrames, JPanels, Dialogs)  │
├─────────────────────────────────────┤
│          Business Logic Layer       │
│  (AccountService, TransactionService│
│   AuthService, ValidationUtils)     │
├─────────────────────────────────────┤
│          Data Access Layer          │
│  (JDBC, DAO classes, ConnectionPool)│
└─────────────────────────────────────┘
         ↕ MySQL Database

The key discipline was making sure my Swing panels never touched the database directly. Every UI action — a button click, a form submit — fired a method on a service class. The service handled business rules, then delegated to a DAO. It sounds obvious on paper, but when you're tired and Swing's event dispatch thread is already confusing you, the temptation to just write ResultSet rs = conn.executeQuery(...) inside an ActionListener is real.

Database Design

The schema ended up being four core tables:

CREATE TABLE users (
    user_id     INT AUTO_INCREMENT PRIMARY KEY,
    username    VARCHAR(50) UNIQUE NOT NULL,
    password_hash VARCHAR(64) NOT NULL,
    full_name   VARCHAR(100) NOT NULL,
    created_at  TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
 
CREATE TABLE accounts (
    account_id    INT AUTO_INCREMENT PRIMARY KEY,
    user_id       INT NOT NULL,
    account_number VARCHAR(16) UNIQUE NOT NULL,
    account_type  ENUM('SAVINGS', 'CHECKING') NOT NULL,
    balance       DECIMAL(15, 2) NOT NULL DEFAULT 0.00,
    FOREIGN KEY (user_id) REFERENCES users(user_id)
);
 
CREATE TABLE transactions (
    txn_id        INT AUTO_INCREMENT PRIMARY KEY,
    from_account  INT,
    to_account    INT,
    txn_type      ENUM('DEPOSIT', 'WITHDRAWAL', 'TRANSFER') NOT NULL,
    amount        DECIMAL(15, 2) NOT NULL,
    description   VARCHAR(255),
    txn_timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (from_account) REFERENCES accounts(account_id),
    FOREIGN KEY (to_account)   REFERENCES accounts(account_id)
);

One design decision I debated for a while: should transfers be one transaction record or two? I went with one row per transfer with both from_account and to_account populated. This made querying a single account's history slightly more complex (you need to WHERE from_account = ? OR to_account = ?) but it kept the data clean and prevented orphaned half-records.

Core Features Implementation

The authentication screen was the entry point. Password storage used SHA-256 hashing — not bcrypt, which I'd do differently today, but at least I wasn't storing plaintext:

public class AuthService {
    public boolean authenticate(String username, String rawPassword) {
        String hashed = hashSHA256(rawPassword);
        String query = "SELECT user_id FROM users WHERE username = ? AND password_hash = ?";
        try (Connection conn = ConnectionPool.getConnection();
             PreparedStatement stmt = conn.prepareStatement(query)) {
            stmt.setString(1, username);
            stmt.setString(2, hashed);
            ResultSet rs = stmt.executeQuery();
            return rs.next();
        } catch (SQLException e) {
            logger.error("Authentication query failed", e);
            return false;
        }
    }
 
    private String hashSHA256(String input) {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        byte[] encoded = digest.digest(input.getBytes(StandardCharsets.UTF_8));
        return HexFormat.of().formatHex(encoded);
    }
}

The PreparedStatement usage here was non-negotiable. Early in development I saw a classmate using string concatenation to build SQL queries and nearly had a breakdown explaining SQL injection to them.

The dashboard screen used a JTabbedPane to separate concerns visually — one tab for account overview, one for transaction history, one for transfers. Each tab was its own JPanel subclass with its own refresh logic. This kept things manageable.

Handling Concurrent Transactions

This is where it got genuinely tricky. In a real banking system, concurrent transactions on the same account are a serious correctness problem. My app wasn't going to see real concurrency, but I wanted to model it correctly anyway.

The solution was database-level locking with a transaction:

public boolean transfer(int fromAccountId, int toAccountId, BigDecimal amount) {
    Connection conn = null;
    try {
        conn = ConnectionPool.getConnection();
        conn.setAutoCommit(false);
 
        // Pessimistic lock: SELECT ... FOR UPDATE
        String lockQuery = "SELECT balance FROM accounts WHERE account_id = ? FOR UPDATE";
        
        BigDecimal fromBalance;
        try (PreparedStatement lockStmt = conn.prepareStatement(lockQuery)) {
            lockStmt.setInt(1, fromAccountId);
            ResultSet rs = lockStmt.executeQuery();
            if (!rs.next()) throw new IllegalArgumentException("Account not found");
            fromBalance = rs.getBigDecimal("balance");
        }
 
        if (fromBalance.compareTo(amount) < 0) {
            conn.rollback();
            return false; // insufficient funds
        }
 
        String debit  = "UPDATE accounts SET balance = balance - ? WHERE account_id = ?";
        String credit = "UPDATE accounts SET balance = balance + ? WHERE account_id = ?";
        
        try (PreparedStatement debitStmt = conn.prepareStatement(debit)) {
            debitStmt.setBigDecimal(1, amount);
            debitStmt.setInt(2, fromAccountId);
            debitStmt.executeUpdate();
        }
        try (PreparedStatement creditStmt = conn.prepareStatement(credit)) {
            creditStmt.setBigDecimal(1, amount);
            creditStmt.setInt(2, toAccountId);
            creditStmt.executeUpdate();
        }
 
        // Record the transaction
        recordTransaction(conn, fromAccountId, toAccountId, amount, "TRANSFER");
        
        conn.commit();
        return true;
 
    } catch (SQLException e) {
        if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} }
        logger.error("Transfer failed", e);
        return false;
    } finally {
        if (conn != null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} }
    }
}

The FOR UPDATE lock ensures that if two transfers from the same account somehow fire simultaneously, one waits for the other to complete before reading the balance. The full try-catch-finally for connection cleanup is verbose but necessary — connection leaks in a pool are silent killers.

Swing Layout: The Pain Points

Swing's layout managers deserve their own post, but briefly: GridBagLayout is powerful and maddening. I ended up using a combination of BorderLayout for top-level panels and GridLayout or FlowLayout for forms. The login dialog used a custom GridBagLayout setup that took me an embarrassingly long time to get centered properly on the screen:

JPanel formPanel = new JPanel(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(8, 8, 8, 8);
gbc.fill = GridBagConstraints.HORIZONTAL;
 
gbc.gridx = 0; gbc.gridy = 0;
formPanel.add(new JLabel("Username:"), gbc);
 
gbc.gridx = 1; gbc.gridy = 0;
formPanel.add(usernameField, gbc);
 
gbc.gridx = 0; gbc.gridy = 1;
formPanel.add(new JLabel("Password:"), gbc);
 
gbc.gridx = 1; gbc.gridy = 1;
formPanel.add(passwordField, gbc);

The insets are what make it look like an actual UI instead of components jammed against each other. I learned this the hard way.

What I Learned

A few things stuck with me from this project:

Always use BigDecimal for money. Never float or double. Floating-point rounding errors in financial calculations are not hypothetical — they are guaranteed.

Connection pooling matters even in small apps. Opening a new Connection for every query in Swing's event dispatch thread blocks the UI. I wrote a bare-minimum pool using a LinkedList<Connection> with synchronized getConnection() / releaseConnection() methods, and it made a noticeable difference.

Swing runs on the EDT (Event Dispatch Thread). Any long-running work — database queries, file I/O — must happen off the EDT. I used SwingWorker for this, which has a slightly awkward API but does the job.

The discipline of "never touch the database from a UI class" saved me from a huge mess when I later needed to add input validation. All validation logic lived in the service layer already — I just had to call it.

What I'd Do Differently

If I built this today: bcrypt for passwords (not SHA-256), a proper connection pool like HikariCP instead of my handwritten one, and I'd probably swap Swing for JavaFX which has a more modern approach to declarative UI. I'd also add unit tests for the service layer — completely untested code in a banking app feels wrong in retrospect, even for an academic project.

The project gave me a strong foundation in JDBC, SQL transactions, and the discipline of layered architecture. Those things transfer cleanly to Spring Boot, which I used in later projects.

TS

Tharun Sai Putta

Product Engineer @ Protectt.ai

Building Android security SDKs, IDE plugins, and cross-platform tooling. IIITDM Kancheepuram CSE alumnus.