Build a Banking Account API

Build a REST API for banking with Spring Boot's layered architecture.

Introduction

30 Second Summary

Every time you check your bank balance or send money to a friend, a REST API is handling that transaction behind the scenes. These APIs need to be fast, reliable, and structured in a way that large teams can maintain.

In this project, you will build a Banking Account Management API using Spring Boot. You'll create endpoints for managing accounts, processing deposits and withdrawals, and transferring funds.

What You'll Build

A fully functional Banking API with enterprise-grade architecture patterns including controllers, services, repositories, DTOs, and global exception handling.

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

  • A Spring Boot REST API with 6 endpoints across two controllers for managing accounts and financial transactions.
  • A three-layer architecture (Controller, Service, Repository) that mirrors how enterprise Java teams structure their code.
  • DTOs, Bean Validation, and a global exception handler to keep your API contract clean and your error responses consistent.
  • Secret Mission: Add an account statement endpoint that filters transactions by date range and type.

Are there any prerequisites?

You should be comfortable with basic Java syntax (variables, classes, methods, interfaces). Familiarity with REST API concepts like HTTP methods, JSON, and endpoints is also helpful.

This project maps every Spring Boot concept to its FastAPI equivalent, so experience with any backend framework will help the patterns click faster.

Before We Start

Before We Start...

Install Maven and Scaffold the Project

Every Spring Boot project needs two things before you write a single line of code: a build tool to manage dependencies and a project scaffold that gives you the right folder structure.

In this step, you'll install Apache Maven (the build tool used by most enterprise Java teams) and generate a ready-to-run project with Spring Initializr.

In this step, get ready to:
  • Install Apache Maven and verify it detects Java 17.
  • Generate a Spring Boot project using Spring Initializr.
  • Explore the project structure and run the application.
Install Apache Maven

Maven is the dependency and build manager for Java projects. Think of it as the Java equivalent of pip for Python, but it also handles compiling, testing, and packaging your application.

  • Check if Maven is already installed by running this command:
mvn -v

✔️ I see a version number

Maven is ready to go. Confirm the output includes Java version: 17 (or any 17.x variant) and move on to the next substep.

ⓧ Command not found

No problem. Install Maven using the instructions for your operating system below.

macOS

  • Install Maven with Homebrew by running this command:
brew install maven
Don't have Homebrew?

Install it first by visiting brew.sh and following the one-line install command.

Windows

  • Install Maven with Chocolatey by running this command in an Administrator PowerShell:
choco install maven
Don't have Chocolatey?

You can install it from chocolatey.org/install, or download Maven manually from maven.apache.org/download.cgi and follow the official install guide.

Linux

  • Download and install Maven 3.9.15 by running these commands:
# Download Maven 3.9.15
cd /tmp
wget https://dlcdn.apache.org/maven/maven-3/3.9.15/binaries/apache-maven-3.9.15-bin.tar.gz

# Extract to /opt
sudo tar -xzf apache-maven-3.9.15-bin.tar.gz -C /opt

# Add Maven to your PATH
echo 'export M2_HOME=/opt/apache-maven-3.9.15' >> ~/.bashrc
echo 'export PATH=$M2_HOME/bin:$PATH' >> ~/.bashrc
source ~/.bashrc
What do these commands do?
  • wget downloads the Maven binary archive from Apache's servers.
  • tar -xzf extracts the archive into /opt, a standard location for manually installed software on Linux.
  • The echo lines add Maven's bin folder to your system PATH so you can run mvn from anywhere.
  • The sudo command will prompt you for your password.
  • Verify Maven installed correctly by running this command:
mvn -v

You should see output that includes Apache Maven 3.9 and Java version: 17.

Still seeing command not found?

  • On Linux, make sure you ran source ~/.bashrc to reload your PATH.
  • If you use Zsh instead of Bash, replace ~/.bashrc with ~/.zshrc in the commands above and run source ~/.zshrc.
  • Try opening a new terminal window. The PATH changes only take effect in new sessions.

Help me troubleshoot my Maven installation

What is Maven?

Maven manages your Java project's dependencies, builds, and packaging. If you've used FastAPI, Maven's pom.xml file is like your requirements.txt, but it also defines how to compile and run your application.

Generate the project with Spring Initializr

Spring Initializr is a web tool that generates a complete Spring Boot project with all the right folders, config files, and dependencies pre-wired. You pick your settings, click a button, and download a ready-to-run project.

  • Open start.spring.io in your browser.
  • In the Project section, select Maven.
  • In the Language section, select Java.
  • Click the Spring Boot version dropdown and select any 3.4.x version.

Why Spring Boot 3.4.x?

Spring Initializr may default to 4.0.x, but enterprise teams (especially at large banks) typically run 3.x. Using 3.4.x gives you the version you are most likely to encounter at work.

  • Under Project Metadata, enter com.example in the Group field.
  • Enter banking-api in the Artifact field.
  • Leave the Name and Package name fields at their defaults.
  • Select Jar for Packaging.
  • Select 17 for Java.

Now add the four dependencies your project needs.

  • Click Add Dependencies (or press Ctrl+B).
  • Search for Spring Web and select it.
  • Repeat for Spring Data JPA.
  • Repeat for H2 Database.
  • Repeat for Validation.
  • Click Generate to download the project as a ZIP file.

What are these dependencies?

  • Spring Web lets you build REST APIs with controllers and endpoints.
  • Spring Data JPA handles database access using JPA (Java Persistence API), so you can work with Java objects instead of writing raw SQL.
  • H2 Database is an embedded, in-memory database. Zero setup, zero install.
  • Validation adds Bean Validation support, which lets you enforce rules on incoming data with annotations.

If you've used FastAPI before, this is like running pip install fastapi uvicorn sqlalchemy. Spring Initializr bundles everything into a single download.

Now extract the ZIP and open the project in your editor.

  • Move to your Desktop and extract the project ZIP by running these commands:
cd ~/Desktop
unzip ~/Downloads/banking-api.zip

On Windows?

Open your Downloads folder in File Explorer, right-click banking-api.zip, and select Extract All. Choose your Desktop as the destination.

  • Confirm the folder was extracted by running:
ls

You should see a banking-api folder in the output.

  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the banking-api folder, then click Open.

Don't see the banking-api folder?

  • Make sure you clicked Generate on Spring Initializr (not Explore). Generate downloads the ZIP. Explore only previews the files.
  • Check that the Artifact field was set to banking-api. The ZIP file name matches the artifact name.

Help me find my Spring Initializr download

Explore the project structure and run it

Spring Initializr generated a full project skeleton for you. Before running the app, take a quick look at what you have.

In the VS Code Explorer sidebar, you'll see three key files and folders:

  • pom.xml sits at the project root. This is your dependency file (like requirements.txt in Python). It lists Spring Web, Spring Data JPA, H2, and Validation as dependencies.
  • src/main/java/com/example/bankingapi/BankingApiApplication.java is the entry point. This is like main.py with uvicorn.run() in a FastAPI project.
  • src/main/resources/application.properties is the configuration file. This is like your .env file in FastAPI.
  • Open BankingApiApplication.java by expanding src > main > java > com > example > bankingapi in the sidebar.

You should see this code inside the file:

package com.example.bankingapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class BankingApiApplication {

    public static void main(String[] args) {
        SpringApplication.run(BankingApiApplication.class, args);
    }
}

What does @SpringBootApplication do?

This single annotation combines three things: auto-configuration (Spring figures out what beans to create based on your dependencies), component scanning (Spring finds your controllers, services, and repositories automatically), and configuration (it marks this class as the application root).

SpringApplication.run() starts the embedded web server. The FastAPI equivalent is calling uvicorn.run(app) at the bottom of main.py.

Time to see it run.

  • Open a terminal in VS Code by pressing Ctrl+` (macOS and Windows).
  • Make sure you are inside the banking-api folder. The terminal prompt should show banking-api in the path.
  • Make the Maven wrapper executable and start the application by running these commands:
# Make the Maven wrapper script executable (needed on macOS/Linux)
chmod +x mvnw

# Start the Spring Boot application
./mvnw spring-boot:run

What does this command do?

mvnw is the Maven Wrapper, a script bundled with your project that runs Maven without needing a global install. spring-boot:run tells Maven to compile and start the embedded web server.

The first run downloads all project dependencies. This may take a few minutes depending on your internet connection.

On Windows?

Skip the chmod command and use mvnw.cmd spring-boot:run instead.

Wait for the download and compilation to finish. You should see a line near the bottom of the output that says:

Started BankingApiApplication in X.XXX seconds

Your Spring Boot application is now running on http://localhost:8080.

Seeing a Whitelabel Error Page?

If you open http://localhost:8080 in your browser, you'll see a "Whitelabel Error Page" with a 404 status. This is completely normal.

Spring Boot is running, but you haven't created any endpoints yet. The 404 just means there's nothing to display at that URL. You'll fix this in Step 3 when you build your first controller.

  • Stop the running application by pressing Ctrl+C in the terminal.

App won't start?

  • If you see Permission denied, make sure you ran chmod +x mvnw first.
  • If you see Port 8080 already in use, another process is using that port. Stop it or change the port by adding server.port=8081 to application.properties.
  • If you see a compilation error, double-check that you selected Java 17 in Spring Initializr and that your system is running JDK 17.

Help me troubleshoot my Spring Boot application startup

Your project is up and running. Next up, you'll create the Account entity and repository to give your API a database model to work with.

Create the Account Entity and Repository

Your project is scaffolded and running on localhost:8080. Now it is time to give your API something to work with: a bank account.

Every API needs a data layer that defines what information it stores and how it accesses that data. In Spring Boot, this means creating a JPA entity (your database table definition) and a repository (your data access interface).

In this step, get ready to:
  • Create an Account JPA entity that maps to a database table.
  • Create an AccountRepository interface that provides CRUD operations for free.
  • Configure the H2 in-memory database and verify the ACCOUNT table was auto-created.
Create the Account JPA entity

A JPA entity is a plain Java class that maps to a database table. Each field in the class becomes a column in the table.

What is a JPA entity?

If you have used FastAPI, a JPA entity is like a SQLAlchemy model (class Account(Base)). In Spring, annotations like @Entity and @Column replace Python decorators to tell JPA how to map your class to a table.

  • In your editor's file explorer, right-click the bankingapi folder inside src/main/java/com/example/bankingapi/.
  • Create a new folder called model.
  • Inside the model folder, create a new file called Account.java.
  • Paste the following code into Account.java:
package com.example.bankingapi.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import java.math.BigDecimal;
import java.time.LocalDateTime;

// Marks this class as a JPA entity mapped to a database table
@Entity
public class Account {

    // Primary key, auto-incremented by the database
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Owner name is required (cannot be null in the database)
    @Column(nullable = false)
    private String ownerName;

    // Balance stored as BigDecimal for precise financial calculations
    @Column(nullable = false)
    private BigDecimal balance;

    // Timestamp for when the account was created
    @Column(nullable = false)
    private LocalDateTime createdAt;

    // JPA requires a no-argument constructor
    public Account() {
    }

    // Convenience constructor that sets createdAt automatically
    public Account(String ownerName, BigDecimal balance) {
        this.ownerName = ownerName;
        this.balance = balance;
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    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 LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}

What does this code do?

@Entity tells JPA that this class maps to a database table. JPA automatically names the table ACCOUNT based on the class name.

@Id and @GeneratedValue(strategy = GenerationType.IDENTITY) mark the id field as an auto-incrementing primary key. The database assigns IDs automatically.

The balance uses BigDecimal instead of double because floating-point math introduces rounding errors. Financial applications always use BigDecimal for exact precision.

JPA requires a no-argument constructor to create entity instances. The second constructor is a convenience shortcut that automatically sets createdAt to the current time.

  • Save Account.java.

You will not see a visible change yet. The next substep creates the repository, and then we will configure the H2 database to verify everything together.

✔️ Awesome, I've got everything!

Make sure you have saved Account.java before moving on.

ⓧ I'd like to double check the full code

Here is the complete Account.java file for comparison.

package com.example.bankingapi.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
public class Account {

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

    @Column(nullable = false)
    private String ownerName;

    @Column(nullable = false)
    private BigDecimal balance;

    @Column(nullable = false)
    private LocalDateTime createdAt;

    public Account() {
    }

    public Account(String ownerName, BigDecimal balance) {
        this.ownerName = ownerName;
        this.balance = balance;
        this.createdAt = LocalDateTime.now();
    }

    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    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 LocalDateTime getCreatedAt() {
        return createdAt;
    }

    public void setCreatedAt(LocalDateTime createdAt) {
        this.createdAt = createdAt;
    }
}
Create the Account repository interface

With the entity defined, you need a way to read and write Account records in the database. In Spring, you do this with a repository interface.

What is JpaRepository?

In FastAPI, you write database queries manually with SQLAlchemy, like db.query(Account).filter(Account.id == id).first(). In Spring, you declare an interface that extends JpaRepository, and Spring auto-implements all the standard CRUD methods for you.

You get findById(), findAll(), save(), and deleteById() for free. No code needed beyond the interface declaration.

  • In the bankingapi folder, create a new folder called repository.
  • Inside the repository folder, create a new file called AccountRepository.java.
  • Paste the following code into AccountRepository.java:
package com.example.bankingapi.repository;

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

// Spring auto-implements all CRUD methods for the Account entity
public interface AccountRepository extends JpaRepository<Account, Long> {
}

What does this code do?

JpaRepository<Account, Long> takes two type parameters: the entity class (Account) and the type of its primary key (Long).

Spring scans for interfaces that extend JpaRepository and generates a full implementation at startup. The empty body is intentional.

  • Save AccountRepository.java.

You still will not see a change yet. The next substep configures the database so you can verify the entity and repository are working.

✔️ Awesome, I've got everything!

Make sure you have saved AccountRepository.java before moving on.

ⓧ I'd like to double check the full code

Here is the complete AccountRepository.java file for comparison.

package com.example.bankingapi.repository;

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

public interface AccountRepository extends JpaRepository<Account, Long> {
}
Configure the H2 database and test your setup

Your entity and repository are ready, but the application does not know where to store data yet. H2 is an in-memory database that runs inside your application process.

You configure it in application.properties, which is the Spring Boot equivalent of a .env file in FastAPI.

  • Open src/main/resources/application.properties in your editor.
  • You should see a single existing line: spring.application.name=banking-api.
  • Add the following lines below that existing line:
# H2 in-memory database connection settings
spring.datasource.url=jdbc:h2:mem:bankingdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA settings: auto-create tables from entities, print SQL to console
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

# Enable the H2 web console for inspecting database tables
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

What does this configuration do?

spring.datasource.url=jdbc:h2:mem:bankingdb creates an in-memory database called bankingdb. The data lives only while the application is running. Restarting the app resets everything.

spring.jpa.hibernate.ddl-auto=update tells JPA to automatically create and update database tables based on your @Entity classes. This is like Base.metadata.create_all() in SQLAlchemy.

spring.jpa.show-sql=true prints every SQL statement to the console, which is useful for debugging. The H2 console lines enable a browser-based database viewer.

  • Save application.properties.
  • If your application is still running, stop it by pressing Ctrl+C in the terminal.
  • Restart the application by running:
./mvnw spring-boot:run

Watch the terminal output as the application starts. You should see SQL statements like create table account printed in the console. This confirms that JPA read your Account entity and auto-created the matching database table.

  • Open your browser and go to http://localhost:8080/h2-console.
  • In the JDBC URL field, make sure the value is jdbc:h2:mem:bankingdb.
  • Leave the User Name as sa and the Password field empty.
  • Click Connect.

You should see the ACCOUNT table listed in the left panel. This table was auto-created from your Account entity.

Not seeing the ACCOUNT table?

  • Check the JDBC URL field on the login page. It must be exactly jdbc:h2:mem:bankingdb. The H2 console defaults to a different URL, so you may need to replace it.
  • Make sure your Account.java is inside the com.example.bankingapi.model package. Spring only scans packages under com.example.bankingapi for entities.
  • Check that the package statement at the top of Account.java matches the folder structure. If the file is in model/ but the package says something different, JPA will not find it.
  • If you see a compilation error in your terminal, scroll up to find the error message. It usually points to the exact file and line number.

Still stuck? Ask for help here.

✔️ Awesome, I've got everything!

Make sure you have saved application.properties before moving on.

ⓧ I'd like to double check the full code

Here is the complete application.properties file for comparison.

spring.application.name=banking-api

spring.datasource.url=jdbc:h2:mem:bankingdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

Your data layer is solid. You have an entity that maps to a database table, a repository that provides CRUD operations, and an H2 console to inspect your data. Next up, you will build the REST controller and DTOs that let clients interact with your accounts over HTTP.

Build the Account Controller and DTOs

Your Account entity and repository are ready. The database layer knows how to store and retrieve accounts. Now you need a way for clients to actually talk to your API.

In this step, you will build three layers that sit between the client and the database: DTOs that define what data goes in and out of your API, a service that holds your business logic, and a REST controller that maps HTTP requests to your service methods.

In this step, get ready to:
  • Create request and response DTOs with Bean Validation annotations.
  • Build a service layer that handles account creation and lookups.
  • Wire up a REST controller with endpoints for creating and retrieving accounts.
Create request and response DTOs

A DTO (Data Transfer Object) is a simple Java class that defines the shape of data your API accepts or returns. You never expose your database entity directly to the client.

Why not return the Account entity directly?

Your Account entity is tied to the database schema. If you return it directly, any database change (renaming a column, adding an internal field) breaks every client using your API.

DTOs give you a separate contract. The database can change freely, and you control exactly what the client sees. In FastAPI, this is like having separate Pydantic models in schemas.py for request vs response.

  • Create a new package folder at src/main/java/com/example/bankingapi/dto/.
  • Inside the dto folder, create a new file called CreateAccountRequest.java and paste this code:
package com.example.bankingapi.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

// DTO that defines what a client sends to create an account
public class CreateAccountRequest {

    // @NotBlank rejects null, empty, and whitespace-only strings
    @NotBlank(message = "Owner name is required")
    private String ownerName;

    // @NotNull ensures the field is present, @Min(0) rejects negatives
    @NotNull(message = "Initial balance is required")
    @Min(value = 0, message = "Initial balance must be zero or positive")
    private BigDecimal initialBalance;

    public CreateAccountRequest() {}

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

    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getInitialBalance() { return initialBalance; }
    public void setInitialBalance(BigDecimal initialBalance) { this.initialBalance = initialBalance; }
}

What does this code do?

  • @NotBlank on ownerName rejects null, empty strings, and whitespace-only strings. In FastAPI terms, this is like Field(min_length=1) on a Pydantic model.
  • @NotNull ensures the client sends the initialBalance field. @Min(0) blocks negative values.
  • The message attribute on each annotation defines the error text the client sees when validation fails.
  • In the same dto folder, create a file called AccountResponse.java and paste this code:
package com.example.bankingapi.dto;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// DTO that defines what the client receives when requesting account data
public class AccountResponse {

    private Long id;
    private String ownerName;
    private BigDecimal balance;
    private LocalDateTime createdAt;

    public AccountResponse() {}

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

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    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 LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this code do?

AccountResponse is a plain Java class with four fields that mirror what the client should see: id, ownerName, balance, and createdAt. No validation annotations here because this is outgoing data, not incoming.

In FastAPI terms, CreateAccountRequest is like your AccountCreate(BaseModel) schema. AccountResponse is like AccountOut(BaseModel).

  • Save both CreateAccountRequest.java and AccountResponse.java.

You will not see a visible change yet. The controller you build next will use these DTOs to accept and return data.

✔️ Awesome, I've got everything!

Double check that you have saved both files inside the dto package.

ⓧ I'd like to double check the full code

Here is CreateAccountRequest.java:

package com.example.bankingapi.dto;

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

public class CreateAccountRequest {

    @NotBlank(message = "Owner name is required")
    private String ownerName;

    @NotNull(message = "Initial balance is required")
    @Min(value = 0, message = "Initial balance must be zero or positive")
    private BigDecimal initialBalance;

    public CreateAccountRequest() {}

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

    public String getOwnerName() { return ownerName; }
    public void setOwnerName(String ownerName) { this.ownerName = ownerName; }
    public BigDecimal getInitialBalance() { return initialBalance; }
    public void setInitialBalance(BigDecimal initialBalance) { this.initialBalance = initialBalance; }
}

And here is AccountResponse.java:

package com.example.bankingapi.dto;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public class AccountResponse {

    private Long id;
    private String ownerName;
    private BigDecimal balance;
    private LocalDateTime createdAt;

    public AccountResponse() {}

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

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    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 LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}
Create the Account service layer

The service layer is where your business logic lives. Controllers handle HTTP. Repositories handle the database. The service sits in the middle and orchestrates everything.

What is a service layer?

In FastAPI, you might put business logic directly in your route functions. In Spring Boot, enterprise teams extract it into @Service classes. This keeps controllers thin and makes the logic easy to reuse and test independently.

@Service is like a Python class you instantiate with FastAPI's Depends(). Spring manages the wiring automatically through Dependency Injection. You declare the dependency in the constructor, and Spring provides it.

Before creating the service, you need a small exception class that the service will throw when an account is not found. You will expand exception handling fully in the next step.

  • Create a new package folder at src/main/java/com/example/bankingapi/exception/.
  • Inside the exception folder, create a file called ResourceNotFoundException.java and paste this code:
package com.example.bankingapi.exception;

// Custom exception for when a requested resource does not exist
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

This is a custom exception that extends RuntimeException. When the service cannot find an account, it throws this exception with a descriptive message. In the next step, you will build a global handler that converts this into a clean JSON error response.

  • Save ResourceNotFoundException.java.
  • Now create a new package folder at src/main/java/com/example/bankingapi/service/.
  • Inside the service folder, create a file called AccountService.java and paste this code:
package com.example.bankingapi.service;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.exception.ResourceNotFoundException;
import com.example.bankingapi.model.Account;
import com.example.bankingapi.repository.AccountRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

// @Service tells Spring to manage this class as a bean
@Service
public class AccountService {

    // final ensures the dependency cannot be reassigned after construction
    private final AccountRepository accountRepository;

    // Constructor injection: Spring automatically provides the AccountRepository
    public AccountService(AccountRepository accountRepository) {
        this.accountRepository = accountRepository;
    }

    // Creates a new Account entity from the request DTO, saves it, and returns a response DTO
    public AccountResponse createAccount(CreateAccountRequest request) {
        Account account = new Account(request.getOwnerName(), request.getInitialBalance());
        Account saved = accountRepository.save(account);
        return toAccountResponse(saved);
    }

    // Finds an account by ID or throws ResourceNotFoundException
    public AccountResponse getAccount(Long id) {
        Account account = accountRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + id));
        return toAccountResponse(account);
    }

    // Returns all accounts as a list of response DTOs
    public List<AccountResponse> getAllAccounts() {
        return accountRepository.findAll().stream()
                .map(this::toAccountResponse)
                .collect(Collectors.toList());
    }

    // Converts an Account entity to an AccountResponse DTO
    private AccountResponse toAccountResponse(Account account) {
        return new AccountResponse(
                account.getId(), account.getOwnerName(), account.getBalance(), account.getCreatedAt());
    }
}

What does this code do?

  • @Service marks this class as a Spring-managed bean. Spring creates one instance and injects it wherever it is needed.
  • The constructor takes AccountRepository as a parameter. Spring sees the single constructor and automatically provides the repository. No @Autowired annotation needed.
  • createAccount() converts the incoming DTO into an entity, saves it through the repository, then converts the saved entity back to a response DTO.
  • getAccount() uses findById() which returns an Optional. The .orElseThrow() unwraps it or throws ResourceNotFoundException if the account does not exist.
  • toAccountResponse() is a private helper that converts an entity to a DTO. Every public method uses it to keep the conversion logic in one place.
  • Save AccountService.java.

You will not see a visible change yet. The controller in the next substep will expose these service methods as HTTP endpoints.

Seeing a compilation error?

  • Check that your package declarations match the folder structure exactly. AccountService.java must be inside service/ and ResourceNotFoundException.java must be inside exception/.
  • Make sure the import paths reference com.example.bankingapi.dto and com.example.bankingapi.exception. A typo in any package name causes an unresolved import.
  • My Spring Boot service won't compile. What's wrong with my imports?

✔️ Awesome, I've got everything!

Make sure both ResourceNotFoundException.java and AccountService.java are saved.

ⓧ I'd like to double check the full code

Here is ResourceNotFoundException.java:

package com.example.bankingapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

And here is AccountService.java:

package com.example.bankingapi.service;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.exception.ResourceNotFoundException;
import com.example.bankingapi.model.Account;
import com.example.bankingapi.repository.AccountRepository;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class AccountService {

    private final AccountRepository accountRepository;

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

    public AccountResponse createAccount(CreateAccountRequest request) {
        Account account = new Account(request.getOwnerName(), request.getInitialBalance());
        Account saved = accountRepository.save(account);
        return toAccountResponse(saved);
    }

    public AccountResponse getAccount(Long id) {
        Account account = accountRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + id));
        return toAccountResponse(account);
    }

    public List<AccountResponse> getAllAccounts() {
        return accountRepository.findAll().stream()
                .map(this::toAccountResponse)
                .collect(Collectors.toList());
    }

    private AccountResponse toAccountResponse(Account account) {
        return new AccountResponse(
                account.getId(), account.getOwnerName(), account.getBalance(), account.getCreatedAt());
    }
}
Create the Account REST controller

The controller is the entry point for every HTTP request. It maps URLs and HTTP methods to your service methods, then returns the result as JSON.

  • Create a new package folder at src/main/java/com/example/bankingapi/controller/.
  • Inside the controller folder, create a file called AccountController.java and paste this code:
package com.example.bankingapi.controller;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.service.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

// @RestController combines @Controller + @ResponseBody, so methods return JSON
@RestController
// All endpoints in this controller start with /api/accounts
@RequestMapping("/api/accounts")
public class AccountController {

    private final AccountService accountService;

    // Constructor injection for the service dependency
    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    // POST /api/accounts - creates a new account
    // @Valid triggers Bean Validation on the request body
    @PostMapping
    public ResponseEntity<AccountResponse> createAccount(@Valid @RequestBody CreateAccountRequest request) {
        AccountResponse response = accountService.createAccount(request);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

    // GET /api/accounts - returns all accounts
    @GetMapping
    public ResponseEntity<List<AccountResponse>> getAllAccounts() {
        return ResponseEntity.ok(accountService.getAllAccounts());
    }

    // GET /api/accounts/{id} - returns a single account by ID
    @GetMapping("/{id}")
    public ResponseEntity<AccountResponse> getAccount(@PathVariable Long id) {
        return ResponseEntity.ok(accountService.getAccount(id));
    }
}

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON. In FastAPI, this is like the @app instance or an APIRouter.
  • @RequestMapping("/api/accounts") sets the base path. Every endpoint in this controller starts with /api/accounts.
  • @PostMapping is like @app.post() in FastAPI. @GetMapping is like @app.get().
  • @Valid on the @RequestBody triggers the validation annotations you added to CreateAccountRequest. Pydantic validates automatically. In Spring, you opt in with @Valid.
  • @PathVariable extracts {id} from the URL, just like a path parameter in FastAPI.
  • ResponseEntity wraps the response body with an HTTP status code. The POST endpoint returns 201 Created. The GET endpoints return 200 OK.
  • Save AccountController.java.
  • Stop the running app by pressing Ctrl+C in the terminal where it is running.
  • Restart the app by running:
./mvnw spring-boot:run

Wait until you see Started BankingApiApplication in the console output. Now test your three endpoints.

  • Open a second terminal tab or window.
  • Create a new account by running this curl command:
curl -X POST http://localhost:8080/api/accounts \
  -H "Content-Type: application/json" \
  -d '{"ownerName": "Alice", "initialBalance": 1000}'

What should I see?

You should see a JSON response with an id of 1, ownerName set to Alice, balance of 1000, and a createdAt timestamp.

  • List all accounts by running:
curl http://localhost:8080/api/accounts

You should see a JSON array containing the Alice account you just created.

  • Retrieve a single account by ID by running:
curl http://localhost:8080/api/accounts/1

You should see the same Alice account returned as a single JSON object.

Getting a 404 or connection refused?

  • Make sure the app is running. Check that the terminal shows Started BankingApiApplication. If you see an error during startup, read the stack trace for missing classes or typos.
  • Confirm the URL path is exactly /api/accounts. A missing /api prefix returns a 404.
  • If you see a 500 error, check that AccountController.java is inside the controller package under com.example.bankingapi. Spring only scans sub-packages of the main application class.
  • My Spring Boot API returns a 404 when I call /api/accounts. How do I debug this?

✔️ Awesome, I've got everything!

Make sure you have saved AccountController.java and that all three curl commands returned valid JSON.

ⓧ I'd like to double check the full code

Here is AccountController.java:

package com.example.bankingapi.controller;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.service.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

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

    private final AccountService accountService;

    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    @PostMapping
    public ResponseEntity<AccountResponse> createAccount(@Valid @RequestBody CreateAccountRequest request) {
        AccountResponse response = accountService.createAccount(request);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<AccountResponse>> getAllAccounts() {
        return ResponseEntity.ok(accountService.getAllAccounts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<AccountResponse> getAccount(@PathVariable Long id) {
        return ResponseEntity.ok(accountService.getAccount(id));
    }
}

Your API can create and retrieve accounts. Three clean layers are in place: the controller handles HTTP, the service holds business logic, and the repository talks to the database. Next up, you will add global exception handling so that errors return clean JSON instead of ugly stack traces.

Add Global Exception Handling

Your API can create and fetch accounts, but what happens when something goes wrong? Right now, if a client requests an account that doesn't exist, Spring Boot returns its default error response. These defaults are verbose and expose internal details your client doesn't need.

In this step, you'll create custom exception classes and wire them up to a global exception handler. Every error your API throws will return a clean, consistent JSON response with a status code, message, and timestamp.

In this step, get ready to:
  • Create custom exception classes for different error scenarios.
  • Build a global exception handler with @RestControllerAdvice.
  • Test your error responses with curl.
Create custom exception classes

Your API needs to communicate specific error types to the client. A missing account is different from insufficient funds, and each should map to its own HTTP status code.

How does Spring Boot handle errors differently from FastAPI?

In FastAPI, you raise HTTPException(status_code=404, detail="Not found") directly in your route function. The exception and the HTTP response are bundled together.

Spring Boot separates the two. You define exception classes here (just plain Java classes), then map them to HTTP responses in a global handler. This separation keeps your service layer focused on business logic instead of HTTP details.

If you recall from the previous step, your AccountService already references a ResourceNotFoundException class that doesn't exist yet. Your project won't compile until you create it.

  • Create a new file called ResourceNotFoundException.java inside src/main/java/com/example/bankingapi/exception/ and paste the following code:
package com.example.bankingapi.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

ResourceNotFoundException extends RuntimeException, making it an unchecked exception. The constructor takes a message string and passes it up via super(). This message becomes the error detail in your API response later.

  • Save ResourceNotFoundException.java.

The next two exception classes follow the exact same pattern. You'll use them in the next step when you add transaction logic.

  • Create InsufficientFundsException.java in the same exception package with this code:
package com.example.bankingapi.exception;

public class InsufficientFundsException extends RuntimeException {

    public InsufficientFundsException(String message) {
        super(message);
    }
}
  • Create InvalidOperationException.java in the same exception package with this code:
package com.example.bankingapi.exception;

public class InvalidOperationException extends RuntimeException {

    public InvalidOperationException(String message) {
        super(message);
    }
}

Why three separate exception classes?

Each exception maps to a different error scenario. ResourceNotFoundException signals a 404 (not found). InsufficientFundsException and InvalidOperationException signal 400 (bad request) errors.

The global exception handler you build next uses the exception type to decide which HTTP status code to return. Separate classes make that mapping clean and explicit.

  • Save both InsufficientFundsException.java and InvalidOperationException.java.

You won't see a change yet. These exception classes are just definitions. The global handler in the next substep wires them to HTTP responses.

Create the global exception handler

When your service throws one of those custom exceptions, something needs to catch it and convert it into a proper HTTP response. That's the job of @RestControllerAdvice.

First, you need a DTO that defines the shape of every error response your API returns.

  • Create ErrorResponse.java inside the exception package and paste the following code:
package com.example.bankingapi.exception;

import java.time.LocalDateTime;

public class ErrorResponse {

    private int status;
    private String message;
    private LocalDateTime timestamp;

    // Constructor sets the timestamp automatically
    public ErrorResponse(int status, String message) {
        this.status = status;
        this.message = message;
        this.timestamp = LocalDateTime.now();
    }

    public int getStatus() { return status; }
    public void setStatus(int status) { this.status = status; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
}

What does this code do?

ErrorResponse is a plain Java class with three fields: status (the HTTP code), message (the error detail), and timestamp (set automatically in the constructor).

Every error your API returns will use this same shape. Clients get a predictable format to parse regardless of the error type.

  • Save ErrorResponse.java.

Now for the handler itself. This is the class that intercepts exceptions from all your controllers and converts them into ErrorResponse objects.

  • Create GlobalExceptionHandler.java in the exception package and paste the following code:
package com.example.bankingapi.exception;

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

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles missing resources (e.g. account not found) -> 404
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // Handles insufficient balance for withdrawals/transfers -> 400
    @ExceptionHandler(InsufficientFundsException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientFunds(InsufficientFundsException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Handles invalid operations (e.g. transferring to same account) -> 400
    @ExceptionHandler(InvalidOperationException.class)
    public ResponseEntity<ErrorResponse> handleInvalidOperation(InvalidOperationException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Handles bean validation failures from @Valid -> 400
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .reduce((a, b) -> a + "; " + b)
                .orElse("Validation failed");
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), message);
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }
}

What does this code do?

  • @RestControllerAdvice tells Spring to intercept exceptions from all controllers and run the matching handler method.
  • Each @ExceptionHandler method catches a specific exception type. handleResourceNotFound returns 404. handleInsufficientFunds and handleInvalidOperation return 400.
  • handleValidationErrors catches MethodArgumentNotValidException, which Spring throws when @Valid fails on a request body. It extracts field-level error messages and joins them into a single string.

How does this compare to FastAPI?

@RestControllerAdvice is like registering @app.exception_handler(SomeException) in FastAPI. The difference is that Spring centralizes ALL exception handling in one class instead of scattered handler decorators across your codebase.

  • Save GlobalExceptionHandler.java.

Seeing import errors in your editor?

Make sure all five files are in the same exception package: src/main/java/com/example/bankingapi/exception/. The handler references the exception classes and ErrorResponse by package, so a misplaced file will cause compilation errors.

Help me debug import errors in my GlobalExceptionHandler

✔️ Awesome, I've got everything!

Make sure you've saved all five files in the exception package before moving on.

ⓧ I'd like to double check the full code

Here is the full GlobalExceptionHandler.java for comparison:

package com.example.bankingapi.exception;

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

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.NOT_FOUND.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(InsufficientFundsException.class)
    public ResponseEntity<ErrorResponse> handleInsufficientFunds(InsufficientFundsException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(InvalidOperationException.class)
    public ResponseEntity<ErrorResponse> handleInvalidOperation(InvalidOperationException ex) {
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), ex.getMessage());
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .reduce((a, b) -> a + "; " + b)
                .orElse("Validation failed");
        ErrorResponse error = new ErrorResponse(HttpStatus.BAD_REQUEST.value(), message);
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }
}

And here is ErrorResponse.java:

package com.example.bankingapi.exception;

import java.time.LocalDateTime;

public class ErrorResponse {

    private int status;
    private String message;
    private LocalDateTime timestamp;

    public ErrorResponse(int status, String message) {
        this.status = status;
        this.message = message;
        this.timestamp = LocalDateTime.now();
    }

    public int getStatus() { return status; }
    public void setStatus(int status) { this.status = status; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
}
Test error responses with curl

Your AccountService already throws ResourceNotFoundException in getAccount() from the previous step. Now that the exception class and global handler exist, the full error pipeline is wired up.

  • Stop the running app if it's still active (press Ctrl+C in the terminal).
  • Restart the app by running this command from the banking-api folder:
./mvnw spring-boot:run
  • Wait for the Started BankingApiApplication message in the terminal output.

App won't start?

If you see a compilation error, double check that all five files in the exception package have the correct package com.example.bankingapi.exception; declaration at the top. Also make sure AccountService.java imports com.example.bankingapi.exception.ResourceNotFoundException.

Help me fix compilation errors in my Spring Boot project

First, test what happens when you request an account that doesn't exist.

  • Open a new terminal window and request a non-existent account by running:
curl -s http://localhost:8080/api/accounts/999

What should I see?

You should see a clean JSON error response like this:

{"status":404,"message":"Account not found with id: 999","timestamp":"2026-04-30T..."}

The status is 404, the message comes from the exception you threw in AccountService, and the timestamp was set automatically by ErrorResponse.

Now test the bean validation handler. This catches errors from the @NotBlank and @Min annotations on your CreateAccountRequest DTO.

  • Try creating an account with an empty name and a negative balance by running:
curl -s -X POST http://localhost:8080/api/accounts \
  -H "Content-Type: application/json" \
  -d '{"ownerName": "", "initialBalance": -5}'

What should I see?

You should see a 400 response with field-level validation messages. The message field will contain something like ownerName: Owner name is required. These messages come from the @NotBlank(message = "...") and @Min(message = "...") annotations you added to CreateAccountRequest in the previous step.

Seeing a Spring default error page instead of JSON?

Make sure GlobalExceptionHandler.java has the @RestControllerAdvice annotation on the class. Without it, Spring doesn't know to route exceptions through your handler methods.

Also check that the class is in the com.example.bankingapi.exception package. Spring's component scanning starts from the root package (com.example.bankingapi) and only detects classes in sub-packages.

Why am I seeing Spring's default error page instead of my custom JSON error?

Your API now returns clean, predictable error responses for every failure scenario. Next up, you'll add transaction support with deposits, withdrawals, and transfers between accounts.

Add Transaction Support with Business Logic

Your API can create accounts, retrieve them, and return clean error messages when something goes wrong. The foundation is solid. Now it is time to add the features that make a banking API a banking API: deposits, withdrawals, and transfers.

Every transaction needs a record. You will create a Transaction entity linked to accounts, build request and response DTOs for each operation, and write the business logic that prevents overdrafts and self-transfers.

In this step, get ready to:
  • Create the Transaction entity with a many-to-one relationship to Account.
  • Build deposit, withdrawal, and transfer endpoints with business logic.
  • Verify the full flow end-to-end with curl and the H2 console.
Create the Transaction entity and repository

Before you can deposit or withdraw money, you need a way to record each transaction. This means creating a new JPA entity and linking it to the Account entity you already built.

Each transaction has a type: deposit, withdrawal, transfer in, or transfer out. In Java, the cleanest way to represent a fixed set of values is with an enum.

  • Create a new file called TransactionType.java inside the model/ package by copying the following code:
package com.example.bankingapi.model;

// Fixed set of transaction types for the banking API
public enum TransactionType {
    DEPOSIT,
    WITHDRAWAL,
    TRANSFER_IN,
    TRANSFER_OUT
}

What does this code do?

A Java enum defines a type-safe set of constants. Instead of passing strings like "deposit" around your code (and risking typos), the compiler enforces that only these four values are valid.

  • Save TransactionType.java.

Now you need the entity that uses this enum. The Transaction entity records every financial operation and belongs to an Account.

  • Create a new file called Transaction.java inside the model/ package by copying the following code:
package com.example.bankingapi.model;

import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Column;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.JoinColumn;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
public class Transaction {

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

    // Store the enum value as a string in the database (e.g. "DEPOSIT")
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TransactionType type;

    @Column(nullable = false)
    private BigDecimal amount;

    private String description;

    @Column(nullable = false)
    private LocalDateTime timestamp;

    // Each transaction belongs to exactly one account
    @ManyToOne
    @JoinColumn(name = "account_id", nullable = false)
    private Account account;

    public Transaction() {}

    public Transaction(TransactionType type, BigDecimal amount, String description, Account account) {
        this.type = type;
        this.amount = amount;
        this.description = description;
        this.account = account;
        this.timestamp = LocalDateTime.now();
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public TransactionType getType() { return type; }
    public void setType(TransactionType type) { this.type = type; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
    public Account getAccount() { return account; }
    public void setAccount(Account account) { this.account = account; }
}

What does this code do?

@Enumerated(EnumType.STRING) stores the enum as a readable string in the database (like "DEPOSIT") instead of an integer index. This makes your data much easier to debug.

@ManyToOne with @JoinColumn creates a foreign key relationship. Many transactions belong to one account. In FastAPI terms, this is like defining a ForeignKey("accounts.id") in SQLAlchemy.

  • Save Transaction.java.

Seeing import errors?

Make sure all imports use the jakarta.persistence namespace, not javax.persistence. Spring Boot 3.x switched from javax to jakarta.

help me fix import errors in my Transaction entity

The last piece of the data layer is a repository. Just like AccountRepository, you extend JpaRepository and Spring auto-implements all the CRUD methods.

  • Create a new file called TransactionRepository.java inside the repository/ package by copying the following code:
package com.example.bankingapi.repository;

import com.example.bankingapi.model.Transaction;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface TransactionRepository extends JpaRepository<Transaction, Long> {

    // Spring Data JPA auto-implements this query from the method name
    List<Transaction> findByAccountId(Long accountId);
}

What does this code do?

The findByAccountId method is a custom query that Spring Data JPA generates automatically from the method name. Spring parses findBy + AccountId and generates a SQL WHERE account_id = ? query for you. No SQL needed.

In FastAPI, you would write db.query(Transaction).filter(Transaction.account_id == id).all() manually. Spring Data JPA removes that boilerplate entirely.

  • Save TransactionRepository.java.
  • You will not see a visible change yet. The next substep uses this repository in the service layer.
Create transaction DTOs, service methods, and the controller

You have the data layer ready. Now you need DTOs for the transaction requests and responses, business logic in the service, and controller endpoints to expose the new operations.

  • Create a new file called TransactionRequest.java inside the dto/ package by copying the following code:
package com.example.bankingapi.dto;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

// Incoming request shape for deposits and withdrawals
public class TransactionRequest {

    @NotNull(message = "Amount is required")
    @DecimalMin(value = "0.01", message = "Amount must be at least 0.01")
    private BigDecimal amount;

    // Optional note for the transaction
    private String description;

    public TransactionRequest() {}
    public TransactionRequest(BigDecimal amount, String description) {
        this.amount = amount;
        this.description = description;
    }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

What does this code do?

@DecimalMin("0.01") ensures the amount is at least one cent. This is designed for BigDecimal values where you need decimal precision. In FastAPI terms, this is like Field(ge=0.01) on a Pydantic model.

  • Save TransactionRequest.java.

Transfers need extra information: the source account, the destination account, and the amount. This requires its own DTO.

  • Create a new file called TransferRequest.java inside the dto/ package by copying the following code:
package com.example.bankingapi.dto;

import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;

// Incoming request shape for account-to-account transfers
public class TransferRequest {

    @NotNull(message = "Source account ID is required")
    private Long fromAccountId;

    @NotNull(message = "Destination account ID is required")
    private Long toAccountId;

    @NotNull(message = "Amount is required")
    @DecimalMin(value = "0.01", message = "Amount must be at least 0.01")
    private BigDecimal amount;

    // Optional note for the transfer
    private String description;

    public TransferRequest() {}
    public TransferRequest(Long fromAccountId, Long toAccountId, BigDecimal amount, String description) {
        this.fromAccountId = fromAccountId;
        this.toAccountId = toAccountId;
        this.amount = amount;
        this.description = description;
    }
    public Long getFromAccountId() { return fromAccountId; }
    public void setFromAccountId(Long fromAccountId) { this.fromAccountId = fromAccountId; }
    public Long getToAccountId() { return toAccountId; }
    public void setToAccountId(Long toAccountId) { this.toAccountId = toAccountId; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
}

What does this code do?

This DTO captures both the source (fromAccountId) and destination (toAccountId) accounts, plus the transfer amount and an optional description. The @NotNull annotations ensure the client always provides both account IDs.

  • Save TransferRequest.java.

You also need a response DTO to control what transaction data the API returns to the client.

  • Create a new file called TransactionResponse.java inside the dto/ package by copying the following code:
package com.example.bankingapi.dto;

import com.example.bankingapi.model.TransactionType;
import java.math.BigDecimal;
import java.time.LocalDateTime;

// Outgoing response shape for transaction records
public class TransactionResponse {

    private Long id;
    private TransactionType type;
    private BigDecimal amount;
    private String description;
    private LocalDateTime timestamp;
    // Return the account ID instead of the full Account entity
    private Long accountId;

    public TransactionResponse() {}
    public TransactionResponse(Long id, TransactionType type, BigDecimal amount, String description,
                                LocalDateTime timestamp, Long accountId) {
        this.id = id;
        this.type = type;
        this.amount = amount;
        this.description = description;
        this.timestamp = timestamp;
        this.accountId = accountId;
    }
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public TransactionType getType() { return type; }
    public void setType(TransactionType type) { this.type = type; }
    public BigDecimal getAmount() { return amount; }
    public void setAmount(BigDecimal amount) { this.amount = amount; }
    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
    public Long getAccountId() { return accountId; }
    public void setAccountId(Long accountId) { this.accountId = accountId; }
}

Why return accountId instead of the full Account?

The response includes accountId (a Long) instead of the full Account object. This keeps your JSON responses small and avoids circular references where Account contains Transactions that contain Account.

  • Save TransactionResponse.java.

With the DTOs ready, you can now add the business logic to AccountService. The deposit and withdrawal methods modify a single account's balance and record the transaction.

  • Open AccountService.java in the service/ package.
  • Replace the entire file contents with the following code:
package com.example.bankingapi.service;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.dto.TransactionRequest;
import com.example.bankingapi.dto.TransactionResponse;
import com.example.bankingapi.dto.TransferRequest;
import com.example.bankingapi.exception.InsufficientFundsException;
import com.example.bankingapi.exception.InvalidOperationException;
import com.example.bankingapi.exception.ResourceNotFoundException;
import com.example.bankingapi.model.Account;
import com.example.bankingapi.model.Transaction;
import com.example.bankingapi.model.TransactionType;
import com.example.bankingapi.repository.AccountRepository;
import com.example.bankingapi.repository.TransactionRepository;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class AccountService {

    private final AccountRepository accountRepository;
    // New dependency for saving and querying transactions
    private final TransactionRepository transactionRepository;

    // Constructor injection now wires both repositories
    public AccountService(AccountRepository accountRepository, TransactionRepository transactionRepository) {
        this.accountRepository = accountRepository;
        this.transactionRepository = transactionRepository;
    }

    public AccountResponse createAccount(CreateAccountRequest request) {
        Account account = new Account(request.getOwnerName(), request.getInitialBalance());
        Account saved = accountRepository.save(account);
        return toAccountResponse(saved);
    }

    public AccountResponse getAccount(Long id) {
        Account account = accountRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + id));
        return toAccountResponse(account);
    }

    public List<AccountResponse> getAllAccounts() {
        return accountRepository.findAll().stream()
                .map(this::toAccountResponse)
                .collect(Collectors.toList());
    }

    // Add the deposit amount to the account balance, then record the transaction
    public TransactionResponse deposit(Long accountId, TransactionRequest request) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        account.setBalance(account.getBalance().add(request.getAmount()));
        accountRepository.save(account);
        Transaction transaction = new Transaction(TransactionType.DEPOSIT, request.getAmount(), request.getDescription(), account);
        Transaction saved = transactionRepository.save(transaction);
        return toTransactionResponse(saved);
    }

    // Check for sufficient funds before subtracting the withdrawal amount
    public TransactionResponse withdraw(Long accountId, TransactionRequest request) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        if (account.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientFundsException("Insufficient funds. Available balance: " + account.getBalance());
        }
        account.setBalance(account.getBalance().subtract(request.getAmount()));
        accountRepository.save(account);
        Transaction transaction = new Transaction(TransactionType.WITHDRAWAL, request.getAmount(), request.getDescription(), account);
        Transaction saved = transactionRepository.save(transaction);
        return toTransactionResponse(saved);
    }

    // Transfer between two accounts: debit the source, credit the destination
    public List<TransactionResponse> transfer(TransferRequest request) {
        if (request.getFromAccountId().equals(request.getToAccountId())) {
            throw new InvalidOperationException("Cannot transfer to the same account");
        }
        Account fromAccount = accountRepository.findById(request.getFromAccountId())
                .orElseThrow(() -> new ResourceNotFoundException("Source account not found with id: " + request.getFromAccountId()));
        Account toAccount = accountRepository.findById(request.getToAccountId())
                .orElseThrow(() -> new ResourceNotFoundException("Destination account not found with id: " + request.getToAccountId()));
        if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientFundsException("Insufficient funds. Available balance: " + fromAccount.getBalance());
        }
        fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
        toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        String desc = request.getDescription() != null ? request.getDescription() : "Transfer";
        Transaction debit = new Transaction(TransactionType.TRANSFER_OUT, request.getAmount(), desc, fromAccount);
        Transaction credit = new Transaction(TransactionType.TRANSFER_IN, request.getAmount(), desc, toAccount);
        transactionRepository.save(debit);
        transactionRepository.save(credit);
        return List.of(toTransactionResponse(debit), toTransactionResponse(credit));
    }

    // Verify the account exists, then return all its transactions
    public List<TransactionResponse> getTransactions(Long accountId) {
        accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        return transactionRepository.findByAccountId(accountId).stream()
                .map(this::toTransactionResponse)
                .collect(Collectors.toList());
    }

    private AccountResponse toAccountResponse(Account account) {
        return new AccountResponse(account.getId(), account.getOwnerName(), account.getBalance(), account.getCreatedAt());
    }

    // Convert a Transaction entity to its response DTO
    private TransactionResponse toTransactionResponse(Transaction transaction) {
        return new TransactionResponse(transaction.getId(), transaction.getType(), transaction.getAmount(),
                transaction.getDescription(), transaction.getTimestamp(), transaction.getAccount().getId());
    }
}

What does this code do?

  • deposit() looks up the account, adds the amount to the balance, saves the updated account, then creates a DEPOSIT transaction record.
  • withdraw() follows the same pattern but first checks if the balance is large enough. If not, it throws InsufficientFundsException (which your global handler from Step 4 catches and returns as a 400 error).
  • transfer() prevents self-transfers, loads both accounts, checks the source balance, then debits one and credits the other. It creates two transaction records: TRANSFER_OUT on the source and TRANSFER_IN on the destination.
  • getTransactions() verifies the account exists, then uses the repository's findByAccountId() to return all transactions for that account.
  • Save AccountService.java.

Seeing a compilation error about the constructor?

The constructor now takes two parameters: AccountRepository and TransactionRepository. Spring automatically injects both because they are the only constructor. Make sure you saved TransactionRepository.java in the previous substep.

help me fix a constructor injection error in AccountService

Now you need a controller to expose the transaction endpoints. Deposits and withdrawals target a specific account, while transfers operate across two accounts.

  • Create a new file called TransactionController.java inside the controller/ package by copying the following code:
package com.example.bankingapi.controller;

import com.example.bankingapi.dto.TransactionRequest;
import com.example.bankingapi.dto.TransactionResponse;
import com.example.bankingapi.dto.TransferRequest;
import com.example.bankingapi.service.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api")
public class TransactionController {

    private final AccountService accountService;

    // Spring injects the same AccountService instance used by AccountController
    public TransactionController(AccountService accountService) {
        this.accountService = accountService;
    }

    // POST /api/accounts/{id}/deposit - add funds to an account
    @PostMapping("/accounts/{id}/deposit")
    public ResponseEntity<TransactionResponse> deposit(
            @PathVariable Long id,
            @Valid @RequestBody TransactionRequest request) {
        return ResponseEntity.ok(accountService.deposit(id, request));
    }

    // POST /api/accounts/{id}/withdraw - remove funds from an account
    @PostMapping("/accounts/{id}/withdraw")
    public ResponseEntity<TransactionResponse> withdraw(
            @PathVariable Long id,
            @Valid @RequestBody TransactionRequest request) {
        return ResponseEntity.ok(accountService.withdraw(id, request));
    }

    // POST /api/transactions/transfer - move funds between two accounts
    @PostMapping("/transactions/transfer")
    public ResponseEntity<List<TransactionResponse>> transfer(
            @Valid @RequestBody TransferRequest request) {
        return ResponseEntity.ok(accountService.transfer(request));
    }
}

What does this code do?

This controller is mapped to /api (not /api/accounts) because the transfer endpoint lives at /api/transactions/transfer, which is a different path from the account-specific endpoints.

Each method uses @Valid on the @RequestBody to trigger Bean Validation before the request reaches your service. If the amount is missing or below 0.01, your global exception handler returns a clean 400 error automatically.

  • Save TransactionController.java.

One last addition: the AccountController needs a new endpoint so clients can fetch all transactions for a specific account.

  • Open AccountController.java in the controller/ package.
  • Add the following import at the top, below the existing CreateAccountRequest import:
import com.example.bankingapi.dto.TransactionResponse;
  • Find the getAccount method. Add the following new endpoint below it, before the closing } of the class:
    // GET /api/accounts/{id}/transactions - list all transactions for an account
    @GetMapping("/{id}/transactions")
    public ResponseEntity<List<TransactionResponse>> getTransactions(@PathVariable Long id) {
        return ResponseEntity.ok(accountService.getTransactions(id));
    }

What does this code do?

This endpoint lives under AccountController (mapped to /api/accounts) so the full path becomes GET /api/accounts/{id}/transactions. Keeping the "get transactions for account X" endpoint on the account controller makes the URL structure RESTful.

  • Save AccountController.java.

✔️ Awesome, I've got everything!

Make sure you have saved all your files before moving on to the verification step.

ⓧ I'd like to double check AccountService.java

Here is the full AccountService.java file for comparison:

package com.example.bankingapi.service;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.dto.TransactionRequest;
import com.example.bankingapi.dto.TransactionResponse;
import com.example.bankingapi.dto.TransferRequest;
import com.example.bankingapi.exception.InsufficientFundsException;
import com.example.bankingapi.exception.InvalidOperationException;
import com.example.bankingapi.exception.ResourceNotFoundException;
import com.example.bankingapi.model.Account;
import com.example.bankingapi.model.Transaction;
import com.example.bankingapi.model.TransactionType;
import com.example.bankingapi.repository.AccountRepository;
import com.example.bankingapi.repository.TransactionRepository;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class AccountService {

    private final AccountRepository accountRepository;
    private final TransactionRepository transactionRepository;

    public AccountService(AccountRepository accountRepository, TransactionRepository transactionRepository) {
        this.accountRepository = accountRepository;
        this.transactionRepository = transactionRepository;
    }

    public AccountResponse createAccount(CreateAccountRequest request) {
        Account account = new Account(request.getOwnerName(), request.getInitialBalance());
        Account saved = accountRepository.save(account);
        return toAccountResponse(saved);
    }

    public AccountResponse getAccount(Long id) {
        Account account = accountRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + id));
        return toAccountResponse(account);
    }

    public List<AccountResponse> getAllAccounts() {
        return accountRepository.findAll().stream()
                .map(this::toAccountResponse)
                .collect(Collectors.toList());
    }

    public TransactionResponse deposit(Long accountId, TransactionRequest request) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        account.setBalance(account.getBalance().add(request.getAmount()));
        accountRepository.save(account);
        Transaction transaction = new Transaction(TransactionType.DEPOSIT, request.getAmount(), request.getDescription(), account);
        Transaction saved = transactionRepository.save(transaction);
        return toTransactionResponse(saved);
    }

    public TransactionResponse withdraw(Long accountId, TransactionRequest request) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        if (account.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientFundsException("Insufficient funds. Available balance: " + account.getBalance());
        }
        account.setBalance(account.getBalance().subtract(request.getAmount()));
        accountRepository.save(account);
        Transaction transaction = new Transaction(TransactionType.WITHDRAWAL, request.getAmount(), request.getDescription(), account);
        Transaction saved = transactionRepository.save(transaction);
        return toTransactionResponse(saved);
    }

    public List<TransactionResponse> transfer(TransferRequest request) {
        if (request.getFromAccountId().equals(request.getToAccountId())) {
            throw new InvalidOperationException("Cannot transfer to the same account");
        }
        Account fromAccount = accountRepository.findById(request.getFromAccountId())
                .orElseThrow(() -> new ResourceNotFoundException("Source account not found with id: " + request.getFromAccountId()));
        Account toAccount = accountRepository.findById(request.getToAccountId())
                .orElseThrow(() -> new ResourceNotFoundException("Destination account not found with id: " + request.getToAccountId()));
        if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
            throw new InsufficientFundsException("Insufficient funds. Available balance: " + fromAccount.getBalance());
        }
        fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
        toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
        accountRepository.save(fromAccount);
        accountRepository.save(toAccount);
        String desc = request.getDescription() != null ? request.getDescription() : "Transfer";
        Transaction debit = new Transaction(TransactionType.TRANSFER_OUT, request.getAmount(), desc, fromAccount);
        Transaction credit = new Transaction(TransactionType.TRANSFER_IN, request.getAmount(), desc, toAccount);
        transactionRepository.save(debit);
        transactionRepository.save(credit);
        return List.of(toTransactionResponse(debit), toTransactionResponse(credit));
    }

    public List<TransactionResponse> getTransactions(Long accountId) {
        accountRepository.findById(accountId)
                .orElseThrow(() -> new ResourceNotFoundException("Account not found with id: " + accountId));
        return transactionRepository.findByAccountId(accountId).stream()
                .map(this::toTransactionResponse)
                .collect(Collectors.toList());
    }

    private AccountResponse toAccountResponse(Account account) {
        return new AccountResponse(account.getId(), account.getOwnerName(), account.getBalance(), account.getCreatedAt());
    }

    private TransactionResponse toTransactionResponse(Transaction transaction) {
        return new TransactionResponse(transaction.getId(), transaction.getType(), transaction.getAmount(),
                transaction.getDescription(), transaction.getTimestamp(), transaction.getAccount().getId());
    }
}

ⓧ I'd like to double check AccountController.java

Here is the full AccountController.java file for comparison:

package com.example.bankingapi.controller;

import com.example.bankingapi.dto.AccountResponse;
import com.example.bankingapi.dto.CreateAccountRequest;
import com.example.bankingapi.dto.TransactionResponse;
import com.example.bankingapi.service.AccountService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

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

    private final AccountService accountService;

    public AccountController(AccountService accountService) {
        this.accountService = accountService;
    }

    @PostMapping
    public ResponseEntity<AccountResponse> createAccount(@Valid @RequestBody CreateAccountRequest request) {
        AccountResponse response = accountService.createAccount(request);
        return new ResponseEntity<>(response, HttpStatus.CREATED);
    }

    @GetMapping
    public ResponseEntity<List<AccountResponse>> getAllAccounts() {
        return ResponseEntity.ok(accountService.getAllAccounts());
    }

    @GetMapping("/{id}")
    public ResponseEntity<AccountResponse> getAccount(@PathVariable Long id) {
        return ResponseEntity.ok(accountService.getAccount(id));
    }

    @GetMapping("/{id}/transactions")
    public ResponseEntity<List<TransactionResponse>> getTransactions(@PathVariable Long id) {
        return ResponseEntity.ok(accountService.getTransactions(id));
    }
}
Verify the full flow end-to-end

You have built the full transaction feature across six new files and two updated files. Time to test it all together.

  • If the app is still running, stop it with Ctrl+C in your terminal.
  • Restart the application by running:
./mvnw spring-boot:run

Wait until you see the Started BankingApiApplication log line in your terminal.

  • Create two test accounts by running these curl commands in a new terminal window:
curl -s -X POST http://localhost:8080/api/accounts \
  -H "Content-Type: application/json" \
  -d '{"ownerName": "Alice", "initialBalance": 1000}'
curl -s -X POST http://localhost:8080/api/accounts \
  -H "Content-Type: application/json" \
  -d '{"ownerName": "Bob", "initialBalance": 500}'

You should see JSON responses with "id": 1 for Alice and "id": 2 for Bob.

  • Deposit $200 into Alice's account by running:
curl -s -X POST http://localhost:8080/api/accounts/1/deposit \
  -H "Content-Type: application/json" \
  -d '{"amount": 200, "description": "Paycheck"}'

You should see a JSON response with "type": "DEPOSIT" and "amount": 200.

  • Transfer $300 from Alice to Bob by running:
curl -s -X POST http://localhost:8080/api/transactions/transfer \
  -H "Content-Type: application/json" \
  -d '{"fromAccountId": 1, "toAccountId": 2, "amount": 300, "description": "Rent"}'

You should see an array with two transaction objects: one TRANSFER_OUT for Alice and one TRANSFER_IN for Bob.

  • Test the overdraft protection by trying to withdraw more than Alice's remaining balance:
curl -s -X POST http://localhost:8080/api/accounts/1/withdraw \
  -H "Content-Type: application/json" \
  -d '{"amount": 99999, "description": "Overdraft test"}'

You should see a 400 error response with the message Insufficient funds. Available balance: 900. Alice started with 1000, received a 200 deposit, then transferred 300 out. That leaves 900.

  • Verify Alice's transaction history by running:
curl -s http://localhost:8080/api/accounts/1/transactions

You should see an array with two transactions: the DEPOSIT of 200 and the TRANSFER_OUT of 300.

  • Open your browser and go to http://localhost:8080/h2-console.
  • Enter jdbc:h2:mem:bankingdb in the JDBC URL field.
  • Leave the username as sa and the password blank, then click Connect.
  • Run SELECT * FROM ACCOUNT to see both accounts with their updated balances.
  • Run SELECT * FROM TRANSACTION to see all transaction records including the TRANSFER_IN and TRANSFER_OUT entries.

Getting a compilation error on startup?

Check that all new files are in the correct packages. TransactionType.java and Transaction.java go in model/. TransactionRepository.java goes in repository/. The three DTO files go in dto/.

Also verify that the package declaration at the top of each file matches its folder path. A file in model/ must declare package com.example.bankingapi.model.

help me fix compilation errors when starting my Spring Boot banking API

Your banking API now handles real financial operations with proper validation and error handling. Every deposit, withdrawal, and transfer is recorded as a transaction and linked to the right account. Next up, you will take on a Secret Mission to add an account statement endpoint with filtering.

Secret mission

Add an Account Statement Endpoint

Your banking API can create accounts, deposit, withdraw, and transfer. But real banking APIs also let clients pull account statements with filters. Build a GET /api/accounts/{id}/statement endpoint that returns a filtered list of transactions. Add optional query parameters for transaction type, start date, and end date.

Before you go

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 with an in-memory database, so there are no ongoing costs.

Resources you used:

  • Spring Boot project folder (banking-api) on your Desktop.
  • Apache Maven global installation.
  • H2 in-memory database (ephemeral, only exists while the app is running).
  • Spring Boot application process on port 8080.

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with the Secret Mission.

  • Leave the Spring Boot application running in your terminal.
  • Your H2 database retains data as long as the app is running.
  • The banking-api folder stays on your Desktop.

Pause - I'll come back to this later

Stop the running application to free up your terminal and port 8080. Your project files stay on disk so you can restart later.

  • Go to the terminal where your Spring Boot app is running.
  • Press Ctrl+C to stop the application.
  • Your H2 data is lost when the app stops because it is an in-memory database. This is expected. The data rebuilds when you restart.
  • To restart later, open a terminal, navigate to your banking-api folder, and run ./mvnw spring-boot:run.

Delete - I don't want to use this again

Remove all project files and start fresh. Maven itself can stay installed for future Java projects.

  • Go to the terminal where your Spring Boot app is running and press Ctrl+C to stop it.
  • Delete the banking-api folder from your Desktop.
  • The H2 database is already gone once the app stops. No separate cleanup needed.
  • Optionally, remove the downloaded Spring Initializr ZIP file if it is still in your Downloads folder.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-structured Banking Account API with Spring Boot. That is no small thing.

You've learned how to:

  • Built a REST API with 6+ endpoints across two controllers, structured using the three-layer architecture (Controller, Service, Repository) used in enterprise Java teams.
  • Separated your API contract from your database model using DTOs, enforced input rules with Bean Validation, and centralized error handling with @RestControllerAdvice for clean JSON error responses.
  • Created JPA entities with a one-to-many relationship and wrote business logic with real-world constraints like overdraft protection and transfer validation.
  • Built an account statement endpoint with query parameter filtering for transaction type and date range.

Ready to quiz yourself?