Build a Bank Account Transfer API
Build a REST API with Spring Boot that processes money transfers with transactional safety.
Introduction
30 Second Summary
Every time you transfer money between bank accounts, a backend system verifies the sender has enough funds, deducts the amount, credits the receiver, and logs everything in a single atomic operation. If any step fails, the entire transfer reverses as if nothing happened.
In this project, you will build a Bank Account Transfer REST API using Spring Boot that creates accounts, retrieves balances, and processes money transfers with transactional safety.
What You'll Build
A REST API with endpoints for creating bank accounts, checking balances, listing all accounts, and transferring money between them.
By the end of this project, you'll have:
- A Spring Boot REST API with 4 endpoints running on a local MySQL database.
- A layered architecture following enterprise patterns (Controller, Service, Repository, DTO).
- A @Transactional transfer operation that automatically rolls back if anything fails mid-transfer.
- Secret Mission: Add a Transaction entity that logs every transfer with timestamps, sender, receiver, and amount, exposed via a GET endpoint for transaction history.
Are there any prerequisites?
You should be comfortable with Java OOP basics (classes, interfaces) and have MySQL installed locally. A basic understanding of REST APIs (what GET and POST requests do) is helpful.
Set Up the Spring Boot Project
Every Spring Boot application starts as a scaffold. You pick your build tool, dependencies, and Java version, and Spring Initializr generates a ready-to-run project skeleton for you.
In this step, you will generate the project, connect it to a MySQL database, and confirm that Spring Boot starts cleanly with Hibernate managing your database schema.
In this step, get ready to:
- Generate the Spring Boot project with Spring Initializr.
- Open the project in IntelliJ IDEA and configure the MySQL connection.
- Verify the application starts on port 8080 and Hibernate auto-creates tables.
Generate the project with Spring Initializr
Spring Initializr is a web tool that generates a pre-configured project based on the options you select. It creates your build file, directory structure, and main application class automatically.
- Open your browser and navigate to https://start.spring.io.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot to 3.5.15.
- Under Project Metadata, set Group to com.example.
- Set Artifact to bankapi.
- Set Packaging to Jar.
- Set Java to 21.
- Click ADD DEPENDENCIES and search for Spring Web. Select it.
- Search for Spring Data JPA and select it.
- Search for MySQL Driver and select it.
- Search for Validation and select it.
- Click GENERATE to download the project as a ZIP file.
- Extract the downloaded bankapi.zip file to your Desktop.
What did Spring Initializr just create?
The generated ZIP contains a complete Maven project with a pom.xml build file, a main application class (BankapiApplication.java), and the standard Spring Boot directory structure.
The four dependencies you selected map to starter packages: spring-boot-starter-web (REST + embedded Tomcat), spring-boot-starter-data-jpa (database access via Hibernate), mysql-connector-j (MySQL driver), and spring-boot-starter-validation (input validation with Jakarta Bean Validation).
Open in IntelliJ and configure MySQL
With the project generated, you need to import it into your IDE and tell Spring Boot how to connect to a MySQL database.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type IntelliJ IDEA and press Enter to open it.
- Click File in the top menu bar.
- Click Open.
- Navigate to your Desktop and select the extracted bankapi folder, then click OK.
What happens when IntelliJ opens the project?
IntelliJ detects the pom.xml and imports the project as a Maven project. It downloads all dependencies declared in pom.xml automatically. You can see progress in the bottom status bar. Wait for this to complete before proceeding.
Next, create the MySQL database that Spring Boot will connect to.
- Open a terminal window on your machine (this is separate from IntelliJ).
- Log in to MySQL by running this command:
mysql -u root -p
- Enter your MySQL root password when prompted.
- Create the bankapi database by running:
CREATE DATABASE bankapi;
- Exit the MySQL prompt by typing exit and pressing Enter.
Access denied or command not found?
If you see an access denied error, double-check that you are using the correct MySQL root password. If mysql is not recognized as a command, you may need to add the MySQL bin directory to your system PATH.
Help me troubleshoot my MySQL connection.
Now configure the application to connect to this database.
- In IntelliJ, expand src > main > resources in the Project panel on the left.
- Double-click application.properties to open it.
- Add the MySQL connection configuration by pasting this into the file:
spring.datasource.url=jdbc:mysql://localhost:3306/bankapi
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
- Replace your-mysql-password-here with your actual MySQL root password.
What do these properties do?
- spring.datasource.url tells Spring Boot the JDBC connection string for your local MySQL database.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your entity classes.
- spring.jpa.show-sql=true prints the SQL statements Hibernate generates in your console, useful for learning and debugging.
- spring.jpa.open-in-view=false disables a pattern that can leak database sessions into the controller layer. This is a best practice for REST APIs.
- Save application.properties by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Verify the application starts
With the database configured, you can now run the application and confirm everything connects properly.
- In IntelliJ, navigate to src > main > java > com.example.bankapi in the Project panel.
- Right-click BankapiApplication.java (the file with the main method).
- Select Run 'BankapiApplication'.
What should I see in the console?
After a few seconds, you should see output in the Run panel at the bottom of IntelliJ. Look for a line that says Started BankapiApplication and confirms the app is running on port 8080. You should also see Hibernate: prefixed SQL statements in the log, which confirms Hibernate connected to MySQL successfully.
- Verify the application started by looking for the Started BankapiApplication message in the console output.
You can also confirm the database tables were created.
- Open a new terminal and log in to MySQL again by running:
mysql -u root -p
- Check the tables in your bankapi database by running:
USE bankapi;
SHOW TABLES;
You should see an empty result set since you have not created any entity classes yet. The database is connected and ready.
Application failed to start?
The most common cause is incorrect database credentials in application.properties. Check that your password matches and that the bankapi database exists. Also confirm MySQL is running on port 3306 (the default).
Help me fix my Spring Boot startup error.
✔️ Awesome, I've got everything!
Great. Double check you have saved application.properties and that your application is running with no errors in the console.
ⓧ I'd like to double check the full code
Here is what your application.properties should look like:
spring.datasource.url=jdbc:mysql://localhost:3306/bankapi
spring.datasource.username=root
spring.datasource.password=your-mysql-password-here
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
Here is what your pom.xml should look like (this was generated by Spring Initializr):
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.15</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>bankapi</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>bankapi</name>
<description>Bank Account Transfer API</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Here is your main application class (auto-generated by Spring Initializr):
package com.example.bankapi;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class BankapiApplication {
public static void main(String[] args) {
SpringApplication.run(BankapiApplication.class, args);
}
}
Your Spring Boot project is running and connected to MySQL. Next up, you will build the entity and repository layer to define how your data is stored.
Build the Entity and Repository Layer
Your Spring Boot project is running and connected to MySQL. Now you need to tell the application what data it stores and how to interact with the database.
In this step, you will create an entity class that maps a Java object to a database table, and a repository interface that gives you database operations for free. This is the foundation that the rest of your API builds on.
In this step, get ready to:
- Create the Account entity with JPA annotations.
- Create the AccountRepository interface for database access.
- Verify Hibernate creates the accounts table in MySQL.
Create the Account entity
An entity is a Java class that Spring Data JPA maps directly to a database table. Each instance of the class represents one row in that table.
- In the IntelliJ Project tool window on the left, expand src/main/java/com/example/bankapi.
- Right-click the bankapi package and select New, then Package.
- Type entity and press Enter.
- Right-click the new entity package and select New, then Java Class.
- Type Account and press Enter.
- Replace the entire contents of Account.java with the following code:
package com.example.bankapi.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class Account {
// Primary key, auto-generated by the database
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Name of the account holder
private String ownerName;
// Account balance stored as BigDecimal for financial precision
private BigDecimal balance;
// No-arg constructor required by JPA
public Account() {
}
// Convenience constructor for creating new accounts
public Account(String ownerName, BigDecimal balance) {
this.ownerName = ownerName;
this.balance = balance;
}
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;
}
}
What does this code do?
@Entity tells Hibernate to map this class to a database table called account. Each field becomes a column.
@Id and @GeneratedValue(strategy = GenerationType.IDENTITY) make the id field an auto-incrementing primary key managed by MySQL.
BigDecimal is used for the balance instead of double or float. Financial applications require exact decimal precision, and floating-point types introduce rounding errors.
Why BigDecimal for money?
A double cannot represent 0.1 exactly in binary. Over many transactions, tiny rounding errors accumulate and cause balances to drift. Banks and payment companies always use BigDecimal (or equivalent fixed-precision types). This immediately signals domain awareness to interviewers at financial companies.
- Save Account.java by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Check that IntelliJ shows no red underlines in the file. If the imports resolve correctly, you are good to continue.
Seeing red underlines on the imports?
Make sure the package statement at the top says package com.example.bankapi.entity; exactly. A mismatch between the package declaration and the folder path causes unresolved imports.
If jakarta.persistence is unresolved, try clicking File, then Invalidate Caches / Restart so IntelliJ re-indexes your Maven dependencies.
Help me fix import errors in my Account entity
Create the Account repository
A repository gives you methods to save, find, update, and delete entities without writing SQL. JPA generates the implementation at runtime based on the interface you define.
- Right-click the bankapi package (the same parent where you created entity) and select New, then Package.
- Type repository and press Enter.
- Right-click the new repository package and select New, then Java Class.
- In the dialog, select Interface (not Class), type AccountRepository, and press Enter.
- Replace the entire contents of AccountRepository.java with the following code:
package com.example.bankapi.repository;
import com.example.bankapi.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
// Extends JpaRepository to get CRUD operations for free
public interface AccountRepository extends JpaRepository<Account, Long> {
}
What does this code do?
By extending JpaRepository<Account, Long>, Spring Data JPA automatically provides methods like save(), findById(), findAll(), and deleteById(). You do not write any implementation code.
The two type parameters tell JPA that this repository manages Account entities and that their primary key is of type Long.
- Save AccountRepository.java.
Don't see the Interface option in the dialog?
When the New Java Class dialog appears, look for a small dropdown or list below the name field. Select Interface from that list before pressing Enter. If you accidentally created a class, delete the file and try again.
Help me create an interface instead of a class in IntelliJ
Test the layer by running the app
When the application starts, Hibernate reads your @Entity class and creates a matching table in MySQL. You can verify this without writing any extra code.
- Run the application by right-clicking BankapiApplication.java and selecting Run 'BankapiApplication'.
- Watch the console output at the bottom of IntelliJ. You should see a line containing Hibernate: create table confirming the table was generated.
- Open your MySQL client and verify the table exists by running:
mysql -u root -p -e "USE bankapi; SHOW TABLES;"
You should see account listed in the output.
Why does the table appear automatically?
The spring.jpa.hibernate.ddl-auto=update property you set in application.properties tells Hibernate to compare your entity classes against the database and create or alter tables as needed. In production, teams typically set this to validate or none and use migration tools instead.
Application fails to start?
Check that your MySQL server is running. If the console shows a connection refused error, start MySQL before running the app.
Verify the password in application.properties matches your actual MySQL root password. A mismatch causes an access denied error.
Help me fix my Spring Boot application startup error
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete Account.java:
package com.example.bankapi.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;
@Entity
public class Account {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String ownerName;
private BigDecimal balance;
public Account() {
}
public Account(String ownerName, BigDecimal balance) {
this.ownerName = ownerName;
this.balance = balance;
}
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;
}
}
Here is the complete AccountRepository.java:
package com.example.bankapi.repository;
import com.example.bankapi.entity.Account;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AccountRepository extends JpaRepository<Account, Long> {
}
Your data layer is solid. Hibernate mapped your Java class to a real MySQL table, and your repository is ready to provide CRUD operations without any SQL. Next up, you will build the service layer that handles business logic and processes money transfers with transactional safety.
Build the Service Layer with @Transactional Transfer
Your entity and repository are in place. Now you need the business logic layer that sits between the controller (which you will build next) and the repository.
In enterprise Spring Boot applications, the service layer owns all business rules. It decides whether a transfer is valid, performs the debit and credit, and ensures the entire operation either succeeds completely or rolls back completely.
In this step, get ready to:
- Create DTO classes that validate incoming API requests.
- Build a service class with a @Transactional money transfer method.
- Implement custom exception classes for error handling.
Create DTOs for request and response
A DTO (Data Transfer Object) is a simple class that carries data between layers. DTOs keep your internal entity structure hidden from API consumers. They also let you attach validation rules to incoming data before it touches your business logic.
You will create three DTOs: one for account creation requests, one for transfer requests, and one for API responses.
- In IntelliJ, right-click the com.example.bankapi package in the Project sidebar.
- Select New then Package.
- Name it dto and press Enter.
This creates the com.example.bankapi.dto package where all your DTOs will live.
- Right-click the new dto package, select New then Java Class, and name it CreateAccountRequest.
- Replace the file contents with the following code:
package com.example.bankapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public class CreateAccountRequest {
// Owner name cannot be blank
@NotBlank(message = "Owner name is required")
private String ownerName;
// Initial balance must be provided and positive
@NotNull(message = "Initial balance is required")
@Positive(message = "Initial balance must be positive")
private BigDecimal 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?
This DTO defines the shape of data the API expects when creating a new account. The jakarta.validation annotations enforce rules automatically.
- @NotBlank rejects empty strings or null values for ownerName.
- @NotNull ensures a value is provided for initialBalance.
- @Positive rejects zero or negative numbers. Nobody opens a bank account with nothing in it.
- Create another Java class in the dto package named TransferRequest.
- Replace the file contents with:
package com.example.bankapi.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public class TransferRequest {
// ID of the account sending money
@NotNull(message = "Source account ID is required")
private Long fromAccountId;
// ID of the account receiving money
@NotNull(message = "Destination account ID is required")
private Long toAccountId;
// Amount to transfer (must be positive)
@NotNull(message = "Transfer amount is required")
@Positive(message = "Transfer amount must be positive")
private BigDecimal amount;
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;
}
}
What does this code do?
This DTO captures the three pieces of information needed for a transfer: who is sending, who is receiving, and how much. All three fields use @NotNull so the API rejects requests with missing data. The amount also uses @Positive to prevent zero or negative transfers.
- Create one more Java class in the dto package named AccountResponse.
- Replace the file contents with:
package com.example.bankapi.dto;
import java.math.BigDecimal;
public class AccountResponse {
private Long id;
private String ownerName;
private BigDecimal balance;
// All-args constructor for easy creation from entity
public AccountResponse(Long id, String ownerName, BigDecimal balance) {
this.id = id;
this.ownerName = ownerName;
this.balance = balance;
}
public Long getId() {
return id;
}
public String getOwnerName() {
return ownerName;
}
public BigDecimal getBalance() {
return balance;
}
}
Why a separate response class?
The AccountResponse DTO decouples what the API returns from what the database stores. If you later add internal fields to the Account entity (like createdAt or isLocked), they stay hidden from API consumers. This is the DTO pattern in action.
- Save all three files with Cmd+S (macOS) or Ctrl+S (Windows).
Seeing red underlines on the imports?
If IntelliJ shows errors on jakarta.validation imports, your Maven dependencies may not have finished downloading. Click the Load Maven Changes icon (circular arrows) in the top-right notification bar.
Help me fix red underlines in my DTO imports.
Create the AccountService
The service layer is where your business logic lives. It coordinates between the repository (data access) and the controller (HTTP handling). The most important method here is transfer, which must either move money successfully between both accounts or not move it at all.
- Right-click the com.example.bankapi package, select New then Package, and name it service.
- Create a new Java class in the service package named AccountService.
- Replace the file contents with:
package com.example.bankapi.service;
import com.example.bankapi.dto.AccountResponse;
import com.example.bankapi.dto.CreateAccountRequest;
import com.example.bankapi.dto.TransferRequest;
import com.example.bankapi.entity.Account;
import com.example.bankapi.exception.AccountNotFoundException;
import com.example.bankapi.exception.InsufficientBalanceException;
import com.example.bankapi.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class AccountService {
private final AccountRepository accountRepository;
// Constructor injection - Spring provides the repository automatically
public AccountService(AccountRepository accountRepository) {
this.accountRepository = accountRepository;
}
// Create a new account from the validated request
public AccountResponse createAccount(CreateAccountRequest request) {
Account account = new Account(request.getOwnerName(), request.getInitialBalance());
Account saved = accountRepository.save(account);
return new AccountResponse(saved.getId(), saved.getOwnerName(), saved.getBalance());
}
// Find one account by ID or throw if not found
public AccountResponse getAccount(Long id) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new AccountNotFoundException(id));
return new AccountResponse(account.getId(), account.getOwnerName(), account.getBalance());
}
// Return all accounts as response DTOs
public List<AccountResponse> getAllAccounts() {
return accountRepository.findAll().stream()
.map(a -> new AccountResponse(a.getId(), a.getOwnerName(), a.getBalance()))
.toList();
}
// Transfer money between accounts - rolls back if anything fails
@Transactional
public void transfer(TransferRequest request) {
Account fromAccount = accountRepository.findById(request.getFromAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getFromAccountId()));
Account toAccount = accountRepository.findById(request.getToAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getToAccountId()));
if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
throw new InsufficientBalanceException(fromAccount.getId());
}
fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
}
}
What does @Transactional do here?
The @Transactional annotation on the transfer method tells Spring to wrap the entire method in a database transaction. If any exception is thrown after the debit (like a failed credit), the debit automatically rolls back.
Without @Transactional, a crash between the debit and credit would leave the sender's money gone and the receiver's unchanged. In banking, that is catastrophic. This is why @Transactional belongs on the service layer, not the controller or repository.
Why do the exception imports show red?
IntelliJ will show red underlines on AccountNotFoundException and InsufficientBalanceException because those classes do not exist yet. You will create them in the next substep, which resolves the errors.
- Save AccountService.java.
Implement custom exceptions
Your service layer needs to communicate specific failure states. Custom exceptions let the exception handler (which you will build in the next step) return meaningful HTTP error codes and messages.
- Right-click the com.example.bankapi package, select New then Package, and name it exception.
- Create a new Java class in the exception package named AccountNotFoundException.
- Replace the file contents with:
package com.example.bankapi.exception;
public class AccountNotFoundException extends RuntimeException {
// Accepts the missing account ID and builds a descriptive message
public AccountNotFoundException(Long id) {
super("Account not found with id: " + id);
}
}
What does this code do?
This exception extends RuntimeException so Spring's @Transactional will automatically trigger a rollback when it is thrown. By default, Spring rolls back on any RuntimeException but not on checked exceptions.
- Create another Java class in the exception package named InsufficientBalanceException.
- Replace the file contents with:
package com.example.bankapi.exception;
public class InsufficientBalanceException extends RuntimeException {
// Accepts the account ID that has insufficient funds
public InsufficientBalanceException(Long accountId) {
super("Insufficient balance in account: " + accountId);
}
}
What does this code do?
This exception is thrown when a transfer amount exceeds the sender's balance. The service layer catches this condition before any money moves. Because it also extends RuntimeException, it triggers an automatic rollback if thrown mid-transaction.
- Save both exception files.
- Return to AccountService.java. The red underlines on the exception imports should now be gone.
- Run the application by right-clicking BankapiApplication.java and selecting Run.
You should see the application start on port 8080 with no errors in the console.
Application fails to start?
If you see compilation errors, check that your exception class names match exactly: AccountNotFoundException and InsufficientBalanceException. The package must be com.example.bankapi.exception to match the import in AccountService.java.
Help me fix compilation errors in my Spring Boot service layer.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files before moving on.
ⓧ I'd like to double check the full code
Here is the full code for each file you created in this step.
package com.example.bankapi.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public class CreateAccountRequest {
@NotBlank(message = "Owner name is required")
private String ownerName;
@NotNull(message = "Initial balance is required")
@Positive(message = "Initial balance must be positive")
private BigDecimal 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;
}
}
package com.example.bankapi.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
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 = "Transfer amount is required")
@Positive(message = "Transfer amount must be positive")
private BigDecimal amount;
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;
}
}
package com.example.bankapi.dto;
import java.math.BigDecimal;
public class AccountResponse {
private Long id;
private String ownerName;
private BigDecimal balance;
public AccountResponse(Long id, String ownerName, BigDecimal balance) {
this.id = id;
this.ownerName = ownerName;
this.balance = balance;
}
public Long getId() {
return id;
}
public String getOwnerName() {
return ownerName;
}
public BigDecimal getBalance() {
return balance;
}
}
package com.example.bankapi.exception;
public class AccountNotFoundException extends RuntimeException {
public AccountNotFoundException(Long id) {
super("Account not found with id: " + id);
}
}
package com.example.bankapi.exception;
public class InsufficientBalanceException extends RuntimeException {
public InsufficientBalanceException(Long accountId) {
super("Insufficient balance in account: " + accountId);
}
}
package com.example.bankapi.service;
import com.example.bankapi.dto.AccountResponse;
import com.example.bankapi.dto.CreateAccountRequest;
import com.example.bankapi.dto.TransferRequest;
import com.example.bankapi.entity.Account;
import com.example.bankapi.exception.AccountNotFoundException;
import com.example.bankapi.exception.InsufficientBalanceException;
import com.example.bankapi.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@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 new AccountResponse(saved.getId(), saved.getOwnerName(), saved.getBalance());
}
public AccountResponse getAccount(Long id) {
Account account = accountRepository.findById(id)
.orElseThrow(() -> new AccountNotFoundException(id));
return new AccountResponse(account.getId(), account.getOwnerName(), account.getBalance());
}
public List<AccountResponse> getAllAccounts() {
return accountRepository.findAll().stream()
.map(a -> new AccountResponse(a.getId(), a.getOwnerName(), a.getBalance()))
.toList();
}
@Transactional
public void transfer(TransferRequest request) {
Account fromAccount = accountRepository.findById(request.getFromAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getFromAccountId()));
Account toAccount = accountRepository.findById(request.getToAccountId())
.orElseThrow(() -> new AccountNotFoundException(request.getToAccountId()));
if (fromAccount.getBalance().compareTo(request.getAmount()) < 0) {
throw new InsufficientBalanceException(fromAccount.getId());
}
fromAccount.setBalance(fromAccount.getBalance().subtract(request.getAmount()));
toAccount.setBalance(toAccount.getBalance().add(request.getAmount()));
accountRepository.save(fromAccount);
accountRepository.save(toAccount);
}
}
Your business logic is wired up. The service layer can create accounts, look them up, and transfer money with transactional safety. Next up, you will build the REST controller and a global exception handler so these methods are accessible over HTTP.
Build the Controller and Exception Handler
Your service layer is complete with transfer logic, validation checks, and @Transactional safety. The business logic is solid, but there's no way for the outside world to reach it yet.
In this step, you'll create the REST controller that maps HTTP requests to your service methods. You'll also build a global exception handler that catches errors and returns clean, consistent JSON responses to the client.
In this step, get ready to:
- Create the REST controller with four endpoints.
- Build the global exception handler with @ControllerAdvice.
- Test all endpoints and verify transactional integrity.
Create the REST controller
The controller layer is the entry point for all HTTP requests. It delegates to the service layer and returns appropriate HTTP responses.
- In IntelliJ, right-click the com.example.bankapi package in the Project sidebar.
- Select New then Package and name it controller.
- Right-click the new controller package, select New then Java Class, and name it AccountController.
- Replace the generated content with the following code:
package com.example.bankapi.controller;
import com.example.bankapi.dto.AccountResponse;
import com.example.bankapi.dto.CreateAccountRequest;
import com.example.bankapi.dto.TransferRequest;
import com.example.bankapi.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;
import java.util.Map;
@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 ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<AccountResponse> getAccount(@PathVariable Long id) {
return ResponseEntity.ok(accountService.getAccount(id));
}
@GetMapping
public ResponseEntity<List<AccountResponse>> getAllAccounts() {
return ResponseEntity.ok(accountService.getAllAccounts());
}
@PostMapping("/transfer")
public ResponseEntity<Map<String, String>> transfer(@Valid @RequestBody TransferRequest request) {
accountService.transfer(request);
return ResponseEntity.ok(Map.of("message", "Transfer successful"));
}
}
What does this code do?
- @RestController combines @Controller and @ResponseBody, so every method automatically serializes its return value to JSON.
- @RequestMapping("/api/accounts") sets the base path for all endpoints in this controller.
- @Valid on @RequestBody parameters triggers the Bean Validation annotations you defined in your DTOs (like @NotBlank and @Positive).
- The constructor injection pattern lets Spring Boot automatically wire in the AccountService dependency.
- Save AccountController.java.
Seeing red import errors?
If IntelliJ shows red underlines on the import statements, wait a moment for the IDE to index the file. If they persist, press Cmd+Shift+O (macOS) or Ctrl+Shift+O (Windows) to optimize imports.
help me fix red import errors in my Spring Boot controller
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
package com.example.bankapi.controller;
import com.example.bankapi.dto.AccountResponse;
import com.example.bankapi.dto.CreateAccountRequest;
import com.example.bankapi.dto.TransferRequest;
import com.example.bankapi.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;
import java.util.Map;
@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 ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping("/{id}")
public ResponseEntity<AccountResponse> getAccount(@PathVariable Long id) {
return ResponseEntity.ok(accountService.getAccount(id));
}
@GetMapping
public ResponseEntity<List<AccountResponse>> getAllAccounts() {
return ResponseEntity.ok(accountService.getAllAccounts());
}
@PostMapping("/transfer")
public ResponseEntity<Map<String, String>> transfer(@Valid @RequestBody TransferRequest request) {
accountService.transfer(request);
return ResponseEntity.ok(Map.of("message", "Transfer successful"));
}
}
Create the global exception handler
Without an exception handler, Spring returns a generic error page when something goes wrong. A global exception handler intercepts specific exceptions and returns structured JSON that API consumers can parse programmatically.
- Right-click the existing com.example.bankapi.exception package in the Project sidebar.
- Select New then Java Class, and name it GlobalExceptionHandler.
- Replace the generated content with the following code:
package com.example.bankapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleAccountNotFound(AccountNotFoundException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 404);
body.put("error", "Not Found");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(InsufficientBalanceException.class)
public ResponseEntity<Map<String, Object>> handleInsufficientBalance(InsufficientBalanceException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Bad Request");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Validation Failed");
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
body.put("details", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
What does this code do?
- @ControllerAdvice makes this class a global interceptor for exceptions thrown by any controller in the application.
- Each @ExceptionHandler method catches a specific exception type and builds a consistent JSON error response with a timestamp, status code, and descriptive message.
- The MethodArgumentNotValidException handler extracts individual field errors from the validation result, so the client knows exactly which fields failed and why.
This pattern is the enterprise standard for REST APIs. Consumers of your API get predictable error shapes they can parse programmatically.
- Save GlobalExceptionHandler.java.
IntelliJ can't resolve MethodArgumentNotValidException?
Make sure spring-boot-starter-web is in your pom.xml dependencies. This class comes from the org.springframework.web.bind package. If the import is still red, click the Maven reload icon in IntelliJ's Maven tool window.
help me fix unresolved MethodArgumentNotValidException in my Spring Boot project
✔️ Awesome, I've got everything!
Great. Make sure you've saved the file before moving on.
ⓧ I'd like to double check the full code
package com.example.bankapi.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleAccountNotFound(AccountNotFoundException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 404);
body.put("error", "Not Found");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(InsufficientBalanceException.class)
public ResponseEntity<Map<String, Object>> handleInsufficientBalance(InsufficientBalanceException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Bad Request");
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Validation Failed");
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error ->
fieldErrors.put(error.getField(), error.getDefaultMessage())
);
body.put("details", fieldErrors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
Why @ControllerAdvice instead of try-catch in every method?
Without @ControllerAdvice, you would need to wrap every controller method in try-catch blocks to produce structured error responses. That scatters error-handling logic across every endpoint.
A centralized exception handler keeps controllers focused on the happy path. When you add new endpoints later, they automatically benefit from the same error handling. This is the pattern enterprise teams at Oracle, Mastercard, and Deutsche Bank expect.
Test all endpoints with Postman or curl
Your API is now fully wired. Time to prove it works by making real HTTP requests against every endpoint.
- Restart the application by right-clicking BankapiApplication.java and selecting Run.
- Confirm the console shows the application started on port 8080 with no errors.
Postman or curl?
You can test with either tool. The instructions below show curl commands you can run directly in your terminal. If you prefer Postman, use the same URLs and JSON bodies in its interface.
- Create your first account by running this command:
curl -X POST http://localhost:8080/api/accounts -H "Content-Type: application/json" -d '{"ownerName": "Alice", "initialBalance": 1000}'
You should see a JSON response with an id, ownerName, and balance field.
- Create a second account by running:
curl -X POST http://localhost:8080/api/accounts -H "Content-Type: application/json" -d '{"ownerName": "Bob", "initialBalance": 500}'
- List all accounts by running:
curl http://localhost:8080/api/accounts
You should see both Alice and Bob in the JSON array response.
- Transfer money from Alice to Bob by running:
curl -X POST http://localhost:8080/api/accounts/transfer -H "Content-Type: application/json" -d '{"fromAccountId": 1, "toAccountId": 2, "amount": 200}'
You should see a response with the message "Transfer successful".
- Verify balances updated correctly by running:
curl http://localhost:8080/api/accounts
Alice should now have 800 and Bob should have 700.
- Now test the transactional safety. Try transferring more than Alice has by running:
curl -X POST http://localhost:8080/api/accounts/transfer -H "Content-Type: application/json" -d '{"fromAccountId": 1, "toAccountId": 2, "amount": 50000}'
You should see a 400 error response with the message about insufficient balance. No money moved.
- Confirm no partial changes by listing accounts one more time:
curl http://localhost:8080/api/accounts
Alice still has 800 and Bob still has 700. The @Transactional annotation prevented any partial state from persisting.
Getting "Connection refused" errors?
Make sure the Spring Boot application is running. Check the IntelliJ console for errors. If you see a port conflict, stop any other process on port 8080 before restarting.
Also verify your MySQL server is running. Spring Boot will fail to start if it cannot connect to the database.
help me debug why my Spring Boot app won't start or accept connections
Secret mission
Add Transaction History
Your API can transfer money, but there's no record of what happened. Real banking systems log every single transaction for auditing and compliance. Can you add a Transaction entity that records every transfer with timestamps, sender, receiver, and amount, then expose it through a new endpoint?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- The bankapi project folder (your Spring Boot application code).
- The bankapi MySQL database (with accounts and transaction tables).
- The running Spring Boot application process (if still active).
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with additional endpoints.
- Your Spring Boot application stays running in IntelliJ IDEA.
- Your bankapi MySQL database retains all account and transaction data.
- Your project files remain wherever you extracted them.
Pause - I'll come back to this later
Shut down the running application to free up memory. Your files and database stay intact for when you return.
- In IntelliJ IDEA, click the red square (Stop) button in the top toolbar to stop the Spring Boot application.
- Close IntelliJ IDEA.
- Your bankapi database and project folder remain untouched. When you come back, open the project in IntelliJ and run BankapiApplication.java again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes your code and database permanently.
- In IntelliJ IDEA, click the red square (Stop) button to stop the application, then close IntelliJ.
- Drop the MySQL database by opening your MySQL client and running the following command:
mysql -u root -p -e "DROP DATABASE bankapi;"
- Delete the project folder. On macOS or Linux, run:
rm -rf ~/Desktop/bankapi
On Windows, run:
rmdir /s /q %USERPROFILE%\Desktop\bankapi
Check your Downloads folder too
If you still have the original bankapi.zip file from Spring Initializr in your Downloads folder, you can delete that as well.
Nice Work!
Nice Work!
You just built a production-style Bank Account Transfer API with Spring Boot, Spring Data JPA, and MySQL. This is the exact architecture that enterprise teams at Oracle, Mastercard, and Deutsche Bank evaluate in technical assessments.
You learned how to:
- Built a complete REST API with layered architecture (Controller, Service, Repository) and the DTO pattern to decouple internal entities from API contracts.
- Used @Transactional to ensure money transfers are atomic, so if the credit fails after the debit, the entire operation rolls back automatically.
- Applied Bean Validation with jakarta.validation annotations and built a global exception handler with @ControllerAdvice for consistent error responses.
- Secret Mission: Added transaction history logging with a second entity, a custom repository query method, and a dedicated endpoint to retrieve transfer records.
Ready to quiz yourself?