Build a Banking REST API with Spring Boot

Build a secured banking REST API with Spring Boot, JPA, and MySQL.

Introduction

30 Second Summary

Every time you tap a banking app to check your balance or transfer money, a backend API is handling that request. It validates your identity, checks your funds, moves the money, and sends back a response.

In this project, you will build a banking REST API from scratch using Spring Boot. You will create endpoints for creating accounts, depositing, withdrawing, and transferring funds, all secured with user authentication and backed by a MySQL database.

What You'll Build

A fully functional banking REST API backend with Spring Boot that authenticates users, manages accounts, and processes transactions against a MySQL database.

By the end of this project, you'll have:

  • A Spring Boot Maven project with a clean layered architecture separating entities, DTOs, services, and controllers.
  • A secured REST API using HTTP Basic authentication with BCrypt-hashed passwords stored in MySQL.
  • Full banking operations including account creation, balance checks, deposits, withdrawals, and fund transfers with proper validation.
  • Secret Mission: Add transaction history tracking so users can view a full log of their account activity.

Are there any prerequisites?

You should be comfortable with Java fundamentals including classes, interfaces, and annotations. You will also need MySQL installed locally on your machine.

Generate and Configure the Spring Boot Project

Every banking backend starts with a foundation. You need a project structure, a database connection, and the right libraries wired together before you can write any business logic.

In this step, you will generate a Spring Boot project using Spring Initializr, connect it to a local MySQL database, and verify the whole stack starts cleanly.

In this step, get ready to:
  • Generate a Spring Boot project with the required dependencies.
  • Create a MySQL database and configure the application connection.
  • Run the app and confirm it starts on port 8080.
Generate the project on Spring Initializr

IntelliJ IDEA Community Edition does not include a built-in Spring Initializr wizard. You will use the web-based tool at start.spring.io to generate the project instead.

  • Open your browser and navigate to start.spring.io.
  • Under Project, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 3.5.14.
  • In the Group field, enter com.example.
  • In the Artifact field, enter banking-api.
  • Click ADD DEPENDENCIES and add the following four dependencies: Spring Web, Spring Data JPA, Spring Security, and MySQL Driver.
  • Click GENERATE to download the ZIP file.

What do these dependencies do?

Spring Web provides the REST controller framework for handling HTTP requests. Spring Data JPA gives you repository interfaces that talk to your database without writing SQL.

Spring Security adds authentication and authorization to every endpoint. MySQL Driver is the JDBC connector that lets Java communicate with your MySQL server.

  • Extract the downloaded banking-api.zip file to your Desktop.
  • Open IntelliJ IDEA.
  • Click File in the top menu bar.
  • Click Open.
  • Navigate to your Desktop and select the banking-api folder, then click OK.
  • If IntelliJ asks whether to trust the project, click Trust Project.

Wait for IntelliJ to finish importing the Maven dependencies. You will see a progress bar in the bottom status bar while it downloads libraries.

IntelliJ stuck on importing?

Maven dependency downloads can take a few minutes on a fresh project. If it stalls for more than 5 minutes, click View then Tool Windows then Maven, and click the refresh icon to re-trigger the import.

help me debug Maven import issues in IntelliJ IDEA

Create the MySQL database and configure the connection

Your Spring Boot app needs a database to persist accounts and users. You will create an empty MySQL database now and tell the app where to find it.

  • Open a terminal window by pressing Cmd+Space (macOS) or the Windows key (Windows), then typing Terminal and pressing Enter.
  • Log in to MySQL and create the database by running these commands:
mysql -u root -p

You will be prompted to enter your MySQL root password. Type it and press Enter (the characters will not appear as you type).

  • Once you see the mysql> prompt, create the database by running:
CREATE DATABASE banking_api;
  • Exit the MySQL shell by running:
exit;

What did this do?

You created an empty MySQL database called banking_api. No tables exist yet. Hibernate (the JPA implementation inside Spring Boot) will auto-create tables for you later when you define your entity classes.

Command not found or access denied?

If you see a "command not found" error, MySQL might not be in your system PATH. Try running it with the full path: /usr/local/mysql/bin/mysql -u root -p on macOS.

If you see "Access denied", double-check you are using the correct root password you set when installing MySQL.

help me connect to MySQL from my terminal

Now you need to tell Spring Boot how to connect to this database.

  • In IntelliJ, expand src then main then resources in the Project panel on the left.
  • Double-click application.properties to open it.
  • Replace the file contents with the following configuration:
# MySQL connection settings
spring.datasource.url=jdbc:mysql://localhost:3306/banking_api
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# JPA/Hibernate settings
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
  • Replace your-mysql-password-here with your actual MySQL root password.

What does each property do?

  • spring.datasource.url points Spring Boot at your local MySQL server and the banking_api database.
  • spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or modify database tables to match your Java entity classes.
  • spring.jpa.show-sql=true prints every SQL query to the console so you can see what Hibernate does behind the scenes.
  • spring.datasource.driver-class-name specifies the MySQL JDBC driver class (com.mysql.cj.jdbc.Driver).
  • Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Not sure what your MySQL password is?

This is the password you set when you first installed MySQL. If you never set one, try leaving the password field empty (delete your-mysql-password-here entirely). Some installations default to no password for the root user.

help me find or reset my MySQL root password

✔️ Awesome, I've got everything!

Great. Double check you have saved application.properties with your actual MySQL password filled in.

ⓧ I'd like to double check the full code

Your application.properties should look exactly like this (with your own password substituted):

spring.datasource.url=jdbc:mysql://localhost:3306/banking_api
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
Run the app to verify everything connects

With the database created and connection configured, you can now start the application to confirm everything is wired together correctly.

  • Open the terminal inside IntelliJ by clicking View, then Tool Windows, then Terminal.
  • Make sure you are in the banking-api project root (you should see pom.xml if you run ls).
  • Start the application by running:
mvn spring-boot:run

The first run will download additional dependencies and compile your code. This may take 1-2 minutes.

  • Look for two key lines in the console output:

A line that says Using generated security password: followed by a random string. This is Spring Security generating a temporary password because you have not configured your own users yet.

A line that confirms the app started on port 8080 (something like Started BankingApiApplication).

Why is there a generated password?

When Spring Security is on the classpath but you have not defined any user accounts, Spring Boot creates a default user named user with a randomly generated password. It prints this password to the console so you can test authenticated endpoints during development.

You will replace this with your own MySQL-backed users in a later step.

  • Stop the app by pressing Ctrl+C in the terminal.

App fails to start?

If you see a connection error mentioning banking_api, confirm the database exists by running mysql -u root -p -e "SHOW DATABASES;" in a separate terminal. You should see banking_api in the list.

If you see "Access denied for user 'root'", the password in application.properties does not match your actual MySQL root password. Update it and try again.

help me debug Spring Boot failing to connect to MySQL

Your foundation is solid. The Spring Boot app starts, connects to MySQL, and Spring Security is active. Next up, you will define the data model by creating JPA entity classes and repository interfaces.

Define Entities and Repositories

Your Spring Boot project is configured and connects to MySQL. Now you need to tell the application what data it stores and how that data is structured.

In this step, you will create JPA entities that map Java classes to database tables. You will also create repository interfaces that give you built-in methods for querying and saving data.

In this step, get ready to:
  • Build User and Account entity classes with JPA annotations.
  • Create repository interfaces for database access.
  • Verify Hibernate auto-creates the database tables.
Create the User entity

A JPA entity is a plain Java class annotated so that Hibernate knows to map it to a database table. Each field becomes a column.

  • In the IntelliJ project sidebar, right-click the com.example.bankingapi package (inside src/main/java).
  • Select New then Package.
  • Name the package entity and press Enter.
  • Right-click the new entity package, select New then Java Class, and name it User.
  • Replace the entire contents of User.java with the following code:
package com.example.bankingapi.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String role;

    public User() {}

    public User(String username, String password, String role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }
}

What does this code do?

@Entity tells Hibernate this class maps to a database table. @Table(name = "users") specifies the exact table name in MySQL.

@Id marks id as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells MySQL to auto-increment it.

@Column(unique = true, nullable = false) adds database constraints directly from your Java code. The empty constructor is required by JPA, while the parameterized constructor is for your own convenience.

  • Save User.java.
  • Confirm IntelliJ shows no red underlines in the file. If you see errors, check that your import is jakarta.persistence.* (not javax.persistence).

Seeing red underlines on the annotations?

IntelliJ may need to download the Jakarta Persistence dependency. Click File then Invalidate Caches / Restart, or open the Maven tool window on the right and click the refresh icon to re-import dependencies.

help me fix red underlines on my JPA annotations in IntelliJ

Create the Account entity

The Account entity represents a bank account. It has a ManyToOne relationship to User because one user can own multiple accounts.

  • Right-click the entity package, select New then Java Class, and name it Account.
  • Replace the entire contents of Account.java with the following code:
package com.example.bankingapi.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String accountNumber;

    @Column(nullable = false)
    private String ownerName;

    @Column(nullable = false)
    private BigDecimal balance = BigDecimal.ZERO;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public Account() {}

    public Account(String accountNumber, String ownerName, User user) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = BigDecimal.ZERO;
        this.user = user;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
}

What does this code do?

@ManyToOne(fetch = FetchType.LAZY) creates a foreign key relationship. Many accounts can belong to one user. FetchType.LAZY means the associated User is only loaded from the database when you actually access it.

@JoinColumn(name = "user_id") tells Hibernate to create a user_id column in the accounts table that references the users table. BigDecimal is used for monetary values because floating-point types like double introduce rounding errors.

  • Save Account.java.
  • Confirm IntelliJ shows no red underlines. Both entity files should compile cleanly.

Cannot resolve symbol BigDecimal?

Make sure the import java.math.BigDecimal is present at the top of the file. IntelliJ can auto-import it for you. Place your cursor on the red text and press Alt+Enter (Windows) or Option+Enter (macOS) to trigger the auto-import suggestion.

help me resolve import errors in my Account entity

✔️ Awesome, I've got everything!

Great. Double-check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is User.java:

package com.example.bankingapi.entity;

import jakarta.persistence.*;

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String role;

    public User() {}

    public User(String username, String password, String role) {
        this.username = username;
        this.password = password;
        this.role = role;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public String getRole() { return role; }
    public void setRole(String role) { this.role = role; }
}

Here is Account.java:

package com.example.bankingapi.entity;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false)
    private String accountNumber;

    @Column(nullable = false)
    private String ownerName;

    @Column(nullable = false)
    private BigDecimal balance = BigDecimal.ZERO;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    public Account() {}

    public Account(String accountNumber, String ownerName, User user) {
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = BigDecimal.ZERO;
        this.user = user;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
    public User getUser() { return user; }
    public void setUser(User user) { this.user = user; }
}
Create the repository interfaces

Spring Data JPA repositories give you full CRUD operations without writing any SQL. You define an interface, extend JpaRepository, and Spring generates the implementation at runtime.

  • Right-click the com.example.bankingapi package, select New then Package, and name it repository.
  • Right-click the repository package, select New then Java Class, choose Interface, and name it UserRepository.
  • Replace the entire contents of UserRepository.java with the following code:
package com.example.bankingapi.repository;

import com.example.bankingapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}
  • Create another interface in the repository package named AccountRepository.
  • Replace the entire contents of AccountRepository.java with the following code:
package com.example.bankingapi.repository;

import com.example.bankingapi.entity.Account;
import com.example.bankingapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByAccountNumber(String accountNumber);
    List<Account> findByUser(User user);
}

What do these repositories do?

By extending JpaRepository<User, Long>, Spring automatically provides methods like save(), findById(), findAll(), and delete() without you writing any implementation.

The custom methods like findByUsername and findByAccountNumber use Spring Data's query derivation. Spring reads the method name and generates the SQL query automatically. findByUsername(String username) becomes SELECT * FROM users WHERE username = ? behind the scenes.

  • Save both UserRepository.java and AccountRepository.java.
  • Confirm IntelliJ shows no red underlines in either file.

Cannot resolve JpaRepository?

This means IntelliJ has not indexed the Spring Data JPA dependency yet. Open the Maven tool window on the right side of IntelliJ and click the refresh (reload) icon. Wait for the dependency download to finish, then the error should disappear.

help me resolve JpaRepository import errors in IntelliJ

✔️ Awesome, I've got everything!

Great. Double-check you have saved both repository files before moving on.

ⓧ I'd like to double check the full code

Here is UserRepository.java:

package com.example.bankingapi.repository;

import com.example.bankingapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
}

Here is AccountRepository.java:

package com.example.bankingapi.repository;

import com.example.bankingapi.entity.Account;
import com.example.bankingapi.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByAccountNumber(String accountNumber);
    List<Account> findByUser(User user);
}
Run the app and verify the tables

With your entities and repositories in place, Hibernate will auto-create the matching database tables on startup because ddl-auto=update is set in your application.properties.

  • Run the app by executing this command in your terminal (from the banking-api folder):
mvn spring-boot:run
  • Wait until you see Started BankingApiApplication in the console output. You should also see Hibernate CREATE TABLE statements in the logs.
  • Open a second terminal window and verify the tables exist by running:
mysql -u root -p -e "USE banking_api; SHOW TABLES;"
  • Enter your MySQL root password when prompted.
  • You should see accounts and users listed in the output.

What is ddl-auto=update doing?

The spring.jpa.hibernate.ddl-auto=update property tells Hibernate to compare your entity classes against the existing database schema on every startup. If it finds new entities or new fields, it creates the tables or adds columns automatically. It never drops existing data.

Tables not showing up?

Check the terminal running your app for error messages. Common causes include the MySQL service not running, incorrect credentials in application.properties, or a typo in the database name banking_api.

Also confirm your entity classes are inside the com.example.bankingapi.entity package. Spring Boot only scans sub-packages of the main application class by default.

help me figure out why Hibernate is not creating my tables

  • Stop the running app by pressing Ctrl+C in the terminal where it is running.

Your data layer is ready. Next up, you will build the DTOs and service class that contain all the business logic for creating accounts, depositing, withdrawing, and transferring funds.

Build DTOs and the BankingService

Your entities and repositories handle data storage, but you don't want to expose your internal database structure directly to API consumers. A controller that returns raw Account entities would leak implementation details like user IDs and JPA metadata.

In this step, you'll create Data Transfer Objects (DTOs) that define exactly what your API accepts and returns. Then you'll build a BankingService class that contains all your business logic: creating accounts, checking balances, depositing, withdrawing, and transferring funds.

In this step, get ready to:
  • Create request and response DTO classes that define your API contracts.
  • Build a BankingService class with business logic for all banking operations.
  • Add validation and transaction safety to protect against invalid operations.
Create the request and response DTOs

DTOs are simple Java classes that carry data between layers of your application. Request DTOs define what a client sends to your API. Response DTOs define what your API sends back.

  • In IntelliJ, right-click the com.example.bankingapi package in the Project sidebar.
  • Select New, then Package.
  • Name it dto and press Enter.
  • Right-click the new dto package, select New > Java Class, and name it CreateAccountRequest.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

public class CreateAccountRequest {

    private String ownerName;

    public CreateAccountRequest() {}

    public CreateAccountRequest(String ownerName) {
        this.ownerName = ownerName;
    }

    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
}

What is a DTO?

A DTO (Data Transfer Object) is a plain class whose only job is to carry data. It has fields, a no-arg constructor (required by Jackson for JSON deserialization), an all-args constructor for convenience, and getters/setters.

By using DTOs instead of passing entities directly, you control exactly which fields your API exposes. Your Account entity has a User relationship and an internal id, but the client only needs to send an ownerName to create an account.

  • Create a new Java class in the dto package named DepositRequest.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;

public class DepositRequest {

    private String accountNumber;
    private BigDecimal amount;

    public DepositRequest() {}

    public DepositRequest(String accountNumber, BigDecimal amount) {
        this.accountNumber = accountNumber;
        this.amount = amount;
    }

    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
}
  • Create another Java class in the dto package named WithdrawRequest.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;

public class WithdrawRequest {

    private String accountNumber;
    private BigDecimal amount;

    public WithdrawRequest() {}

    public WithdrawRequest(String accountNumber, BigDecimal amount) {
        this.accountNumber = accountNumber;
        this.amount = amount;
    }

    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
}
  • Create another Java class in the dto package named TransferRequest.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;

public class TransferRequest {

    private String fromAccountNumber;
    private String toAccountNumber;
    private BigDecimal amount;

    public TransferRequest() {}

    public TransferRequest(String fromAccountNumber, String toAccountNumber, BigDecimal amount) {
        this.fromAccountNumber = fromAccountNumber;
        this.toAccountNumber = toAccountNumber;
        this.amount = amount;
    }

    public String getFromAccountNumber() { return fromAccountNumber; }
    public void setFromAccountNumber(String fromAccountNumber) { this.fromAccountNumber = fromAccountNumber; }
    public String getToAccountNumber() { return toAccountNumber; }
    public void setToAccountNumber(String toAccountNumber) { this.toAccountNumber = toAccountNumber; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
}

What does each request DTO carry?

  • CreateAccountRequest carries just an ownerName. The account number is generated server-side.
  • DepositRequest and WithdrawRequest carry an accountNumber and an amount (as BigDecimal for precise currency math).
  • TransferRequest carries a fromAccountNumber, a toAccountNumber, and an amount.

Now create the response DTOs. These define what your API sends back to the client.

  • Create a new Java class in the dto package named AccountResponse.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;

public class AccountResponse {

    private Long id;
    private String accountNumber;
    private String ownerName;
    private BigDecimal balance;

    public AccountResponse() {}

    public AccountResponse(Long id, String accountNumber, String ownerName, BigDecimal balance) {
        this.id = id;
        this.accountNumber = accountNumber;
        this.ownerName = ownerName;
        this.balance = balance;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getBalance() { return balance; }
    public void setBalance(BigDecimal balance) { this.balance = balance; }
}
  • Create one more Java class in the dto package named TransactionResponse.
  • Replace the file contents with this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;

public class TransactionResponse {

    private String message;
    private String accountNumber;
    private BigDecimal amount;
    private BigDecimal newBalance;

    public TransactionResponse() {}

    public TransactionResponse(String message, String accountNumber, BigDecimal amount, BigDecimal newBalance) {
        this.message = message;
        this.accountNumber = accountNumber;
        this.amount = amount;
        this.newBalance = newBalance;
    }

    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public String getAccountNumber() { return accountNumber; }
    public void setAccountNumber(String accountNumber) { this.accountNumber = accountNumber; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public BigDecimal getNewBalance() { return newBalance; }
    public void setNewBalance(BigDecimal newBalance) { this.newBalance = newBalance; }
}

Why two different response types?

AccountResponse returns the full account state (id, number, owner, balance). It's used for account creation and balance lookups.

TransactionResponse returns the result of a money operation (a status message, the account affected, the amount moved, and the new balance). It's used for deposits, withdrawals, and transfers.

  • Save all six DTO files.
  • Confirm that IntelliJ shows no red underlines in any of the DTO files. Your dto package should contain six classes.

Seeing red underlines in a DTO file?

  • Make sure the package declaration at the top of each file matches com.example.bankingapi.dto. A mismatched package causes compilation errors.
  • Check that the import java.math.BigDecimal; line is present in every file that uses BigDecimal (all except CreateAccountRequest).
  • help me fix red underlines in my DTO files
Build the BankingService class

The service layer is where your business logic lives. Instead of putting banking rules directly in a controller, you isolate them in a dedicated class. This keeps your controller thin and your logic testable.

  • Right-click the com.example.bankingapi package and select New > Package.
  • Name it service and press Enter.
  • Right-click the service package, select New > Java Class, and name it BankingService.
  • Replace the file contents with this code to set up the class structure, constructor injection, and the first two methods:
package com.example.bankingapi.service;

import com.example.bankingapi.dto.*;
import com.example.bankingapi.entity.Account;
import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.UUID;

@Service
public class BankingService {

    private final AccountRepository accountRepository;

    // Constructor injection - Spring automatically provides the repository
    public BankingService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Creates a new account with a random account number
    public AccountResponse createAccount(CreateAccountRequest request, User user) {
        String accountNumber = UUID.randomUUID().toString().substring(0, 10).toUpperCase();
        Account account = new Account(accountNumber, request.getOwnerName(), user);
        Account saved = accountRepository.save(account);
        return new AccountResponse(saved.getId(), saved.getAccountNumber(), saved.getOwnerName(), saved.getBalance());
    }

    // Looks up an account and returns its details
    public AccountResponse getBalance(String accountNumber) {
        Account account = accountRepository.findByAccountNumber(accountNumber)
                .orElseThrow(() -> new RuntimeException("Account not found: " + accountNumber));
        return new AccountResponse(account.getId(), account.getAccountNumber(), account.getOwnerName(), account.getBalance());
    }
}

What does this code do?

  • @Service tells Spring to manage this class as a bean, making it available for injection into controllers.
  • The constructor accepts an AccountRepository. Spring auto-injects the repository without needing @Autowired (single-constructor classes get auto-wiring for free).
  • createAccount generates a random 10-character account number using UUID, saves the entity, then wraps the result in an AccountResponse DTO.
  • getBalance uses orElseThrow to throw a clear error if the account number doesn't exist in the database.

Why no interface for the service?

Many Spring tutorials create a BankingService interface with a separate BankingServiceImpl class. That pattern only adds value when you need multiple implementations (like swapping a mock for testing). Spring can proxy concrete classes directly, so the interface is unnecessary boilerplate here.

Add deposit, withdraw, and transfer methods

The remaining methods handle money movement. Each one validates the input, performs the operation, and returns a response. Invalid operations throw a RuntimeException with a clear message.

  • In BankingService.java, add the deposit and withdraw methods below the getBalance method (but still inside the class braces):
    // Adds funds to an account after validating the amount
    @Transactional
    public TransactionResponse deposit(DepositRequest request) {
        Account account = accountRepository.findByAccountNumber(request.getAccountNumber())
                .orElseThrow(() -> new RuntimeException("Account not found: " + request.getAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Deposit amount must be positive");
        }

        account.setBalance(account.getBalance().add(request.getAmount()));
        accountRepository.save(account);

        return new TransactionResponse("Deposit successful", account.getAccountNumber(), request.getAmount(), account.getBalance());
    }

    // Removes funds from an account after checking sufficient balance
    @Transactional
    public TransactionResponse withdraw(WithdrawRequest request) {
        Account account = accountRepository.findByAccountNumber(request.getAccountNumber())
                .orElseThrow(() -> new RuntimeException("Account not found: " + request.getAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Withdrawal amount must be positive");
        }

        if (account.getBalance().compareTo(request.getAmount()) < 0) {
            throw new RuntimeException("Insufficient funds. Current balance: " + account.getBalance());
        }

        account.setBalance(account.getBalance().subtract(request.getAmount()));
        accountRepository.save(account);

        return new TransactionResponse("Withdrawal successful", account.getAccountNumber(), request.getAmount(), account.getBalance());
    }

What validation checks are happening?

  • Both methods first check that the account exists. If not, a RuntimeException stops execution with a clear error message.
  • Both methods verify the amount is positive. This prevents zero or negative deposits/withdrawals.
  • The withdraw method adds an extra check: it compares the balance against the requested amount and rejects the operation if funds are insufficient.
  • @Transactional ensures that if anything fails mid-operation, the database rolls back to its previous state.
  • Now add the transfer method below the withdraw method:
    // Moves funds between two accounts atomically
    @Transactional
    public TransactionResponse transfer(TransferRequest request) {
        Account fromAccount = accountRepository.findByAccountNumber(request.getFromAccountNumber())
                .orElseThrow(() -> new RuntimeException("Source account not found: " + request.getFromAccountNumber()));

        Account toAccount = accountRepository.findByAccountNumber(request.getToAccountNumber())
                .orElseThrow(() -> new RuntimeException("Destination account not found: " + request.getToAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Transfer amount must be positive");
        }

        if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
            throw new RuntimeException("Insufficient funds. Current balance: " + fromAccount.getBalance());
        }

        fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
        toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);

        return new TransactionResponse("Transfer successful", fromAccount.getAccountNumber(), request.getAmount(), fromAccount.getBalance());
    }

Why is @Transactional critical for transfers?

A transfer subtracts from one account and adds to another. If the app crashes between those two operations (without @Transactional), money disappears from the source account without arriving at the destination.

@Transactional wraps both saves in a single database transaction. If either save fails, the entire operation rolls back and no money moves.

  • Save BankingService.java.
  • Confirm that IntelliJ shows no red underlines in the file. All imports should resolve without errors.

Seeing unresolved import errors?

  • Make sure your DTO class names match exactly: CreateAccountRequest, DepositRequest, WithdrawRequest, TransferRequest, AccountResponse, TransactionResponse.
  • Verify that all DTO files have package com.example.bankingapi.dto; at the top. The wildcard import com.example.bankingapi.dto.* in BankingService only works if the package declaration is correct.
  • If @Transactional is red, make sure the import is org.springframework.transaction.annotation.Transactional (not the Jakarta one).
  • help me fix import errors in BankingService

✔️ Awesome, I've got everything!

Great. Double check you've saved all your files before moving on.

ⓧ I'd like to double check the full code

Here is the complete BankingService.java file:

package com.example.bankingapi.service;

import com.example.bankingapi.dto.*;
import com.example.bankingapi.entity.Account;
import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.math.BigDecimal;
import java.util.UUID;

@Service
public class BankingService {

    private final AccountRepository accountRepository;

    public BankingService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    public AccountResponse createAccount(CreateAccountRequest request, User user) {
        String accountNumber = UUID.randomUUID().toString().substring(0, 10).toUpperCase();
        Account account = new Account(accountNumber, request.getOwnerName(), user);
        Account saved = accountRepository.save(account);
        return new AccountResponse(saved.getId(), saved.getAccountNumber(), saved.getOwnerName(), saved.getBalance());
    }

    public AccountResponse getBalance(String accountNumber) {
        Account account = accountRepository.findByAccountNumber(accountNumber)
                .orElseThrow(() -> new RuntimeException("Account not found: " + accountNumber));
        return new AccountResponse(account.getId(), account.getAccountNumber(), account.getOwnerName(), account.getBalance());
    }

    @Transactional
    public TransactionResponse deposit(DepositRequest request) {
        Account account = accountRepository.findByAccountNumber(request.getAccountNumber())
                .orElseThrow(() -> new RuntimeException("Account not found: " + request.getAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Deposit amount must be positive");
        }

        account.setBalance(account.getBalance().add(request.getAmount()));
        accountRepository.save(account);

        return new TransactionResponse("Deposit successful", account.getAccountNumber(), request.getAmount(), account.getBalance());
    }

    @Transactional
    public TransactionResponse withdraw(WithdrawRequest request) {
        Account account = accountRepository.findByAccountNumber(request.getAccountNumber())
                .orElseThrow(() -> new RuntimeException("Account not found: " + request.getAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Withdrawal amount must be positive");
        }

        if (account.getBalance().compareTo(request.getAmount()) < 0) {
            throw new RuntimeException("Insufficient funds. Current balance: " + account.getBalance());
        }

        account.setBalance(account.getBalance().subtract(request.getAmount()));
        accountRepository.save(account);

        return new TransactionResponse("Withdrawal successful", account.getAccountNumber(), request.getAmount(), account.getBalance());
    }

    @Transactional
    public TransactionResponse transfer(TransferRequest request) {
        Account fromAccount = accountRepository.findByAccountNumber(request.getFromAccountNumber())
                .orElseThrow(() -> new RuntimeException("Source account not found: " + request.getFromAccountNumber()));

        Account toAccount = accountRepository.findByAccountNumber(request.getToAccountNumber())
                .orElseThrow(() -> new RuntimeException("Destination account not found: " + request.getToAccountNumber()));

        if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
            throw new RuntimeException("Transfer amount must be positive");
        }

        if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
            throw new RuntimeException("Insufficient funds. Current balance: " + fromAccount.getBalance());
        }

        fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
        toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);

        return new TransactionResponse("Transfer successful", fromAccount.getAccountNumber(), request.getAmount(), fromAccount.getBalance());
    }
}
Verify the project compiles

Before moving on, confirm that everything wires together correctly by running the application.

  • Return to your terminal and run the app by running:
mvn spring-boot:run
  • Watch the console output. The app should start on port 8080 without any compilation errors.
  • Once you see the startup message, press Ctrl+C to stop the app.

App fails to start?

  • If you see a compilation error, read the error message carefully. It usually names the file and line number where the problem is.
  • Check that your BankingService imports match: com.example.bankingapi.dto.* for DTOs, com.example.bankingapi.entity.Account and com.example.bankingapi.entity.User for entities.
  • Make sure every class file is in the correct package folder. BankingService.java should be in src/main/java/com/example/bankingapi/service/.
  • help me fix compilation errors in my banking API project

Your DTOs and service layer are complete. You now have a clean separation between what the API accepts, what it returns, and how it processes banking operations. Next up, you'll expose these operations through REST endpoints that clients can actually call.

Create REST Controllers

Your entities, repositories, DTOs, and service class are all in place. The missing piece is the layer that actually listens for HTTP requests and routes them to your business logic.

In this step, you'll create a REST controller that maps incoming requests to the BankingService methods you built. You'll also add a global exception handler so your API returns clean JSON errors instead of raw stack traces.

In this step, get ready to:
  • Build the BankingController with five REST endpoints.
  • Add a global exception handler for clean JSON error responses.
  • Test the API with curl to verify all endpoints respond correctly.
Build the BankingController

The controller is the front door of your API. Each method maps to an HTTP endpoint and delegates the real work to BankingService.

  • In IntelliJ, right-click the com.example.bankingapi package in the Project sidebar.
  • Select New then Package.
  • Name it controller and press Enter.
  • Right-click the new controller package, select New then Java Class, and name it BankingController.
  • Replace the generated content with the following code:
package com.example.bankingapi.controller;

import com.example.bankingapi.dto.*;
import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.UserRepository;
import com.example.bankingapi.service.BankingService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/accounts")
public class BankingController {

    private final BankingService bankingService;
    private final UserRepository userRepository;

    public BankingController(BankingService bankingService, UserRepository userRepository) {
        this.bankingService = bankingService;
        this.userRepository = userRepository;
    }

    // Create a new bank account linked to the authenticated user
    @PostMapping
    public ResponseEntity<AccountResponse> createAccount(@RequestBody CreateAccountRequest request, Authentication authentication) {
        User user = userRepository.findByUsername(authentication.getName())
                .orElseThrow(() -> new RuntimeException("User not found"));
        AccountResponse response = bankingService.createAccount(request, user);
        return ResponseEntity.ok(response);
    }

    // Look up an account's current balance by account number
    @GetMapping("/{accountNumber}/balance")
    public ResponseEntity<AccountResponse> getBalance(@PathVariable String accountNumber) {
        AccountResponse response = bankingService.getBalance(accountNumber);
        return ResponseEntity.ok(response);
    }

    // Deposit funds into an account
    @PostMapping("/deposit")
    public ResponseEntity<TransactionResponse> deposit(@RequestBody DepositRequest request) {
        TransactionResponse response = bankingService.deposit(request);
        return ResponseEntity.ok(response);
    }

    // Withdraw funds from an account
    @PostMapping("/withdraw")
    public ResponseEntity<TransactionResponse> withdraw(@RequestBody WithdrawRequest request) {
        TransactionResponse response = bankingService.withdraw(request);
        return ResponseEntity.ok(response);
    }

    // Transfer funds between two accounts
    @PostMapping("/transfer")
    public ResponseEntity<TransactionResponse> transfer(@RequestBody TransferRequest request) {
        TransactionResponse response = bankingService.transfer(request);
        return ResponseEntity.ok(response);
    }
}

What does this code do?

@RestController tells Spring this class handles HTTP requests and automatically serializes return values to JSON. @RequestMapping("/api/accounts") sets the base URL path for all endpoints in this controller.

The createAccount method uses Spring Security's Authentication object to identify who is making the request. It looks up the User entity by username, then passes it to the service so the new account is linked to that user.

Each method delegates to BankingService and wraps the result in ResponseEntity.ok(), which returns a 200 status with the JSON body.

  • Save BankingController.java.

Seeing red underlines in the imports?

Make sure your package statement at the top says com.example.bankingapi.controller. If IntelliJ shows unresolved imports, click Build then Rebuild Project to refresh the classpath.

Help me fix my import errors

Add a global exception handler

When your service throws a RuntimeException (for example, "Insufficient funds"), you want the API to return structured JSON. Without an exception handler, Spring returns a verbose HTML error page or a default Whitelabel error.

  • Right-click the controller package, select New then Java Class, and name it GlobalExceptionHandler.
  • Replace the generated content with the following code:
package com.example.bankingapi.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Map;

// Catches exceptions thrown by any controller and returns a clean JSON response
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(Map.of("error", ex.getMessage()));
    }
}

What does this code do?

@RestControllerAdvice makes this class a global interceptor for all controller exceptions. When any controller method throws a RuntimeException, this handler catches it and returns a 400 Bad Request with a JSON body like {"error": "Insufficient funds. Current balance: 0"}.

This means your API consumers always get predictable, machine-readable error responses instead of raw stack traces.

  • Save GlobalExceptionHandler.java.

Import errors on Map.of?

Make sure your project is using Java 21. Map.of() requires Java 9 or later. Check that your pom.xml has <java.version>21</java.version> in the <properties> section.

Help me fix Java version issues

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete BankingController.java:

package com.example.bankingapi.controller;

import com.example.bankingapi.dto.*;
import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.UserRepository;
import com.example.bankingapi.service.BankingService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/accounts")
public class BankingController {

    private final BankingService bankingService;
    private final UserRepository userRepository;

    public BankingController(BankingService bankingService, UserRepository userRepository) {
        this.bankingService = bankingService;
        this.userRepository = userRepository;
    }

    @PostMapping
    public ResponseEntity<AccountResponse> createAccount(@RequestBody CreateAccountRequest request, Authentication authentication) {
        User user = userRepository.findByUsername(authentication.getName())
                .orElseThrow(() -> new RuntimeException("User not found"));
        AccountResponse response = bankingService.createAccount(request, user);
        return ResponseEntity.ok(response);
    }

    @GetMapping("/{accountNumber}/balance")
    public ResponseEntity<AccountResponse> getBalance(@PathVariable String accountNumber) {
        AccountResponse response = bankingService.getBalance(accountNumber);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/deposit")
    public ResponseEntity<TransactionResponse> deposit(@RequestBody DepositRequest request) {
        TransactionResponse response = bankingService.deposit(request);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/withdraw")
    public ResponseEntity<TransactionResponse> withdraw(@RequestBody WithdrawRequest request) {
        TransactionResponse response = bankingService.withdraw(request);
        return ResponseEntity.ok(response);
    }

    @PostMapping("/transfer")
    public ResponseEntity<TransactionResponse> transfer(@RequestBody TransferRequest request) {
        TransactionResponse response = bankingService.transfer(request);
        return ResponseEntity.ok(response);
    }
}

And here is the complete GlobalExceptionHandler.java:

package com.example.bankingapi.controller;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Map<String, String>> handleRuntimeException(RuntimeException ex) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
                .body(Map.of("error", ex.getMessage()));
    }
}
Test your API with curl

Your controller is wired up. Time to prove it works by sending real HTTP requests.

  • Start the application by running this in your terminal (from the banking-api project root):
mvn spring-boot:run

Look for a line in the console output that includes a generated security password.

Using generated security password: <some-random-value>

What is this generated password?

Spring Security auto-configures a default user with username user and a randomly generated password printed at startup. You'll use these credentials for your curl commands. In the next step, you'll replace this with MySQL-backed users.

  • Copy the generated password from your console output.
  • Open a second terminal window.
  • Create a bank account by running this command (replace your-generated-password with the password from your console):
curl -u user:your-generated-password -X POST http://localhost:8080/api/accounts -H "Content-Type: application/json" -d '{"ownerName": "John Doe"}'

You should see a JSON response containing an id, accountNumber, ownerName, and balance of 0.

  • Copy the accountNumber value from the response. You'll need it for the next commands.
  • Deposit funds into that account by running this command (replace the account number with your actual value):
curl -u user:your-generated-password -X POST http://localhost:8080/api/accounts/deposit -H "Content-Type: application/json" -d '{"accountNumber": "[[ACCT_NUMBER="your account number from the create response"]]", "amount": 500}'

You should see a response with "message": "Deposit successful" and a newBalance of 500.

  • Check the account balance by running:
curl -u user:your-generated-password http://localhost:8080/api/accounts/[[ACCT_NUMBER="your account number"]]/balance

You should see the account details with a balance of 500.

  • Try withdrawing more than the balance to test your error handler:
curl -u user:your-generated-password -X POST http://localhost:8080/api/accounts/withdraw -H "Content-Type: application/json" -d '{"accountNumber": "[[ACCT_NUMBER="your account number"]]", "amount": 99999}'

You should see a 400 response with a JSON body like {"error":"Insufficient funds. Current balance: 500"}. That's your GlobalExceptionHandler in action.

Getting a 401 Unauthorized response?

Double-check that you copied the generated password exactly from the console output. The password changes every time you restart the app. Make sure you're using -u user:PASSWORD (lowercase user) with no extra spaces around the colon.

Why am I getting 401 Unauthorized?

Your API is fully functional. All five endpoints accept requests, process them through BankingService, and return clean JSON responses. Next up, you'll replace the auto-generated security password with real MySQL-backed users and proper authentication.

Add Spring Security with MySQL-Backed Users

Your REST controller and exception handler are working. But right now, the API relies on Spring Security's auto-generated in-memory password. Every time the app restarts, the password changes.

In this step, you will replace that default behaviour with a custom security configuration backed by your MySQL database. You will create a SecurityFilterChain bean, a UserDetailsService that reads credentials from the users table, and a data initializer that seeds test users with BCrypt-hashed passwords.

In this step, get ready to:
  • Configure a SecurityFilterChain with HTTP Basic authentication.
  • Create a custom UserDetailsService backed by your MySQL users table.
  • Seed the database with test users and verify authentication works.
Create the SecurityConfig class

Spring Security needs a SecurityFilterChain bean to know how to protect your endpoints. Without one, Spring uses its defaults (form login, CSRF enabled). For a stateless REST API, you want CSRF disabled and HTTP Basic authentication enabled.

  • In IntelliJ IDEA, right-click the com.example.bankingapi package in the Project sidebar.
  • Select New, then Package.
  • Name it config and press Enter.
  • Right-click the new config package, select New, then Java Class.
  • Name it SecurityConfig and press Enter.
  • Replace the entire file contents with the following code:
package com.example.bankingapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Define how HTTP requests are secured
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }

    // Provide a BCrypt encoder for hashing passwords
    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

What does this code do?

@Configuration and @EnableWebSecurity tell Spring this class provides security beans. The SecurityFilterChain bean configures three things:

  • .csrf(csrf -> csrf.disable()) turns off CSRF protection. CSRF tokens are for browser-based form submissions. A REST API uses tokens or Basic auth instead, so CSRF is unnecessary.
  • .anyRequest().authenticated() means every endpoint requires valid credentials.
  • .httpBasic(Customizer.withDefaults()) enables HTTP Basic authentication. Clients send credentials in the Authorization header with every request.

The PasswordEncoder bean provides BCrypt hashing. Spring Security uses it to compare incoming passwords against stored hashes.

  • Save SecurityConfig.java.
  • Confirm the file compiles without errors in IntelliJ (no red underlines).

Seeing import errors?

  • Make sure the package declaration at the top says com.example.bankingapi.config. If you created the file in the wrong package, the imports will not resolve.
  • Check that spring-boot-starter-security is in your pom.xml dependencies. Without it, the Spring Security classes are not on the classpath.

Help me fix Spring Security import errors in my SecurityConfig class.

Why disable CSRF for a REST API?

CSRF (Cross-Site Request Forgery) protection guards against malicious websites making requests on behalf of a logged-in browser user. It works by embedding a secret token in HTML forms.

A REST API does not serve HTML forms. Clients like curl or mobile apps authenticate with every request using headers. CSRF tokens add complexity with no security benefit in this context.

Create the CustomUserDetailsService class

Spring Security needs a UserDetailsService to load user credentials during authentication. By default it uses an in-memory user. You will replace that with a service that queries your MySQL users table.

  • Right-click the config package, select New, then Java Class.
  • Name it CustomUserDetailsService and press Enter.
  • Replace the entire file contents with the following code:
package com.example.bankingapi.config;

import com.example.bankingapi.repository.UserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // Look up the user entity from the database
        com.example.bankingapi.entity.User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        // Convert to a Spring Security UserDetails object
        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRole())
                .build();
    }
}

What does this code do?

  • The class implements UserDetailsService, which Spring Security calls every time someone tries to authenticate.
  • loadUserByUsername queries your UserRepository for the username. If no match is found, it throws UsernameNotFoundException.
  • It returns a Spring Security User object built from your entity's username, BCrypt-hashed password, and role. Spring compares this against the credentials the client sent.

Notice the fully qualified com.example.bankingapi.entity.User reference. This avoids a naming conflict with Spring Security's own User class that is imported for the builder.

  • Save CustomUserDetailsService.java.
  • Confirm the file compiles without errors. The UserRepository import should resolve because you created it in an earlier step.

Cannot resolve UserRepository?

  • Make sure your UserRepository.java is in the com.example.bankingapi.repository package. If IntelliJ cannot find it, try clicking File, then Invalidate Caches / Restart.

Help me fix unresolved repository imports in my Spring Boot project.

Create the DataInitializer and test authentication

Your security configuration is in place, but the users table is still empty. You need seed data to test with. A CommandLineRunner runs automatically when the app starts, making it ideal for inserting default users.

  • Right-click the config package, select New, then Java Class.
  • Name it DataInitializer and press Enter.
  • Replace the entire file contents with the following code:
package com.example.bankingapi.config;

import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class DataInitializer implements CommandLineRunner {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public void run(String... args) {
        // Only seed users if the table is empty
        if (userRepository.count() == 0) {
            User admin = new User("admin", passwordEncoder.encode("password123"), "ADMIN");
            User user = new User("user", passwordEncoder.encode("password123"), "USER");
            userRepository.save(admin);
            userRepository.save(user);
            System.out.println("Default users created: admin/password123, user/password123");
        }
    }
}

What does this code do?

  • @Component registers this class as a Spring bean. Since it implements CommandLineRunner, Spring calls its run() method after the application context loads.
  • userRepository.count() == 0 prevents duplicate inserts on subsequent restarts. Users are only created if the table is empty.
  • passwordEncoder.encode("password123") hashes the plain text password with BCrypt before saving. The database never stores raw passwords.
  • Save DataInitializer.java.
  • If the app is still running, stop it by pressing Ctrl+C in the terminal.
  • Restart the app by running:
mvn spring-boot:run
  • In the console output, look for Default users created: admin/password123, user/password123. This confirms the data initializer ran.
  • Confirm that the auto-generated security password no longer appears in the logs. Your custom UserDetailsService has replaced the default.

Now test authentication with your seeded users. Open a new terminal tab and run this command:

  • Test a successful login by running:
curl -u admin:password123 http://localhost:8080/api/accounts
  • You should see a [] (empty JSON array) because no accounts exist yet. The important part is that you received a 200 response instead of a 401.
  • Test a failed login by running:
curl -u admin:wrongpassword http://localhost:8080/api/accounts
  • You should receive a 401 Unauthorized response. This proves Spring Security is checking credentials against your MySQL database.
  • Verify the users exist in MySQL by running:
mysql -u root -p -e "USE banking_api; SELECT id, username, role FROM users;"
  • You should see two rows: one for admin with role ADMIN, and one for user with role USER.

Still seeing the auto-generated password?

  • Make sure CustomUserDetailsService has the @Service annotation. Without it, Spring does not detect the bean and falls back to its default user.
  • Check that the class is inside the com.example.bankingapi.config package. Spring Boot's component scan only picks up classes under the main application package.
  • If curl returns 401 with the correct credentials, check the console for stack traces. A missing PasswordEncoder bean will cause all logins to fail.

Help me debug why Spring Security is not using my custom UserDetailsService.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files in the config package.

ⓧ I'd like to double check the full code

Here is the full code for each file in the config package:

package com.example.bankingapi.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated()
            )
            .httpBasic(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
package com.example.bankingapi.config;

import com.example.bankingapi.repository.UserRepository;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

@Service
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    public CustomUserDetailsService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        com.example.bankingapi.entity.User user = userRepository.findByUsername(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        return User.builder()
                .username(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRole())
                .build();
    }
}
package com.example.bankingapi.config;

import com.example.bankingapi.entity.User;
import com.example.bankingapi.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

@Component
public class DataInitializer implements CommandLineRunner {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    public DataInitializer(UserRepository userRepository, PasswordEncoder passwordEncoder) {
        this.userRepository = userRepository;
        this.passwordEncoder = passwordEncoder;
    }

    @Override
    public void run(String... args) {
        if (userRepository.count() == 0) {
            User admin = new User("admin", passwordEncoder.encode("password123"), "ADMIN");
            User user = new User("user", passwordEncoder.encode("password123"), "USER");
            userRepository.save(admin);
            userRepository.save(user);
            System.out.println("Default users created: admin/password123, user/password123");
        }
    }
}

Secret mission

Add Transaction History

Your banking API processes deposits, withdrawals, and transfers. But right now, there's no record of what happened. Can you add a Transaction entity and a history endpoint so users can see every operation on their account?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • MySQL database banking_api (local).
  • The banking-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to keep experimenting with the API.

  • Your MySQL banking_api database stays on your local machine and uses minimal disk space.
  • Your banking-api project folder remains on your Desktop for future editing.
  • The Spring Boot app only runs when you explicitly start it with mvn spring-boot:run. It does not consume resources when stopped.

Pause - I'll come back to this later

Shut down any running processes to free up memory. Your files and database stay intact for when you return.

  • If the Spring Boot app is still running in your terminal, press Ctrl+C to stop it.
  • Your MySQL database and project folder will remain untouched until you're ready to continue.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database and project files.

  • If the Spring Boot app is still running, press Ctrl+C to stop it.
  • Drop the MySQL database by running:
mysql -u root -p -e "DROP DATABASE IF EXISTS banking_api;"
  • Enter your MySQL root password when prompted. This permanently removes all tables and data.
  • Delete the project folder. On macOS/Linux, run:
rm -rf ~/Desktop/banking-api
  • On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\banking-api
  • Confirm deletion by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). The banking-api folder should no longer appear.

Nice Work!

Nice Work!

Nice work! You just built a fully secured banking REST API from scratch using Spring Boot 3.5.14. Your API handles real banking operations with proper layering, security, and persistence.

You've learned how to:

  • Build a complete REST API with Spring Boot that performs banking operations (create account, deposit, withdraw, transfer) against a MySQL database.
  • Implement a clean layered architecture with JPA entities, request/response DTOs, a single service class with @Transactional business logic, and REST controllers with global exception handling.
  • Configure Spring Security with HTTP Basic authentication, BCrypt password encoding, and a MySQL-backed UserDetailsService.
  • Secret Mission: Added a Transaction entity and history endpoint, proving you can extend the architecture independently.

Ready to quiz yourself?