Deploy a Spring Boot REST API on AWS
Build and deploy a personal expense tracker API with Spring Boot on AWS.
Introduction
30 Second Summary
Every app you use tracks data behind the scenes. From logging bank transactions to recording fitness stats, a REST API handles the heavy lifting.
In this project, you will build a Personal Expense Tracker REST API using Spring Boot. You'll deploy it live on AWS, making your expense tracking endpoints accessible from anywhere.
What You'll Build
A production-structured Spring Boot API with CRUD endpoints for managing personal expenses, backed by an H2 in-memory database, with input validation, automated tests, and a live cloud deployment.
By the end of this project, you'll have:
- A Spring Boot REST API with full CRUD operations, input validation, and global error handling.
- An H2 in-memory database with JPA entity mapping and automated JUnit tests.
- A live deployment on Elastic Beanstalk accessible via a public URL.
- Secret Mission: Connect a React frontend to your API and display expenses in a styled dashboard.
Are there any prerequisites?
This project assumes you have basic Java knowledge and some familiarity with Spring Boot concepts. You'll install all the tools you need in Step 1.
Set Up Your Tools and AWS Account
Before you write any code, you need a few tools installed on your machine. Java compiles and runs the API. Maven manages dependencies and builds. Git tracks your changes across every step.
You also need the AWS CLI to connect your terminal to Amazon's cloud. In this step, you'll verify each tool is ready and set up your AWS account for deployment later.
In this step, get ready to:
- Verify Java, Maven, and Git are installed.
- Install the AWS CLI.
- Create and configure your AWS account.
Verify Java, Maven, and Git are installed
These three tools form the backbone of your development workflow. Let's make sure they're all available on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
Your terminal is where you'll run every command in this project. Keep it open throughout.
- Check your Java version by running this command:
java -version
✔️ I see a version number
Confirm the version number is 17 or later. If it is, Java is ready.
ⓧ Command not found
You need Java 17 or later. Install it for your operating system.
macOS
- Install Java 17 using Homebrew by running this command:
brew install openjdk@17
Don't have Homebrew?
Homebrew is a package manager for macOS. Install it first by running:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
- Close and reopen your terminal, then run java -version again to confirm it shows 17 or later.
Windows
- Download the Java 17 installer from the Adoptium website.
- Run the installer and follow the prompts.
- Close and reopen your Command Prompt, then run java -version again to confirm it shows 17 or later.
- Check your Maven version by running this command:
mvn -version
✔️ I see a version number
Confirm the version is 3.8 or later. Maven is ready.
ⓧ Command not found
You need Maven 3.8 or later. Install it for your operating system.
macOS
- Install Maven using Homebrew by running this command:
brew install maven
- Run mvn -version again to confirm the install.
Windows
The easiest way to install Maven on Windows is with Chocolatey, a package manager for Windows.
- Install Maven using Chocolatey by running this command in an Administrator Command Prompt:
choco install maven
Don't have Chocolatey?
Install Chocolatey by following the instructions at chocolatey.org/install. You can also install Maven manually by downloading the binary zip from maven.apache.org/download.html, extracting it, and adding the bin directory to your PATH.
- Close and reopen your Command Prompt, then run mvn -version to confirm the install.
- Check your Git version by running this command:
git --version
✔️ I see a version number
Any recent version works. Git is ready.
ⓧ Command not found
Install Git for your operating system.
macOS
- Install Git using Homebrew by running this command:
brew install git
- Run git --version again to confirm the install.
Windows
- Download the Git installer from git-scm.com.
- Run the installer and accept the default settings.
- Close and reopen your Command Prompt, then run git --version to confirm the install.
Tools installed but still showing errors?
If you just installed a tool but the version check still fails, close your terminal completely and reopen it. Your terminal loads PATH changes only when it starts up.
Help me troubleshoot my Java, Maven, or Git installation.
Install the AWS CLI
The AWS CLI lets you interact with Amazon's cloud services from your terminal. You'll use it in the final step to deploy your API.
- Check if the AWS CLI is already installed by running this command:
aws --version
✔️ I see a version number
The AWS CLI is already installed. Skip ahead to the next substep.
ⓧ Command not found
Install the AWS CLI for your operating system.
macOS
- Download the AWS CLI installer by running this command:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
- Install the AWS CLI by running this command:
sudo installer -pkg AWSCLIV2.pkg -target /
Why does this ask for my password?
The sudo command runs the installer with administrator privileges. Type your Mac login password when prompted. You won't see the characters as you type.
- Verify the installation by running:
aws --version
You should see output starting with aws-cli/2 followed by a version number.
- Clean up the installer file by running:
rm AWSCLIV2.pkg
Windows
- Download the AWS CLI installer from this link.
- Run the downloaded MSI installer and follow the prompts.
- Close and reopen your Command Prompt after installation.
- Verify the installation by running:
aws --version
You should see output starting with aws-cli/2 followed by a version number.
Still seeing 'command not found' after installing?
Make sure you closed and reopened your terminal after installation. If it still fails, the installer may not have added aws to your system PATH. Try opening a brand new terminal window.
Help me troubleshoot my AWS CLI installation.
Create your AWS account and configure the CLI
Your API will eventually live on AWS. To deploy it, you need an AWS account and a way for your terminal to authenticate with it.
- Open aws.amazon.com/free in your browser.
✔️ I already have an AWS account
Sign in to the AWS Management Console and skip ahead to the IAM user creation steps below.
ⓧ I need to create one
- Click Create a Free Account.
- Enter your email address and choose an account name.
- Follow the prompts to verify your email, set a password, and enter your payment information.
- Select the Basic support - Free plan.
Will this cost me anything?
New AWS accounts receive $100 in free credits at sign-up. The resources in this project are covered by those credits. You won't be charged as long as you clean up your resources in the Before You Go section.
Now you need an IAM user with programmatic access. IAM users let you create separate credentials for CLI access instead of using your root account.
- Sign in to the AWS Management Console at console.aws.amazon.com.
- Type IAM in the search bar at the top and select IAM from the results.
- Click Users in the left sidebar.
- Click Create user.
- Enter a user name (for example, expense-tracker-cli) and click Next.
- Select Attach policies directly.
- Search for AdministratorAccess and check the box next to it.
- Click Next, then click Create user.
What is IAM?
IAM (Identity and Access Management) controls who can access your AWS resources. Creating a separate IAM user for CLI access is more secure than using your root account credentials.
Next, create an access key so the AWS CLI can authenticate as this user.
- Click on the user name you just created to open its details page.
- Click the Security credentials tab.
- Scroll down to the Access keys section and click Create access key.
- Select Command Line Interface (CLI) as the use case.
- Check the confirmation checkbox at the bottom of the page, then click Next.
- Click Create access key.
- Click Download .csv file to save your Access Key ID and Secret Access Key. Store this file somewhere safe.
Save your keys now
This is the only time you can view or download your Secret Access Key. If you close this page without saving, you'll need to delete the key and create a new one.
Finally, connect your terminal to AWS using the credentials you just created.
- Configure the AWS CLI by running this command:
aws configure
What will this command ask me?
This command launches an interactive wizard that asks for four values. Enter each one and press Enter after each prompt:
- AWS Access Key ID: paste the Access Key ID from your downloaded CSV file.
- AWS Secret Access Key: paste the Secret Access Key from your downloaded CSV file.
- Default region name: enter us-east-1.
- Default output format: enter json.
- Verify that the CLI is connected to your account by running this command:
aws sts get-caller-identity
You should see a JSON response containing your UserId, Account number, and Arn. This confirms your terminal can talk to AWS.
Seeing 'Unable to locate credentials'?
This means aws configure didn't save your credentials correctly. Run aws configure again and double-check that you pasted the full Access Key ID and Secret Access Key from your CSV file.
Help me fix my AWS CLI configuration.
Your development environment is fully set up. Next, you'll bootstrap your Spring Boot project and get it running locally.
Bootstrap the Spring Boot Project
Your development environment is ready with Java, Maven, Git, and the AWS CLI all configured. Now it is time to create the actual Spring Boot project that you will build on for the rest of this tutorial.
Spring Initializr is a web-based tool that generates a pre-configured Spring Boot project for you. You pick your build tool, Java version, and dependencies, and it hands you a ready-to-run project scaffold.
In this step, get ready to:
- Generate a Spring Boot project with the right dependencies from Spring Initializr.
- Open the project in your IDE and make your first Git commit.
- Run the application and confirm it starts successfully.
Generate your project on Spring Initializr
Spring Initializr lets you configure every aspect of a new Spring Boot project from a single web page. You will set the build tool, Java version, and library dependencies your expense tracker needs.
- Open your browser and go to https://start.spring.io.
- Under Project, select Maven.
- Under Language, select Java.
- Under Spring Boot, select 3.5.14.
- Under Project Metadata, set Group to com.example.
- Set Artifact to expense-tracker.
- Set Description to Personal Expense Tracker API.
- Leave Packaging set to Jar (the default).
- Set Java to 17.
Now add the four dependencies your project needs.
- Click ADD DEPENDENCIES in the top right of the page.
- Search for Spring Web and select it.
- Search for Spring Data JPA and select it.
- Search for H2 Database and select it.
- Search for Validation and select it.
- Click GENERATE (or press Ctrl+Enter) to download the zip file.
What are these dependencies?
Spring Web adds the embedded web server and REST support your API endpoints need.
Spring Data JPA provides the database access layer so your app can save and retrieve expenses. H2 is a lightweight in-memory database that requires zero installation.
Validation adds Bean Validation annotations so you can reject bad input before it reaches your database.
Safari auto-extracts zip files
If you are using Safari on macOS, the zip file may be automatically extracted after downloading. In that case, look for an expense-tracker folder in your Downloads folder instead of a zip file.
Set up the project and run the application
With the zip file downloaded, you need to move the project to a known location, open it in your IDE, and make sure it runs.
- Move to your Desktop by running this command:
cd ~/Desktop
- Unzip the downloaded project by running this command:
macOS
unzip ~/Downloads/expense-tracker.zip
Already extracted by Safari?
If Safari already unzipped the file, run this instead to move the folder to your Desktop:
mv ~/Downloads/expense-tracker ~/Desktop/expense-tracker
Windows
Expand-Archive -Path $HOME\Downloads\expense-tracker.zip -DestinationPath .
- Move into the project folder by running:
cd expense-tracker
- Confirm the project files exist by running:
ls
You should see pom.xml, mvnw, mvnw.cmd, and a src directory listed in the output.
Don't see these files?
Make sure you ran cd expense-tracker before running ls. If the folder does not exist, check your Downloads folder for the zip file and re-extract it.
help me find my expense-tracker project files after downloading from Spring Initializr
Now open the project in your IDE.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type the name of your IDE (IntelliJ IDEA or VS Code) and press Enter to open it.
- In your IDE, click File in the top menu bar.
- Click Open (or Open Folder in VS Code).
- Select the expense-tracker folder on your Desktop and click Open.
What is inside the generated project?
Spring Initializr created a complete project skeleton for you. Here are the key files:
- pom.xml lists your project dependencies and build settings.
- mvnw and mvnw.cmd are Maven wrapper scripts that let you build without installing Maven globally.
- ExpenseTrackerApplication.java is the main entry point that boots your application.
- application.properties holds your application configuration (mostly empty for now).
Next, initialize a Git repository so you can track every change you make.
- Initialize a Git repository and make your first commit by running these commands:
# Initialize a new Git repository in this folder
git init
# Stage all generated files
git add .
# Create your first commit
git commit -m "Initial Spring Boot project scaffold"
What does this do?
git init creates a new Git repository in the expense-tracker folder. git add . stages every file in the project. git commit saves a snapshot of your project as it is right now.
You should see output that includes Initial Spring Boot project scaffold and a list of created files.
Finally, run the application to make sure everything works.
- Start the Spring Boot application by running:
macOS
./mvnw spring-boot:run
Windows
.\mvnw.cmd spring-boot:run
What should I see?
Maven will download dependencies on first run (this may take a minute or two). Once complete, you will see log output ending with a line like Started ExpenseTrackerApplication.
- Open your browser and go to http://localhost:8080.
You should see a Whitelabel Error Page that says "This application has no explicit mapping for /error." This is expected because your API has no endpoints yet.
Application fails to start?
Make sure you are inside the expense-tracker folder when running the command. If you see a Permission denied error on macOS, run chmod +x mvnw first and try again.
help me troubleshoot my Spring Boot application not starting
- Stop the running application by pressing Ctrl+C in your terminal.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files and that your Git commit went through.
ⓧ I'd like to double check the full code
Here is the full pom.xml that Spring Initializr generated for your project. Compare it against yours to make sure all dependencies are present.
<?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.14</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>expense-tracker</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>expense-tracker</name>
<description>Personal Expense Tracker API</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<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>com.h2database</groupId>
<artifactId>h2</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>
And here is the main application class:
package com.example.expensetracker;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ExpenseTrackerApplication {
public static void main(String[] args) {
SpringApplication.run(ExpenseTrackerApplication.class, args);
}
}
Your Spring Boot project is scaffolded and running. Next up, you will build the data layer with a JPA entity and configure the H2 database.
Build the Data Layer
Your Spring Boot project is scaffolded and running. Now it's time to give your API something to work with: data.
Every REST API needs a data layer that defines what your data looks like and how it gets saved. You'll create a JPA entity that maps a Java class to a database table, and a repository that handles the read/write operations.
In this step, get ready to:
- Create the Expense JPA entity with five fields.
- Set up the ExpenseRepository interface for database access.
- Configure the H2 in-memory database and verify the table was created.
Create the Expense entity
Each expense in your tracker needs a structure. A JPA entity is a Java class where each field becomes a column in a database table.
- In your IDE, create a new package called entity inside src/main/java/com/example/expensetracker/.
- Create a new file called Expense.java inside the entity package, then copy the following code into it:
package com.example.expensetracker.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
// Maps this class to the "expenses" database table
@Entity
@Table(name = "expenses")
public class Expense {
// Auto-generated primary key
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private BigDecimal amount;
private String category;
private LocalDate date;
// Default no-arg constructor required by JPA
public Expense() {
}
// Constructor for creating new expenses (id is auto-generated)
public Expense(String description, BigDecimal amount, String category, LocalDate date) {
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
// Getters and setters for each field
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
What does this code do?
- @Entity tells JPA this class represents a database table. @Table(name = "expenses") sets the table name explicitly.
- @Id marks id as the primary key. @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment it for each new row.
- The five fields (id, description, amount, category, date) each become a column in the EXPENSES table.
- JPA requires a no-argument constructor to create entity instances. The second constructor is a convenience for your own code.
- Save Expense.java.
You won't see a change yet. The next chunk connects this entity to the database.
Seeing red squiggly lines in your IDE?
If your IDE shows import errors on the jakarta.persistence imports, make sure your pom.xml includes the spring-boot-starter-data-jpa dependency. Your IDE may also need a moment to re-index. Try refreshing your Maven project (in IntelliJ, click the Maven icon in the sidebar and hit the refresh button).
Help me fix import errors in my Expense.java entity file
✔️ Awesome, I've got everything!
Great. Double check you've saved Expense.java before moving on.
ⓧ I'd like to double check the full code
Here is the complete Expense.java file. Make sure yours matches exactly.
package com.example.expensetracker.entity;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "expenses")
public class Expense {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private BigDecimal amount;
private String category;
private LocalDate date;
public Expense() {
}
public Expense(String description, BigDecimal amount, String category, LocalDate date) {
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
Create the repository and configure H2
The entity defines your data shape. Now you need a repository to actually save and retrieve that data from the database.
Spring Data JPA generates all the standard CRUD queries for you from a simple interface. You just define the interface, and Spring handles the implementation at runtime.
- Create a new package called repository inside src/main/java/com/example/expensetracker/.
- Create a new file called ExpenseRepository.java inside the repository package, then copy the following code into it:
package com.example.expensetracker.repository;
import com.example.expensetracker.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Spring Data JPA generates all CRUD operations automatically
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
// Custom query: finds all expenses matching a given category
List<Expense> findByCategory(String category);
}
What does this code do?
- By extending JpaRepository<Expense, Long>, your interface automatically gets methods like save(), findAll(), findById(), and deleteById(). You don't write any of those yourself.
- findByCategory(String category) is a custom query method. Spring Data JPA reads the method name and generates the SQL query SELECT * FROM expenses WHERE category = ? automatically.
- Save ExpenseRepository.java.
You won't see a visible change yet. The repository comes alive once the database is configured in the next chunk.
✔️ Awesome, I've got everything!
Great. Make sure you've saved ExpenseRepository.java before continuing.
ⓧ I'd like to double check the full code
Here is the complete ExpenseRepository.java file. Make sure yours matches exactly.
package com.example.expensetracker.repository;
import com.example.expensetracker.entity.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
List<Expense> findByCategory(String category);
}
Now you need to tell Spring Boot how to connect to the database. The H2 in-memory database is already included as a dependency in your pom.xml, but it needs configuration to know the connection URL and behavior.
- Open application.properties in src/main/resources/.
- Replace the entire contents of the file with the following configuration:
spring.application.name=expense-tracker
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:expensedb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA / Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
# H2 Console
spring.h2.console.enabled=true
# Server port (Elastic Beanstalk expects 5000)
server.port=5000
What do these properties do?
- spring.datasource.url=jdbc:h2:mem:expensedb creates an in-memory database called expensedb. The data lives in memory and resets every time you restart the app.
- ddl-auto=create-drop tells Hibernate to create all tables on startup and drop them on shutdown. Perfect for development.
- show-sql=true prints every SQL query Hibernate runs to the terminal. This helps you see exactly what's happening under the hood.
- h2.console.enabled=true turns on a web-based database viewer at /h2-console. You'll use this in the next substep to verify your table.
- server.port=5000 sets the port your app runs on. You'll deploy to AWS Elastic Beanstalk later, which expects port 5000 by default.
- Save application.properties.
You won't see a change yet. You need to restart the app for these properties to take effect.
Port conflict on 5000?
If another application is already using port 5000 on your machine, the app will fail to start with a Port already in use error. On macOS, AirPlay Receiver sometimes uses port 5000. Disable it in System Settings > General > AirDrop & Handoff and turn off AirPlay Receiver.
Help me fix a port 5000 conflict on my machine
✔️ Awesome, I've got everything!
Great. Make sure you've saved application.properties before continuing.
ⓧ I'd like to double check the full code
Here is the complete application.properties file. Make sure yours matches exactly.
spring.application.name=expense-tracker
# H2 Database Configuration
spring.datasource.url=jdbc:h2:mem:expensedb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
# JPA / Hibernate
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
# H2 Console
spring.h2.console.enabled=true
# Server port (Elastic Beanstalk expects 5000)
server.port=5000
Restart the app and verify the database
With the entity, repository, and database config in place, it's time to restart your app and confirm that Hibernate created the EXPENSES table for you.
- Go to the terminal where your app is running.
- Press Ctrl+C to stop the current process.
- Restart the application by running:
./mvnw spring-boot:run
Notice the app now starts on port 5000 instead of 8080. You should also see SQL statements in the terminal output. That's Hibernate creating the EXPENSES table.
- Open your browser and go to http://localhost:5000/h2-console.
You should see the H2 Console login page.
- In the JDBC URL field, enter jdbc:h2:mem:expensedb.
- Leave the User Name as sa.
- Leave the Password field empty.
- Click Connect.
You should see the EXPENSES table listed in the left sidebar. This confirms that Hibernate read your Expense entity and created a matching table in the database.
- Verify the table structure by typing this query into the SQL input box and clicking Run:
SELECT * FROM EXPENSES;
You should see an empty result set with columns: ID, DESCRIPTION, AMOUNT, CATEGORY, and DATE. The table is empty because you haven't added any expenses yet.
Don't see the EXPENSES table?
- Make sure the JDBC URL is jdbc:h2:mem:expensedb (not jdbc:h2:mem:testdb, which is the H2 default).
- Check that your Expense.java has the @Entity annotation. Without it, Hibernate won't create the table.
- If you changed application.properties while the app was running, you need to stop it with Ctrl+C and restart with ./mvnw spring-boot:run.
Help me troubleshoot my H2 console connection
Your data layer is working. Time to commit your progress.
- Open a new terminal tab (keep the app running in the original tab).
- Commit your changes by running:
git add . && git commit -m "Add Expense entity and H2 database config"
Why commit frequently?
Each commit captures a working state of your project. If something breaks in a future step, you can always roll back to this point. Small, meaningful commits are a production best practice.
Your data layer is complete. You have an entity that defines your expense structure and a repository that handles database operations. Next up, you'll build the service and DTO layer that sits between your controller and database.
Build the Service and DTO Layer
Your data layer is solid. The Expense entity maps to the database, and the ExpenseRepository handles persistence. Now it's time to build the layers that sit between your API endpoints and the database.
Exposing your database entity directly to API consumers is risky. If you add an internal field to Expense later, it leaks into every response. A DTO (Data Transfer Object) solves this by defining a separate shape for API requests and responses.
In this step, get ready to:
- Create an ExpenseDto with validation rules for incoming API requests.
- Add global exception handling for clean, consistent error responses.
- Build the service layer that contains your business logic.
Create the ExpenseDto class
A DTO controls exactly which fields your API accepts on input and returns on output. Your ExpenseDto mirrors most of the entity's fields but adds Bean Validation annotations so Spring automatically rejects bad data before it reaches your service layer.
- Create a new folder called dto inside src/main/java/com/example/expensetracker/.
- Create a new file called ExpenseDto.java inside the dto folder, then paste the following code:
package com.example.expensetracker.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
public class ExpenseDto {
private Long id;
@NotBlank(message = "Description is required")
private String description;
@NotNull(message = "Amount is required")
@Positive(message = "Amount must be positive")
private BigDecimal amount;
@NotBlank(message = "Category is required")
private String category;
@NotNull(message = "Date is required")
private LocalDate date;
public ExpenseDto() {
}
public ExpenseDto(Long id, String description, BigDecimal amount, String category, LocalDate date) {
this.id = id;
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
What does this code do?
- The id field has no validation annotation. It is null on incoming create requests and populated by the database on responses.
- @NotBlank rejects both null and empty strings. @NotNull only rejects null, which is the right choice for non-string types like BigDecimal and LocalDate.
- @Positive ensures amount is greater than zero. Nobody should be logging a negative expense.
- Each annotation's message attribute defines the error text your API returns when validation fails.
- Save ExpenseDto.java.
- Check your IDE's file explorer. You should see ExpenseDto.java inside the dto package under com.example.expensetracker.
Seeing red underlines on the annotations?
- Make sure the imports use the jakarta.validation.constraints package, not javax.validation. Spring Boot 3.x uses the Jakarta namespace.
- Confirm that spring-boot-starter-validation is listed in your pom.xml dependencies. This starter provides the Bean Validation annotations.
- help me fix red underlines on my Bean Validation annotations in ExpenseDto.java
Why use a DTO instead of the entity directly?
Your Expense entity is tied to the database schema. If you expose it directly in your API, any schema change (like adding an internal audit column) automatically leaks into every API response.
A DTO gives you a separate contract for the API. You control exactly what goes in and what comes out, independent of how the database is structured.
✔️ Awesome, I've got everything!
Double check you've saved ExpenseDto.java before moving on.
ⓧ I'd like to double check the full code
package com.example.expensetracker.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
import java.time.LocalDate;
public class ExpenseDto {
private Long id;
@NotBlank(message = "Description is required")
private String description;
@NotNull(message = "Amount is required")
@Positive(message = "Amount must be positive")
private BigDecimal amount;
@NotBlank(message = "Category is required")
private String category;
@NotNull(message = "Date is required")
private LocalDate date;
public ExpenseDto() {
}
public ExpenseDto(Long id, String description, BigDecimal amount, String category, LocalDate date) {
this.id = id;
this.description = description;
this.amount = amount;
this.category = category;
this.date = date;
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public LocalDate getDate() {
return date;
}
public void setDate(LocalDate date) {
this.date = date;
}
}
Create the exception handler classes
When something goes wrong (a missing expense, invalid input), your API should return a structured error response instead of a raw stack trace. Spring's @RestControllerAdvice lets you define a single class that catches exceptions from any controller and formats the response.
You will create two files: a custom exception class for missing resources, and a global handler that maps exceptions to HTTP status codes.
- Create a new folder called exception inside src/main/java/com/example/expensetracker/.
- Create a new file called ResourceNotFoundException.java inside the exception folder, then paste the following code:
package com.example.expensetracker.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
- Save ResourceNotFoundException.java.
- Now create the main handler. Create a new file called GlobalExceptionHandler.java in the same exception folder, then paste the following code:
package com.example.expensetracker.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;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException 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 new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> fieldErrors.put(error.getField(), error.getDefaultMessage()));
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Validation Failed");
body.put("errors", fieldErrors);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 500);
body.put("error", "Internal Server Error");
body.put("message", "An unexpected error occurred");
return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
What does this code do?
- @RestControllerAdvice tells Spring to intercept exceptions thrown from any controller before they reach the client.
- handleResourceNotFound catches your custom ResourceNotFoundException and returns a 404 response with a descriptive message.
- handleValidation catches MethodArgumentNotValidException (triggered when Bean Validation fails) and returns a 400 response with a map of which fields failed and why.
- handleGeneral is the safety net. Any unexpected exception becomes a clean 500 response instead of a raw stack trace.
- Save GlobalExceptionHandler.java.
- Check your IDE's file explorer. You should see both ResourceNotFoundException.java and GlobalExceptionHandler.java inside the exception package.
Seeing import errors in GlobalExceptionHandler?
- Make sure ResourceNotFoundException.java is saved in the same exception package. The handler imports it by package path.
- Check that MethodArgumentNotValidException is imported from org.springframework.web.bind, not from another package with a similar name.
- help me fix import errors in my GlobalExceptionHandler
✔️ Awesome, I've got everything!
Double check you've saved both exception files before moving on.
ⓧ I'd like to double check the full code
package com.example.expensetracker.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
package com.example.expensetracker.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;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleResourceNotFound(ResourceNotFoundException 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 new ResponseEntity<>(body, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> fieldErrors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> fieldErrors.put(error.getField(), error.getDefaultMessage()));
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("error", "Validation Failed");
body.put("errors", fieldErrors);
return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleGeneral(Exception ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 500);
body.put("error", "Internal Server Error");
body.put("message", "An unexpected error occurred");
return new ResponseEntity<>(body, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
Create the ExpenseService class
The service layer is where your business logic lives. Controllers receive HTTP requests, but they delegate the actual work to the service. This separation keeps your controller thin and your logic testable.
- Create a new folder called service inside src/main/java/com/example/expensetracker/.
- Create a new file called ExpenseService.java inside the service folder, then paste the following code:
package com.example.expensetracker.service;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.entity.Expense;
import com.example.expensetracker.exception.ResourceNotFoundException;
import com.example.expensetracker.repository.ExpenseRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ExpenseService {
private final ExpenseRepository expenseRepository;
public ExpenseService(ExpenseRepository expenseRepository) {
this.expenseRepository = expenseRepository;
}
public ExpenseDto createExpense(ExpenseDto dto) {
Expense expense = toEntity(dto);
Expense saved = expenseRepository.save(expense);
return toDto(saved);
}
public List<ExpenseDto> getAllExpenses() {
return expenseRepository.findAll()
.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public ExpenseDto getExpenseById(Long id) {
Expense expense = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
return toDto(expense);
}
public ExpenseDto updateExpense(Long id, ExpenseDto dto) {
Expense existing = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
existing.setDescription(dto.getDescription());
existing.setAmount(dto.getAmount());
existing.setCategory(dto.getCategory());
existing.setDate(dto.getDate());
Expense updated = expenseRepository.save(existing);
return toDto(updated);
}
public void deleteExpense(Long id) {
if (!expenseRepository.existsById(id)) {
throw new ResourceNotFoundException("Expense not found with id: " + id);
}
expenseRepository.deleteById(id);
}
public List<ExpenseDto> getExpensesByCategory(String category) {
return expenseRepository.findByCategory(category)
.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
private ExpenseDto toDto(Expense expense) {
return new ExpenseDto(
expense.getId(),
expense.getDescription(),
expense.getAmount(),
expense.getCategory(),
expense.getDate()
);
}
private Expense toEntity(ExpenseDto dto) {
return new Expense(
dto.getDescription(),
dto.getAmount(),
dto.getCategory(),
dto.getDate()
);
}
}
What does this code do?
- The constructor takes an ExpenseRepository parameter. Spring automatically injects it at startup. This is called constructor injection.
- createExpense converts the DTO to an entity, saves it through the repository, then converts the saved entity back to a DTO for the response.
- getExpenseById and updateExpense throw a ResourceNotFoundException when the ID does not exist. Your GlobalExceptionHandler catches this and returns a 404.
- toDto and toEntity are private helpers that convert between the DTO and entity. This keeps the conversion logic in one place.
- Save ExpenseService.java.
- Verify the app compiles by running this in your terminal:
./mvnw compile
You should see BUILD SUCCESS in the terminal output. This confirms all four new files compile without errors.
Seeing compilation errors?
- Check that each file's package declaration matches its folder path. For example, ExpenseService.java must be in the service folder and start with package com.example.expensetracker.service;.
- Make sure all four new files are saved: ExpenseDto.java, ResourceNotFoundException.java, GlobalExceptionHandler.java, and ExpenseService.java.
- Read the error message carefully. It usually names the exact file, line number, and missing import.
- help me fix my Spring Boot compilation errors
✔️ Awesome, I've got everything!
Double check you've saved ExpenseService.java before moving on.
ⓧ I'd like to double check the full code
package com.example.expensetracker.service;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.entity.Expense;
import com.example.expensetracker.exception.ResourceNotFoundException;
import com.example.expensetracker.repository.ExpenseRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ExpenseService {
private final ExpenseRepository expenseRepository;
public ExpenseService(ExpenseRepository expenseRepository) {
this.expenseRepository = expenseRepository;
}
public ExpenseDto createExpense(ExpenseDto dto) {
Expense expense = toEntity(dto);
Expense saved = expenseRepository.save(expense);
return toDto(saved);
}
public List<ExpenseDto> getAllExpenses() {
return expenseRepository.findAll()
.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
public ExpenseDto getExpenseById(Long id) {
Expense expense = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
return toDto(expense);
}
public ExpenseDto updateExpense(Long id, ExpenseDto dto) {
Expense existing = expenseRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Expense not found with id: " + id));
existing.setDescription(dto.getDescription());
existing.setAmount(dto.getAmount());
existing.setCategory(dto.getCategory());
existing.setDate(dto.getDate());
Expense updated = expenseRepository.save(existing);
return toDto(updated);
}
public void deleteExpense(Long id) {
if (!expenseRepository.existsById(id)) {
throw new ResourceNotFoundException("Expense not found with id: " + id);
}
expenseRepository.deleteById(id);
}
public List<ExpenseDto> getExpensesByCategory(String category) {
return expenseRepository.findByCategory(category)
.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
private ExpenseDto toDto(Expense expense) {
return new ExpenseDto(
expense.getId(),
expense.getDescription(),
expense.getAmount(),
expense.getCategory(),
expense.getDate()
);
}
private Expense toEntity(ExpenseDto dto) {
return new Expense(
dto.getDescription(),
dto.getAmount(),
dto.getCategory(),
dto.getDate()
);
}
}
- Commit your changes by running:
git add . && git commit -m "Add service layer, DTOs, and exception handling"
You should see a list of new files in the commit output, including files in the dto, exception, and service packages.
Your service and DTO layers are locked in. Next up, you'll build the REST controller that wires these layers to actual HTTP endpoints and test them with curl.
Build the REST Controller and Test Manually
You have a complete backend with an entity, repository, service layer, DTOs, and exception handling. Everything is wired up internally, but there is no way for the outside world to talk to your application yet.
The REST controller is the front door of your API. It receives HTTP requests, delegates work to the service layer, and sends back responses with the right status codes. In this step, you'll create that front door and test every endpoint by hand.
In this step, get ready to:
- Create the ExpenseController with six REST endpoints.
- Test each endpoint using curl to verify your entire stack works end to end.
- Commit your working controller to Git.
Create the ExpenseController class
The controller maps HTTP methods (GET, POST, PUT, DELETE) to your service methods. Each endpoint returns a ResponseEntity with the correct status code so clients know exactly what happened.
- Create a new file at src/main/java/com/example/expensetracker/controller/ExpenseController.java.
- Copy the following code into the file:
package com.example.expensetracker.controller;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.service.ExpenseService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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;
// Marks this class as a REST controller that returns JSON responses
@RestController
// All endpoints in this controller are prefixed with /api/expenses
@RequestMapping("/api/expenses")
public class ExpenseController {
private final ExpenseService expenseService;
// Constructor injection for the service layer
public ExpenseController(ExpenseService expenseService) {
this.expenseService = expenseService;
}
// POST /api/expenses - create a new expense, returns 201 Created
@PostMapping
public ResponseEntity<ExpenseDto> createExpense(@Valid @RequestBody ExpenseDto dto) {
ExpenseDto created = expenseService.createExpense(dto);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
// GET /api/expenses - retrieve all expenses
@GetMapping
public ResponseEntity<List<ExpenseDto>> getAllExpenses() {
return ResponseEntity.ok(expenseService.getAllExpenses());
}
// GET /api/expenses/{id} - retrieve a single expense by its ID
@GetMapping("/{id}")
public ResponseEntity<ExpenseDto> getExpenseById(@PathVariable Long id) {
return ResponseEntity.ok(expenseService.getExpenseById(id));
}
// PUT /api/expenses/{id} - update an existing expense
@PutMapping("/{id}")
public ResponseEntity<ExpenseDto> updateExpense(@PathVariable Long id, @Valid @RequestBody ExpenseDto dto) {
return ResponseEntity.ok(expenseService.updateExpense(id, dto));
}
// DELETE /api/expenses/{id} - remove an expense, returns 204 No Content
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteExpense(@PathVariable Long id) {
expenseService.deleteExpense(id);
return ResponseEntity.noContent().build();
}
// GET /api/expenses/category/{category} - filter expenses by category name
@GetMapping("/category/{category}")
public ResponseEntity<List<ExpenseDto>> getExpensesByCategory(@PathVariable String category) {
return ResponseEntity.ok(expenseService.getExpensesByCategory(category));
}
}
What does this code do?
@RestController tells Spring this class handles HTTP requests and automatically converts return values to JSON.
@RequestMapping("/api/expenses") sets the base URL path for every endpoint in this controller. Each method-level annotation like @GetMapping("/{id}") appends to that base.
@Valid on the @RequestBody parameters triggers the Bean Validation annotations you added to ExpenseDto. If validation fails, your GlobalExceptionHandler catches the error and returns a 400 response with field-level messages.
- Save ExpenseController.java.
You won't see a change yet. The next substep restarts the app and tests every endpoint.
Seeing a compile error?
- Make sure the controller folder exists inside src/main/java/com/example/expensetracker/. Create it if your IDE did not generate it automatically.
- Verify your imports use jakarta.validation.Valid (Jakarta namespace), not javax.validation.Valid. Spring Boot 3.5.14 uses the Jakarta namespace.
- If you see Cannot resolve symbol 'ExpenseService', check that your ExpenseService.java file is saved and compiles without errors.
Still stuck? Ask for help.
✔️ Awesome, I've got everything!
Make sure you have saved ExpenseController.java before moving on.
ⓧ I'd like to double check the full code
package com.example.expensetracker.controller;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.service.ExpenseService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
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.PutMapping;
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/expenses")
public class ExpenseController {
private final ExpenseService expenseService;
public ExpenseController(ExpenseService expenseService) {
this.expenseService = expenseService;
}
@PostMapping
public ResponseEntity<ExpenseDto> createExpense(@Valid @RequestBody ExpenseDto dto) {
ExpenseDto created = expenseService.createExpense(dto);
return new ResponseEntity<>(created, HttpStatus.CREATED);
}
@GetMapping
public ResponseEntity<List<ExpenseDto>> getAllExpenses() {
return ResponseEntity.ok(expenseService.getAllExpenses());
}
@GetMapping("/{id}")
public ResponseEntity<ExpenseDto> getExpenseById(@PathVariable Long id) {
return ResponseEntity.ok(expenseService.getExpenseById(id));
}
@PutMapping("/{id}")
public ResponseEntity<ExpenseDto> updateExpense(@PathVariable Long id, @Valid @RequestBody ExpenseDto dto) {
return ResponseEntity.ok(expenseService.updateExpense(id, dto));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteExpense(@PathVariable Long id) {
expenseService.deleteExpense(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/category/{category}")
public ResponseEntity<List<ExpenseDto>> getExpensesByCategory(@PathVariable String category) {
return ResponseEntity.ok(expenseService.getExpensesByCategory(category));
}
}
Restart the app and test every endpoint with curl
Your controller is saved. Time to restart the application and hit each endpoint from the terminal to confirm the full stack works.
- If your app is still running from a previous step, stop it by pressing Ctrl+C in the terminal.
- Restart the application by running:
./mvnw spring-boot:run
Wait until you see Started ExpenseTrackerApplication in the terminal output. Open a second terminal window for the curl commands below.
Keep two terminals open
One terminal runs your Spring Boot app. The other runs your curl commands. If you run curl in the same terminal as the app, you will need to stop the app first.
- Create your first expense by running this command in your second terminal:
curl -X POST http://localhost:5000/api/expenses -H "Content-Type: application/json" -d '{"description":"Coffee","amount":4.50,"category":"Food","date":"2026-05-03"}'
You should see a JSON response with an id of 1, along with the description, amount, category, and date you just sent.
- Retrieve all expenses by running:
curl http://localhost:5000/api/expenses
You should see a JSON array containing the Coffee expense you just created.
- Retrieve a single expense by its ID by running:
curl http://localhost:5000/api/expenses/1
You should see the same Coffee expense as a single JSON object.
- Update the expense to change the amount by running:
curl -X PUT http://localhost:5000/api/expenses/1 -H "Content-Type: application/json" -d '{"description":"Fancy Coffee","amount":6.00,"category":"Food","date":"2026-05-03"}'
The response shows the updated expense with Fancy Coffee and an amount of 6.00.
- Filter expenses by category by running:
curl http://localhost:5000/api/expenses/category/Food
You should see a JSON array with your Fancy Coffee expense since its category is Food.
- Delete the expense by running:
curl -X DELETE http://localhost:5000/api/expenses/1
You should see no response body. A 204 No Content status means the deletion succeeded.
- Finally, test that your validation catches bad input by sending an expense with an empty description:
curl -X POST http://localhost:5000/api/expenses -H "Content-Type: application/json" -d '{"description":"","amount":4.50,"category":"Food","date":"2026-05-03"}'
You should see a 400 response with an errors object containing "description": "Description is required". Your validation and exception handler are working together.
Getting Connection refused?
- Make sure your app is still running in the first terminal. Look for Started ExpenseTrackerApplication in the output.
- Your app runs on port 5000 (set in application.properties). Double check your curl commands use localhost:5000, not localhost:8080.
- On macOS, you may get a firewall prompt asking to allow incoming connections. Click Allow.
Still having trouble with your endpoints?
Commit your working controller
- Stop the running application with Ctrl+C in the terminal where it is running.
- Stage and commit your changes by running:
git add . && git commit -m "Add REST controller with CRUD endpoints"
You should see a commit message confirming the new file was added.
Commit failed?
- If you see nothing to commit, working tree clean, your controller file may not have been saved. Save it and run the commit command again.
- Make sure you are running the command from inside your expense-tracker project folder, where the .git directory lives.
Need help with the Git commit?
Your API is fully functional and tested locally. Next up, you'll write automated tests so your endpoints stay reliable as the codebase grows.
Write Automated Tests
Your API endpoints are built and manually verified with curl. Every route returns the right status code and data.
Manual testing proves things work right now, but it can't protect you from breaking things later. Automated tests run in seconds and catch regressions every time you change the code. In this step, you'll write controller tests using MockMvc.
In this step, get ready to:
- Create an ExpenseControllerTest class with MockMvc and a mocked service layer.
- Write three test methods covering create, list, and error handling endpoints.
- Run the full test suite and commit your passing tests.
Create the test class and write the POST test
Spring Boot's @WebMvcTest annotation loads only the web layer of your application. It spins up your controller, exception handler, and validation logic without starting the full app or the database.
Since the controller depends on ExpenseService, you need a stand-in for it during testing. The @MockitoBean annotation creates a mock version of the service that returns whatever you tell it to.
- Create a new file at src/test/java/com/example/expensetracker/controller/ExpenseControllerTest.java and paste the following code:
package com.example.expensetracker.controller;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.exception.ResourceNotFoundException;
import com.example.expensetracker.service.ExpenseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
// Only loads the web layer (controller + exception handler), not the full app
@WebMvcTest(ExpenseController.class)
class ExpenseControllerTest {
// Simulates HTTP requests without starting a real server
@Autowired
private MockMvc mockMvc;
// Replaces the real ExpenseService with a controllable mock
@MockitoBean
private ExpenseService expenseService;
private ObjectMapper objectMapper;
// Runs before each test to set up the JSON mapper with LocalDate support
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
}
@Test
void shouldCreateExpense() throws Exception {
// Input has no id (simulates what the client sends)
ExpenseDto input = new ExpenseDto(null, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
// Output has an id (simulates what the database returns)
ExpenseDto output = new ExpenseDto(1L, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
// Tell the mock to return output whenever createExpense is called
when(expenseService.createExpense(any(ExpenseDto.class))).thenReturn(output);
// Perform a POST request and verify the response
mockMvc.perform(post("/api/expenses")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.description").value("Coffee"))
.andExpect(jsonPath("$.amount").value(4.50));
}
}
What does this test do?
@WebMvcTest(ExpenseController.class) tells Spring to load only the ExpenseController (plus your exception handler and validation). No database, no service layer, no full application startup.
@MockitoBean swaps the real ExpenseService with a mock. The when(...).thenReturn(...) line programs that mock to return a specific ExpenseDto when createExpense is called.
The ObjectMapper converts your ExpenseDto into JSON for the request body. The JavaTimeModule teaches Jackson how to serialize LocalDate fields.
Why @MockitoBean instead of @MockBean?
Starting in Spring Boot 3.4, the old @MockBean annotation is deprecated. The replacement is @MockitoBean from the org.springframework.test.context.bean.override.mockito package. Since this project uses Spring Boot 3.5.14, you use the new annotation.
- Save ExpenseControllerTest.java.
You won't run the test just yet. The next substep adds two more tests, then you'll run the full suite together.
Add the GET and error handling tests
Your first test covers the happy path for creating an expense. You also need to verify that listing expenses works and that requesting a non-existent expense returns a 404.
- In ExpenseControllerTest.java, find the closing brace } of the shouldCreateExpense method, then paste the following two test methods below it (but still inside the class):
@Test
void shouldGetAllExpenses() throws Exception {
// Set up a single expense and tell the mock to return it as a list
ExpenseDto expense = new ExpenseDto(1L, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
when(expenseService.getAllExpenses()).thenReturn(List.of(expense));
// Perform a GET request and verify the first item in the JSON array
mockMvc.perform(get("/api/expenses"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].description").value("Coffee"));
}
@Test
void shouldReturn404WhenExpenseNotFound() throws Exception {
// Tell the mock to throw ResourceNotFoundException for id 99
when(expenseService.getExpenseById(99L))
.thenThrow(new ResourceNotFoundException("Expense not found with id: 99"));
// Perform a GET request for a non-existent id and verify the 404 response
mockMvc.perform(get("/api/expenses/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Expense not found with id: 99"));
}
What do these tests verify?
- shouldGetAllExpenses sends a GET request to /api/expenses and checks that the response is a 200 with the correct expense data in a JSON array.
- shouldReturn404WhenExpenseNotFound programs the mock to throw a ResourceNotFoundException when id 99 is requested. This tests that your GlobalExceptionHandler catches the exception and returns a 404 with the right error message.
- Save ExpenseControllerTest.java.
Before running the tests, double check your full test file matches the expected code.
✔️ Awesome, I've got everything!
Great. Make sure you have saved ExpenseControllerTest.java before running the tests.
ⓧ I'd like to double check the full code
package com.example.expensetracker.controller;
import com.example.expensetracker.dto.ExpenseDto;
import com.example.expensetracker.exception.ResourceNotFoundException;
import com.example.expensetracker.service.ExpenseService;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(ExpenseController.class)
class ExpenseControllerTest {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private ExpenseService expenseService;
private ObjectMapper objectMapper;
@BeforeEach
void setUp() {
objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
}
@Test
void shouldCreateExpense() throws Exception {
ExpenseDto input = new ExpenseDto(null, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
ExpenseDto output = new ExpenseDto(1L, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
when(expenseService.createExpense(any(ExpenseDto.class))).thenReturn(output);
mockMvc.perform(post("/api/expenses")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(input)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.id").value(1))
.andExpect(jsonPath("$.description").value("Coffee"))
.andExpect(jsonPath("$.amount").value(4.50));
}
@Test
void shouldGetAllExpenses() throws Exception {
ExpenseDto expense = new ExpenseDto(1L, "Coffee", new BigDecimal("4.50"), "Food", LocalDate.of(2026, 5, 3));
when(expenseService.getAllExpenses()).thenReturn(List.of(expense));
mockMvc.perform(get("/api/expenses"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].description").value("Coffee"));
}
@Test
void shouldReturn404WhenExpenseNotFound() throws Exception {
when(expenseService.getExpenseById(99L))
.thenThrow(new ResourceNotFoundException("Expense not found with id: 99"));
mockMvc.perform(get("/api/expenses/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.message").value("Expense not found with id: 99"));
}
}
- Run the test suite by running this command in your terminal:
./mvnw test
You should see output ending with something like Tests run: 3, Failures: 0, Errors: 0 followed by BUILD SUCCESS.
Tests failing or not running?
- If you see a compilation error about @MockitoBean not being found, check that your pom.xml uses Spring Boot 3.5.14 as the parent version. Older versions use @MockBean from a different package.
- If you see 404 Not Found on the POST test instead of 201, make sure the test file is in the controller package under src/test/java and the class annotation says @WebMvcTest(ExpenseController.class).
- If you see a Jackson serialization error related to LocalDate, make sure your setUp method registers the JavaTimeModule on the ObjectMapper.
Why are my MockMvc tests failing with unexpected status codes?
- Commit your passing tests by running these commands:
git add . && git commit -m "Add controller unit tests with MockMvc"
Your API now has a safety net. Any future change that breaks your endpoints will show up as a failing test. Next up, you'll take this tested API and deploy it live to AWS.
Deploy to AWS Elastic Beanstalk
Your API is fully built and all three controller tests pass. The application works locally.
Now it's time to put your API on the internet. You'll package everything into a single JAR file and deploy it to AWS Elastic Beanstalk, which provisions a server and gives you a public URL automatically.
In this step, get ready to:
- Build the Spring Boot application into a deployable JAR file.
- Create an Elastic Beanstalk application and deploy your JAR to it.
- Test your live API using its public URL.
Build your JAR and test it locally
Before deploying, your app needs to listen on the right port. Your application.properties already includes server.port=5000 from earlier in the project.
Why port 5000?
Elastic Beanstalk's default nginx proxy forwards incoming traffic to port 5000 on your application. Spring Boot defaults to port 8080. The server.port=5000 setting ensures your app listens on the port Elastic Beanstalk expects.
- Stop any running instance of your app if one is still active by pressing Ctrl+C in your terminal.
- Build the application JAR by running this command:
./mvnw clean package -DskipTests
What does this command do?
- clean removes any previous build output from the target/ folder.
- package compiles your code and bundles it into a single JAR file with an embedded Tomcat server.
- -DskipTests skips running tests during the build, since you already verified them in the previous step.
- Confirm the JAR was created by running:
ls target/expense-tracker-0.0.1-SNAPSHOT.jar
You should see the file path printed in your terminal.
- Test the JAR locally by running:
java -jar target/expense-tracker-0.0.1-SNAPSHOT.jar
- Wait for the Started ExpenseTrackerApplication message in your terminal output.
- Open a new terminal tab and test the API on port 5000 by running:
curl http://localhost:5000/api/expenses
You should see an empty JSON array [] in the response. This confirms the JAR is working correctly on port 5000.
- Go back to the terminal tab running your app and press Ctrl+C to stop it.
Seeing 'connection refused' on port 5000?
- Make sure the app finished starting before you ran the curl command. Look for Started ExpenseTrackerApplication in the terminal output.
- Check that no other process is already using port 5000 on your machine.
- Verify that server.port=5000 appears in your application.properties file.
- Help me debug why my Spring Boot JAR won't start on port 5000.
Create the Elastic Beanstalk application
Your JAR is verified and ready for the cloud. Elastic Beanstalk provisions a server, deploys your code, and gives you a public URL with a single upload.
- Open your browser and sign in to the AWS Management Console at aws.amazon.com/console.
- Type Elastic Beanstalk in the search bar at the top of the console.
- Select Elastic Beanstalk from the search results.
- Click Create application.
- Enter expense-tracker-api in the Application name field.
- Under Platform, select Java as the platform type.
- Select Corretto 17 as the platform branch.
- Under Application code, select Upload your code.
- Click Choose file and navigate to the target/ folder inside your expense-tracker project folder.
- Select the expense-tracker-0.0.1-SNAPSHOT.jar file.
- Under Presets, select Single instance (free tier eligible).
- Click Next and continue through the remaining configuration pages, accepting the defaults on each page.
- On the final review page, click Submit.
What is Elastic Beanstalk doing?
Elastic Beanstalk provisions an EC2 instance, installs Java, deploys your JAR, and sets up a nginx reverse proxy in front of it. All of this happens automatically from your single JAR upload.
The environment takes 3-5 minutes to launch. Wait for the Health status to show Ok with a green checkmark.
Health shows 'Degraded' or 'Severe'?
- Click the Events tab in the Elastic Beanstalk console to see detailed error messages.
- The most common cause is uploading the wrong file. Make sure you uploaded the JAR from target/expense-tracker-0.0.1-SNAPSHOT.jar, not the source code zip.
- Verify that you selected Corretto 17 as the platform branch. A mismatched Java version will prevent the app from starting.
- Help me troubleshoot my Elastic Beanstalk deployment.
Test your live API and commit
Your API is live on the internet. The environment URL is displayed at the top of your Elastic Beanstalk environment dashboard.
- Copy the environment URL from the top of the dashboard. It looks something like http://expense-tracker-api-env.eba-xxxxx.us-east-1.elasticbeanstalk.com.
- Test your live API by running this command:
curl [[EB_URL="your Elastic Beanstalk environment URL"]]/api/expenses
You should see an empty JSON array [] in the response. Your API is live and accepting requests from anywhere on the internet.
- Create an expense on your live API to confirm the full POST flow by running:
curl -X POST [[EB_URL="your Elastic Beanstalk environment URL"]]/api/expenses -H "Content-Type: application/json" -d '{"description":"Coffee","amount":4.50,"category":"Food","date":"2026-05-03"}'
You should see a JSON response containing the created expense with an id field. This confirms your entire API is working in the cloud.
Seeing a timeout or 502 error on your EB URL?
- Make sure you copied the full URL including http:// at the beginning.
- Double-check that the health status shows Ok before testing. If it shows Degraded, the app has not started correctly.
- A 502 error usually means your JAR is not listening on port 5000. Verify that server.port=5000 is in your application.properties, rebuild the JAR, and re-upload it.
- Help me fix my Elastic Beanstalk API connection issue.
- Commit your final changes by running these commands:
git add .
git commit -m "Configure for AWS deployment"
Secret mission
Connect a React Frontend
Your API works great in the terminal, but real users need a visual interface. In this secret mission, you'll build a React dashboard that connects to your Expense Tracker API and displays expenses in a styled, interactive UI. This is the full-stack addition that ties your entire project together.
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 created AWS resources that are covered by free tier credits ($100+ on sign-up). Monitor your AWS Billing Dashboard to track credit usage.
Cost warning
Your Elastic Beanstalk environment runs an EC2 instance continuously. This is covered by your AWS Free Tier credits, but once those credits are exhausted, standard EC2 pricing applies.
If you are not actively using the API, choose Pause or Delete below to avoid unexpected charges.
Resources you used:
- Elastic Beanstalk environment (runs an EC2 instance, t3.micro or similar).
- S3 bucket (Elastic Beanstalk stores your application version here, minimal cost).
- IAM user with programmatic access (created during AWS CLI setup).
- Local project files in the expense-tracker folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or want your API to stay live at its public URL.
- Your API remains accessible at your Elastic Beanstalk environment URL.
- Monitor your credit balance in the AWS Billing Dashboard to avoid surprise charges.
Pause - I'll come back to this later
Terminate the Elastic Beanstalk environment to stop the EC2 instance. Your local project files stay intact so you can re-deploy later.
- Open the AWS Management Console and navigate to Elastic Beanstalk.
- Select your expense-tracker-api environment.
- Click Actions, then select Terminate environment.
- Confirm the termination when prompted.
- Your local expense-tracker folder and Git history are untouched. You can re-deploy by creating a new environment and uploading your JAR again.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is the safest option to avoid any charges.
- Open the AWS Management Console and navigate to Elastic Beanstalk.
- Select the expense-tracker-api application.
- Click Actions, then select Delete application. This removes the EC2 instance, S3 artifacts, and security groups.
- Confirm the deletion when prompted.
- Navigate to IAM in the console. Select Users and delete the IAM user you created for this project.
- On your local machine, delete the expense-tracker folder from your Desktop.
- Optionally, remove your AWS CLI credentials by deleting the ~/.aws folder if you no longer need the AWS CLI.
Mission accomplished
That's a Wrap!
Nice work! You just built and deployed a production-quality REST API from scratch using Spring Boot and AWS Elastic Beanstalk.
You've learned how to:
- Built a Spring Boot REST API with full CRUD operations, layered architecture, DTOs, Bean Validation, and global exception handling for clean, production-grade API design.
- Wrote automated controller tests using JUnit 5 and MockMvc to validate your endpoints before shipping.
- Deployed your API to AWS Elastic Beanstalk and accessed it live via a public URL.
- Secret Mission: Connected a React frontend to create a full-stack expense tracking dashboard.
Ready to quiz yourself?