Build an Expense Splitting API
Build a Splitwise-style REST API with Spring Boot, PostgreSQL, and Java OOP.
Introduction
30 Second Summary
Splitting bills with friends sounds simple until someone paid for dinner, someone else covered the cab, and now nobody remembers who owes what. Apps like Splitwise solve this by tracking shared expenses and calculating exactly who needs to pay whom.
In this project, you will build the backend engine behind an app like Splitwise. You'll create a REST API using Java and Spring Boot that handles groups, shared expenses, and automatic balance calculations.
What You'll Build
A Splitwise-style expense splitting REST API built with Spring Boot and PostgreSQL.
By the end of this project, you'll have:
- A working Spring Boot REST API with endpoints for users, groups, expenses, and balance queries.
- A polymorphic split engine using the Strategy pattern to calculate equal, exact, and percentage splits.
- A debt simplification algorithm that uses Java Streams and Collections to calculate who owes whom.
- Secret Mission: Add a "settle up" endpoint that records payments between users and recalculates balances.
Are there any prerequisites?
You need programming experience in any language. No prior Java or Spring Boot knowledge is required.
Set Up Your Environment
Before you write any Java code, you need the right tools installed on your machine. This project requires a Java runtime and compiler, a database to persist data, and an IDE extension that understands Java projects.
In this step, you will install OpenJDK 21 (the Java Development Kit), PostgreSQL 18 (a relational database), and the VS Code Extension Pack for Java. Then you will generate a Spring Boot project and confirm everything runs.
In this step, get ready to:
- Install OpenJDK 21 and verify it works.
- Install PostgreSQL 18 and create the project database.
- Generate and configure the Spring Boot project.
Install OpenJDK 21 and verify
Java 21 is the current long-term support (LTS) release. Spring Boot 3.5.x requires Java 17 or higher, and JDK 21 gives you access to modern language features like records and pattern matching.
- Open a terminal.
- Check whether Java is already installed by running this command:
java --version
✔️ I see version 21 or higher
You should see output starting with openjdk 21 followed by a build string. Java is ready to go.
- Also confirm the compiler is available by running:
javac --version
You should see a version starting with 21.
ⓧ I see an older version
If the output shows a version lower than 21 (like 17 or 11), you need to install OpenJDK 21 alongside it.
- Install OpenJDK 21 by running:
sudo apt update && sudo apt install openjdk-21-jdk
- Switch the default Java to version 21 by running:
sudo update-alternatives --config java
Select the number corresponding to java-21-openjdk from the list, then press Enter.
- Verify the switch was successful by running:
java --version
javac --version
Both should now show a version starting with 21.
ⓧ Command not found
Java is not installed yet.
- Install OpenJDK 21 by running:
sudo apt update && sudo apt install openjdk-21-jdk
This installs both the Java runtime and the Java compiler.
- Verify the installation by running:
java --version
javac --version
Both commands should show a version starting with 21.
What is OpenJDK?
OpenJDK is the open-source implementation of the Java platform. The JDK (Java Development Kit) includes java (the runtime that executes programs) and javac (the compiler that turns .java files into bytecode).
Install PostgreSQL 18 and configure a database
Your API needs a database to store users, groups, and expenses. PostgreSQL is a production-grade relational database that works seamlessly with Spring Boot's JPA layer.
- Check whether PostgreSQL is already installed by running:
psql --version
✔️ I see version 18 or higher
PostgreSQL 18 is already installed. Skip ahead to the database creation steps below.
ⓧ I see an older version
You have an older PostgreSQL installed. You need version 18 for this project.
- Add the official PostgreSQL APT repository by running:
sudo apt install -y postgresql-common && sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
When prompted, press Enter to confirm adding the repository.
- Install PostgreSQL 18 by running:
sudo apt update && sudo apt install postgresql-18
- Verify the new version by running:
psql --version
You should now see a version starting with 18.
ⓧ Command not found
PostgreSQL is not installed yet.
- Add the official PostgreSQL APT repository by running:
sudo apt install -y postgresql-common && sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
When prompted, press Enter to confirm adding the repository.
- Install PostgreSQL 18 by running:
sudo apt update && sudo apt install postgresql-18
- Verify the installation by running:
psql --version
You should see a version starting with 18.
- Start the PostgreSQL service by running:
sudo systemctl start postgresql
- Confirm it is running by checking the service status:
sudo systemctl status postgresql
You should see active (running) in the output. Press q to exit the status view.
Now you need to create a dedicated database and user for this project.
- Open the PostgreSQL interactive shell by running:
sudo -u postgres psql
- Create the project user by running this SQL command inside the shell:
CREATE USER splitwise WITH PASSWORD 'splitwise123';
- Create the project database owned by that user:
CREATE DATABASE splitwise_db OWNER splitwise;
- Exit the PostgreSQL shell by typing:
\q
What did you just create?
You created a user called splitwise with password splitwise123 and a database called splitwise_db. Your Spring Boot application will use these credentials to connect.
Seeing a permission denied error?
If sudo -u postgres psql fails, make sure the PostgreSQL service is running with sudo systemctl start postgresql. If you see a socket error, try sudo systemctl restart postgresql and wait a few seconds before retrying.
help me fix a PostgreSQL connection error on Ubuntu
Install VS Code Extension Pack for Java and generate the Spring Boot project
The Extension Pack for Java gives VS Code features like IntelliSense, debugging, and project management for Java files. After installing it, you will generate a ready-made Spring Boot project from Spring Initializr.
- In VS Code, click the Extensions icon in the left sidebar (or press Ctrl+Shift+X).
- Search for Extension Pack for Java by Microsoft.
- Click Install.
What does this extension pack include?
It bundles several extensions: Language Support for Java, Debugger for Java, Maven for Java, and more. Together they give VS Code full Java IDE capabilities.
Now generate the Spring Boot project using Spring Initializr.
- Open your browser and navigate to https://start.spring.io.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot version to 3.5.15.
- Under Project Metadata, set Group to com.splitwise.
- Set Artifact to splitwise-api.
- Set Packaging to Jar.
- Set Java to 21.
- Click ADD DEPENDENCIES and add these four: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation.
- Click GENERATE to download the project zip file.
Why these four dependencies?
Spring Web provides the REST controller framework. Spring Data JPA handles database access with Hibernate. PostgreSQL Driver lets Java talk to your PostgreSQL database. Validation adds request body validation annotations.
- Move to your Desktop by running this command:
cd ~/Desktop
- Unzip the downloaded project by running:
unzip ~/Downloads/splitwise-api.zip
- Move into the project folder by running:
cd splitwise-api
- Open the project in VS Code by running:
code .
VS Code may prompt you to import the Java project. Click Yes if prompted.
Don't see the unzip command?
If unzip is not found, install it with sudo apt install unzip and try again.
help me unzip a file on Ubuntu
Now configure the database connection so Spring Boot knows how to reach PostgreSQL.
- In VS Code's Explorer sidebar, expand src > main > resources.
- Open the file application.properties.
- Replace its contents with the following configuration:
spring.datasource.url=jdbc:postgresql://localhost:5432/splitwise_db
spring.datasource.username=splitwise
spring.datasource.password=splitwise123
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
What does this configuration do?
The spring.datasource properties tell Spring Boot where your database lives and how to authenticate. The ddl-auto=update setting makes Hibernate automatically create or update database tables based on your Java entities.
The show-sql=true setting prints every SQL query to the console so you can see what Hibernate does behind the scenes.
- Save application.properties.
- Run the application by executing this in your terminal (make sure you are in the splitwise-api folder):
./mvnw spring-boot:run
The first run downloads dependencies, so it may take a minute or two. When you see log output mentioning that the application has started, the server is ready.
- Open your browser and navigate to http://localhost:8080.
You will see a Whitelabel Error Page. This is expected because you have not created any endpoints yet. It confirms that your Spring Boot server is running and responding to requests.
Application fails to start?
If you see a connection refused error mentioning port 5432, PostgreSQL is not running. Run sudo systemctl start postgresql and try again.
If you see an authentication failed error, double-check that the username (splitwise) and password (splitwise123) in application.properties match what you created in the PostgreSQL shell.
help me fix my Spring Boot application startup error
✔️ Awesome, I've got everything!
Great. Double-check you have saved application.properties and that your server is still running in the terminal.
ⓧ I'd like to double check the full code
Here is the complete application.properties file:
spring.datasource.url=jdbc:postgresql://localhost:5432/splitwise_db
spring.datasource.username=splitwise
spring.datasource.password=splitwise123
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
Your environment is fully set up. Next up, you will define the domain model for users, groups, and expenses using JPA entities and OOP principles.
Define the Domain Model
Your Spring Boot project compiles and starts, but it has no idea what a "user" or an "expense" actually looks like. Before you can build any API endpoints, you need to define the data structures that represent your domain.
In this step, you will create JPA entity classes that map directly to PostgreSQL tables. You will also model the different ways an expense can be split using inheritance and enums.
In this step, get ready to:
- Create User and Group entities with a many-to-many relationship.
- Create the Expense entity and a Split class hierarchy using inheritance.
- Create repository interfaces for database access using generics.
Create User and Group entities with a many-to-many relationship
Every expense-splitting app needs users and groups. A user can belong to many groups, and a group can have many members. This is a classic many-to-many relationship.
- In VS Code's Explorer sidebar, right-click on src/main/java/com/splitwise and select New Folder.
- Name the folder model.
- Right-click on the new model folder and select New File.
- Name the file User.java and paste the following code:
package com.splitwise.model;
import jakarta.persistence.*;
// JPA entity representing a user in the expense-splitting system
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
What does this code do?
@Entity tells Hibernate this class maps to a database table. @Table(name = "app_user") sets the table name explicitly because "user" is a reserved keyword in PostgreSQL.
@Id marks the primary key field. @GeneratedValue(strategy = GenerationType.IDENTITY) tells PostgreSQL to auto-generate the ID using its SERIAL column type.
- Now create a second file in the model folder named ExpenseGroup.java and paste this code:
package com.splitwise.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
// JPA entity representing a group of users who share expenses
@Entity
@Table(name = "expense_group")
public class ExpenseGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private LocalDate createdDate;
// Many-to-many: a group has many members, a user can be in many groups
@ManyToMany
@JoinTable(
name = "group_members",
joinColumns = @JoinColumn(name = "group_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private Set<User> members = new HashSet<>();
// Getters and setters
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getCreatedDate() { return createdDate; }
public void setCreatedDate(LocalDate createdDate) { this.createdDate = createdDate; }
public Set<User> getMembers() { return members; }
public void setMembers(Set<User> members) { this.members = members; }
}
What is a many-to-many relationship?
A many-to-many relationship means multiple records on each side can connect to multiple records on the other side. Here, one group can have many users, and one user can belong to many groups.
@JoinTable creates a separate group_members table in PostgreSQL to hold these connections. The joinColumns point to the owning side (group_id) and inverseJoinColumns point to the other side (user_id).
Using a Set instead of a List prevents duplicate members in the same group.
- Save both files.
- Open the Problems panel with Ctrl+Shift+M. You should see no errors related to User.java or ExpenseGroup.java.
Seeing import errors?
If VS Code shows red underlines on jakarta.persistence imports, the Java extension may still be loading the project. Wait a few seconds for the language server to finish indexing.
If errors persist after 30 seconds, open the Command Palette with Ctrl+Shift+P and run Java: Clean Java Language Server Workspace. help me fix jakarta.persistence import errors in VS Code
Create the Expense entity and the Split class hierarchy using inheritance
Expenses can be split in different ways: equally, by exact amounts, or by percentages. You will model this using an abstract class with concrete subclasses, which is a textbook example of polymorphism.
- Create a new file in the model folder named SplitType.java and paste this code:
package com.splitwise.model;
// Enum defining the three ways an expense can be split
public enum SplitType {
EQUAL,
EXACT,
PERCENTAGE
}
- Create another file named Split.java in the same folder and paste this code:
package com.splitwise.model;
import java.math.BigDecimal;
// Abstract base class for all split types
public abstract class Split {
protected Long userId;
protected BigDecimal amount;
// Each subclass defines how to retrieve the share
public abstract BigDecimal getShare();
// Getters and setters
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
What is an abstract class?
An abstract class defines a template that subclasses must follow. You cannot create an instance of Split directly. Instead, you create instances of its subclasses like EqualSplit or ExactSplit.
The abstract keyword on getShare() forces every subclass to provide its own implementation. This is polymorphism: same method name, different behavior depending on the type.
- Create EqualSplit.java in the model folder:
package com.splitwise.model;
import java.math.BigDecimal;
// Equal split: each user pays the same share
public class EqualSplit extends Split {
@Override
public BigDecimal getShare() { return amount; }
}
- Create ExactSplit.java:
package com.splitwise.model;
import java.math.BigDecimal;
// Exact split: each user pays a specified exact amount
public class ExactSplit extends Split {
@Override
public BigDecimal getShare() { return amount; }
}
- Create PercentageSplit.java:
package com.splitwise.model;
import java.math.BigDecimal;
// Percentage split: each user pays a percentage of the total
public class PercentageSplit extends Split {
private BigDecimal percentage;
@Override
public BigDecimal getShare() { return amount; }
// Getter and setter for percentage
public BigDecimal getPercentage() { return percentage; }
public void setPercentage(BigDecimal percentage) { this.percentage = percentage; }
}
Why three subclasses that look similar?
Right now, each getShare() method simply returns the amount field. The real differentiation happens in the SplitStrategy service layer (Step 3), which calculates and sets that amount differently depending on the type.
Having separate classes lets the service layer use instanceof checks and handle each type distinctly. This is the Strategy pattern in action.
- Now create the main Expense.java entity in the model folder:
package com.splitwise.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
// JPA entity representing a shared expense within a group
@Entity
@Table(name = "expense")
public class Expense {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private BigDecimal amount;
// The user who paid for the expense
@ManyToOne
private User payer;
// The group this expense belongs to
@ManyToOne
private ExpenseGroup group;
private LocalDate date;
// Store the split type as a string in the database
@Enumerated(EnumType.STRING)
private SplitType splitType;
// Map of userId -> their share of the expense
@ElementCollection
@CollectionTable(name = "expense_splits")
@MapKeyColumn(name = "user_id")
@Column(name = "share")
private Map<Long, BigDecimal> splitDetails = new HashMap<>();
// Getters and setters
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 User getPayer() { return payer; }
public void setPayer(User payer) { this.payer = payer; }
public ExpenseGroup getGroup() { return group; }
public void setGroup(ExpenseGroup group) { this.group = group; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public SplitType getSplitType() { return splitType; }
public void setSplitType(SplitType splitType) { this.splitType = splitType; }
public Map<Long, BigDecimal> getSplitDetails() { return splitDetails; }
public void setSplitDetails(Map<Long, BigDecimal> splitDetails) { this.splitDetails = splitDetails; }
}
What does this code do?
- @ManyToOne on payer and group creates foreign key columns pointing to the app_user and expense_group tables.
- @Enumerated(EnumType.STRING) stores the split type as a readable string (EQUAL, EXACT, PERCENTAGE) instead of an integer index.
- @ElementCollection with @CollectionTable creates a separate expense_splits table to store the map of user shares. Each row holds a user_id and the share amount.
- BigDecimal is used for money instead of double to avoid floating-point rounding errors.
- Save all files.
- Check the Problems panel (Ctrl+Shift+M). You should see no errors in any of the model files.
Seeing a 'cannot find symbol' error for SplitType?
Make sure SplitType.java is saved and in the same com.splitwise.model package as Expense.java. The package declaration at the top of every file must be package com.splitwise.model;.
help me fix 'cannot find symbol' errors in my Java project
✔️ Awesome, I've got everything!
Great. Make sure all your model files are saved before moving on.
ⓧ I'd like to double check the full code
Here is every file you should have in your model folder at this point.
package com.splitwise.model;
import jakarta.persistence.*;
@Entity
@Table(name = "app_user")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String email;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}
package com.splitwise.model;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "expense_group")
public class ExpenseGroup {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private LocalDate createdDate;
@ManyToMany
@JoinTable(
name = "group_members",
joinColumns = @JoinColumn(name = "group_id"),
inverseJoinColumns = @JoinColumn(name = "user_id")
)
private Set<User> members = new HashSet<>();
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public LocalDate getCreatedDate() { return createdDate; }
public void setCreatedDate(LocalDate createdDate) { this.createdDate = createdDate; }
public Set<User> getMembers() { return members; }
public void setMembers(Set<User> members) { this.members = members; }
}
package com.splitwise.model;
public enum SplitType {
EQUAL,
EXACT,
PERCENTAGE
}
package com.splitwise.model;
import java.math.BigDecimal;
public abstract class Split {
protected Long userId;
protected BigDecimal amount;
public abstract BigDecimal getShare();
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
}
package com.splitwise.model;
import java.math.BigDecimal;
public class EqualSplit extends Split {
@Override
public BigDecimal getShare() { return amount; }
}
package com.splitwise.model;
import java.math.BigDecimal;
public class ExactSplit extends Split {
@Override
public BigDecimal getShare() { return amount; }
}
package com.splitwise.model;
import java.math.BigDecimal;
public class PercentageSplit extends Split {
private BigDecimal percentage;
@Override
public BigDecimal getShare() { return amount; }
public BigDecimal getPercentage() { return percentage; }
public void setPercentage(BigDecimal percentage) { this.percentage = percentage; }
}
package com.splitwise.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.HashMap;
import java.util.Map;
@Entity
@Table(name = "expense")
public class Expense {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String description;
private BigDecimal amount;
@ManyToOne
private User payer;
@ManyToOne
private ExpenseGroup group;
private LocalDate date;
@Enumerated(EnumType.STRING)
private SplitType splitType;
@ElementCollection
@CollectionTable(name = "expense_splits")
@MapKeyColumn(name = "user_id")
@Column(name = "share")
private Map<Long, BigDecimal> splitDetails = new HashMap<>();
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 User getPayer() { return payer; }
public void setPayer(User payer) { this.payer = payer; }
public ExpenseGroup getGroup() { return group; }
public void setGroup(ExpenseGroup group) { this.group = group; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public SplitType getSplitType() { return splitType; }
public void setSplitType(SplitType splitType) { this.splitType = splitType; }
public Map<Long, BigDecimal> getSplitDetails() { return splitDetails; }
public void setSplitDetails(Map<Long, BigDecimal> splitDetails) { this.splitDetails = splitDetails; }
}
Create repository interfaces using Spring Data JPA generics
Repository interfaces give you database operations (create, read, update, delete) without writing any SQL. Spring Data JPA generates the implementation at runtime based on the interface definition.
- In VS Code's Explorer sidebar, right-click on src/main/java/com/splitwise and create a new folder named repository.
- Create a file named UserRepository.java inside the repository folder and paste this code:
package com.splitwise.repository;
import com.splitwise.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
// Spring Data JPA auto-generates CRUD methods for User entities
public interface UserRepository extends JpaRepository<User, Long> {
}
- Create GroupRepository.java in the same folder:
package com.splitwise.repository;
import com.splitwise.model.ExpenseGroup;
import org.springframework.data.jpa.repository.JpaRepository;
// Spring Data JPA auto-generates CRUD methods for ExpenseGroup entities
public interface GroupRepository extends JpaRepository<ExpenseGroup, Long> {
}
- Create ExpenseRepository.java in the same folder:
package com.splitwise.repository;
import com.splitwise.model.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
// Spring Data JPA auto-generates CRUD methods for Expense entities
// Custom query method: Spring parses the method name to generate the SQL
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
List<Expense> findByGroupId(Long groupId);
}
What are generics doing here?
JpaRepository<User, Long> uses Java generics to tell Spring which entity type (User) and which primary key type (Long) this repository handles. Spring uses these type parameters to generate all the standard CRUD methods (save(), findById(), findAll(), deleteById()) automatically.
The findByGroupId method in ExpenseRepository is a custom query method. Spring Data JPA reads the method name and generates the SQL query WHERE group_id = ? for you.
- Save all three repository files.
- Verify the full project compiles by running this command in the terminal:
./mvnw spring-boot:run
- Watch the terminal output. You should see Hibernate SQL statements like create table app_user, create table expense_group, and create table expense in the logs. The application should start without errors.
- Press Ctrl+C in the terminal to stop the application once you confirm it started successfully.
Application fails to start?
Make sure PostgreSQL is still running by checking with sudo systemctl status postgresql. If it shows inactive, start it with sudo systemctl start postgresql.
If you see a "relation already exists" warning, that is normal. The ddl-auto=update setting in application.properties only creates tables that do not exist yet and updates existing ones.
help me debug my Spring Boot application startup failure
✔️ Awesome, I've got everything!
Great. Confirm you saved all repository files and your app started cleanly.
ⓧ I'd like to double check the full code
Here are the three repository files you should have:
package com.splitwise.repository;
import com.splitwise.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
public interface UserRepository extends JpaRepository<User, Long> {
}
package com.splitwise.repository;
import com.splitwise.model.ExpenseGroup;
import org.springframework.data.jpa.repository.JpaRepository;
public interface GroupRepository extends JpaRepository<ExpenseGroup, Long> {
}
package com.splitwise.repository;
import com.splitwise.model.Expense;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ExpenseRepository extends JpaRepository<Expense, Long> {
List<Expense> findByGroupId(Long groupId);
}
Your domain model is complete. You have entities, an enum, an inheritance hierarchy, and repository interfaces ready to go. Next up, you will build the split calculation engine that uses these models to determine who owes what.
Build the Split Calculation Engine
Your domain model is in place. You have entities for users, groups, and expenses, plus an enum that tracks the split type.
Now you need the engine that actually calculates who owes what. When someone pays for a group dinner, how does the app figure out each person's share? That depends on the split type: equal, exact amounts, or percentages. In this step, you will build a strategy-based calculation engine and a balance service that uses Java Streams to aggregate debts across all expenses.
In this step, get ready to:
- Create a SplitStrategy interface and three implementations using the Strategy pattern.
- Build a BalanceService that calculates net balances using Streams.
- Add custom exceptions for business rule validation.
Create the SplitStrategy interface and implementations
A group expense can be split three ways: equally among all members, by exact amounts each person owes, or by percentages. Each splitting approach needs different math.
Why the Strategy pattern instead of if-else?
You could write one big method with if-else branches for each split type. The Strategy pattern separates each algorithm into its own class that implements a shared interface. This means adding a new split type later (like "shares-based") only requires a new class, not editing existing code.
It also demonstrates polymorphism, interfaces, and dependency injection working together in a real use case.
- In the VS Code Explorer sidebar, right-click the com.splitwise package (under src/main/java) and create a new package called strategy.
- Inside the strategy package, create a file called SplitStrategy.java and add the interface definition:
package com.splitwise.strategy;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
// Defines the contract for all split calculation strategies
public interface SplitStrategy {
Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members);
}
- In the same strategy package, create EqualSplitStrategy.java with this code:
package com.splitwise.strategy;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// Splits the expense equally among all group members
@Component
public class EqualSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
// Divide total by number of members, rounding to 2 decimal places
BigDecimal share = expense.getAmount().divide(BigDecimal.valueOf(members.size()), 2, RoundingMode.HALF_UP);
// Return a map of userId -> equal share for each member
return members.stream().collect(Collectors.toMap(User::getId, u -> share));
}
}
What does this code do?
The SplitStrategy interface defines a single method: given an expense and a list of members, return a map of user IDs to their share amounts.
The EqualSplitStrategy divides the expense total by the number of members. The @Component annotation tells Spring Boot to auto-detect this class and make it available for injection. The Collectors.toMap call uses a stream to build the result map in one pass.
- Save both files.
- Confirm you see SplitStrategy.java and EqualSplitStrategy.java in the Explorer sidebar under strategy/.
Don't see the strategy package?
Make sure you created the package inside src/main/java/com/splitwise/ and not at the project root. The full path should be src/main/java/com/splitwise/strategy/.
help me find where to create the strategy package in my Spring Boot project
- Create ExactSplitStrategy.java in the strategy package with this code:
package com.splitwise.strategy;
import com.splitwise.exception.InvalidSplitException;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// Uses exact amounts specified per user in the expense's splitDetails
@Component
public class ExactSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
// Sum all specified amounts and verify they equal the expense total
BigDecimal total = expense.getSplitDetails().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.compareTo(expense.getAmount()) != 0) throw new InvalidSplitException("Exact splits must sum to total amount");
return new HashMap<>(expense.getSplitDetails());
}
}
What does this code do?
The ExactSplitStrategy trusts the caller to specify exact dollar amounts per user in splitDetails. It validates that those amounts add up to the expense total. If they do not match, it throws an InvalidSplitException (which you will create in substep 3).
- Save ExactSplitStrategy.java.
- You won't be able to compile yet because InvalidSplitException does not exist. You will create it in the final substep.
- Create PercentageSplitStrategy.java in the strategy package with this code:
package com.splitwise.strategy;
import com.splitwise.exception.InvalidSplitException;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// Converts percentage-based splits into actual dollar amounts
@Component
public class PercentageSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
// Verify all percentages sum to exactly 100
BigDecimal totalPct = expense.getSplitDetails().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalPct.compareTo(new BigDecimal("100")) != 0) throw new InvalidSplitException("Percentages must sum to 100");
// Convert each percentage to a dollar amount
return expense.getSplitDetails().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> expense.getAmount().multiply(e.getValue()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP)
));
}
}
What does this code do?
The PercentageSplitStrategy first validates that all percentages in splitDetails sum to 100. Then it converts each percentage to an actual dollar amount using multiply and divide on BigDecimal. This avoids floating-point rounding errors that plague financial calculations.
- Save PercentageSplitStrategy.java.
✔️ Awesome, I've got everything!
Great. You should now have four files in your strategy package. Double check you have saved them all.
ⓧ I'd like to double check the full code
package com.splitwise.strategy;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
public interface SplitStrategy {
Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members);
}
package com.splitwise.strategy;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class EqualSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
BigDecimal share = expense.getAmount().divide(BigDecimal.valueOf(members.size()), 2, RoundingMode.HALF_UP);
return members.stream().collect(Collectors.toMap(User::getId, u -> share));
}
}
package com.splitwise.strategy;
import com.splitwise.exception.InvalidSplitException;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
public class ExactSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
BigDecimal total = expense.getSplitDetails().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (total.compareTo(expense.getAmount()) != 0) throw new InvalidSplitException("Exact splits must sum to total amount");
return new HashMap<>(expense.getSplitDetails());
}
}
package com.splitwise.strategy;
import com.splitwise.exception.InvalidSplitException;
import com.splitwise.model.Expense;
import com.splitwise.model.User;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Component
public class PercentageSplitStrategy implements SplitStrategy {
@Override
public Map<Long, BigDecimal> calculateSplits(Expense expense, List<User> members) {
BigDecimal totalPct = expense.getSplitDetails().values().stream().reduce(BigDecimal.ZERO, BigDecimal::add);
if (totalPct.compareTo(new BigDecimal("100")) != 0) throw new InvalidSplitException("Percentages must sum to 100");
return expense.getSplitDetails().entrySet().stream().collect(Collectors.toMap(
Map.Entry::getKey,
e -> expense.getAmount().multiply(e.getValue()).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP)
));
}
}
Build the BalanceService that calculates net balances using streams
The strategies handle individual expense splits. Now you need a service that processes ALL expenses in a group, figures out each person's net position, and simplifies the debts so people make as few payments as possible.
- Create a new package called dto inside com.splitwise.
- Inside dto, create DebtRecord.java with this record definition:
package com.splitwise.dto;
import java.math.BigDecimal;
// Immutable data carrier representing one simplified debt between two people
public record DebtRecord(String from, String to, BigDecimal amount) {}
What is a record?
A Java record is a compact class that holds immutable data. The compiler automatically generates the constructor, getters, equals(), hashCode(), and toString(). It replaces the boilerplate of a traditional DTO class.
- Save DebtRecord.java.
- Create a new package called service inside com.splitwise.
- Inside service, create BalanceService.java with this code:
package com.splitwise.service;
import com.splitwise.dto.DebtRecord;
import com.splitwise.exception.InsufficientMembersException;
import com.splitwise.exception.ResourceNotFoundException;
import com.splitwise.model.Expense;
import com.splitwise.model.ExpenseGroup;
import com.splitwise.model.SplitType;
import com.splitwise.model.User;
import com.splitwise.repository.ExpenseRepository;
import com.splitwise.repository.GroupRepository;
import com.splitwise.strategy.EqualSplitStrategy;
import com.splitwise.strategy.ExactSplitStrategy;
import com.splitwise.strategy.PercentageSplitStrategy;
import com.splitwise.strategy.SplitStrategy;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class BalanceService {
private final ExpenseRepository expenseRepository;
private final GroupRepository groupRepository;
// Maps each SplitType to its corresponding strategy implementation
private final Map<SplitType, SplitStrategy> strategies;
// Constructor injection: Spring auto-wires all three strategy beans
public BalanceService(ExpenseRepository expenseRepository, GroupRepository groupRepository,
EqualSplitStrategy equalStrategy, ExactSplitStrategy exactStrategy, PercentageSplitStrategy pctStrategy) {
this.expenseRepository = expenseRepository;
this.groupRepository = groupRepository;
this.strategies = Map.of(SplitType.EQUAL, equalStrategy, SplitType.EXACT, exactStrategy, SplitType.PERCENTAGE, pctStrategy);
}
public List<DebtRecord> calculateBalances(Long groupId) {
// Look up the group or throw if not found
ExpenseGroup group = groupRepository.findById(groupId).orElseThrow(() -> new ResourceNotFoundException("Group not found"));
List<User> members = new ArrayList<>(group.getMembers());
if (members.size() < 2) throw new InsufficientMembersException("Group needs at least 2 members");
// Load all expenses for this group
List<Expense> expenses = expenseRepository.findByGroupId(groupId);
// Track each user's net balance (positive = owed money, negative = owes money)
Map<Long, BigDecimal> netBalances = new HashMap<>();
for (Expense expense : expenses) {
SplitStrategy strategy = strategies.get(expense.getSplitType());
Map<Long, BigDecimal> splits = strategy.calculateSplits(expense, members);
splits.forEach((userId, share) -> {
if (!userId.equals(expense.getPayer().getId())) {
// The user owes this share (negative for them)
netBalances.merge(userId, share.negate(), BigDecimal::add);
// The payer is owed this share (positive for them)
netBalances.merge(expense.getPayer().getId(), share, BigDecimal::add);
}
});
}
return simplifyDebts(netBalances, members);
}
private List<DebtRecord> simplifyDebts(Map<Long, BigDecimal> netBalances, List<User> members) {
// Build a lookup from userId to display name
Map<Long, String> nameMap = members.stream().collect(Collectors.toMap(User::getId, User::getName));
List<DebtRecord> debts = new ArrayList<>();
// Match debtors (negative balance) with creditors (positive balance)
netBalances.entrySet().stream()
.filter(e -> e.getValue().compareTo(BigDecimal.ZERO) < 0)
.forEach(e -> {
netBalances.entrySet().stream()
.filter(c -> c.getValue().compareTo(BigDecimal.ZERO) > 0)
.forEach(c -> {
BigDecimal amount = e.getValue().abs().min(c.getValue());
if (amount.compareTo(BigDecimal.ZERO) > 0) {
debts.add(new DebtRecord(nameMap.get(e.getKey()), nameMap.get(c.getKey()), amount));
}
});
});
return debts;
}
}
What does this code do?
- The constructor receives all three strategy beans via dependency injection and stores them in an immutable Map.of() keyed by SplitType.
- The calculateBalances method loads the group and its expenses, applies the correct strategy for each expense, and accumulates net balances per user using merge.
- The simplifyDebts method uses streams with filter to pair debtors with creditors and produce a list of "Alice owes Bob $15" style results.
- The @Service annotation registers this class as a Spring bean, making it injectable into controllers later.
- Save BalanceService.java.
- You won't be able to compile yet because the exception classes do not exist. The next substep creates them.
✔️ Awesome, I've got everything!
Great. Double check you have saved DebtRecord.java and BalanceService.java.
ⓧ I'd like to double check the full code
package com.splitwise.dto;
import java.math.BigDecimal;
public record DebtRecord(String from, String to, BigDecimal amount) {}
package com.splitwise.service;
import com.splitwise.dto.DebtRecord;
import com.splitwise.exception.InsufficientMembersException;
import com.splitwise.exception.ResourceNotFoundException;
import com.splitwise.model.Expense;
import com.splitwise.model.ExpenseGroup;
import com.splitwise.model.SplitType;
import com.splitwise.model.User;
import com.splitwise.repository.ExpenseRepository;
import com.splitwise.repository.GroupRepository;
import com.splitwise.strategy.EqualSplitStrategy;
import com.splitwise.strategy.ExactSplitStrategy;
import com.splitwise.strategy.PercentageSplitStrategy;
import com.splitwise.strategy.SplitStrategy;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.util.*;
import java.util.stream.Collectors;
@Service
public class BalanceService {
private final ExpenseRepository expenseRepository;
private final GroupRepository groupRepository;
private final Map<SplitType, SplitStrategy> strategies;
public BalanceService(ExpenseRepository expenseRepository, GroupRepository groupRepository,
EqualSplitStrategy equalStrategy, ExactSplitStrategy exactStrategy, PercentageSplitStrategy pctStrategy) {
this.expenseRepository = expenseRepository;
this.groupRepository = groupRepository;
this.strategies = Map.of(SplitType.EQUAL, equalStrategy, SplitType.EXACT, exactStrategy, SplitType.PERCENTAGE, pctStrategy);
}
public List<DebtRecord> calculateBalances(Long groupId) {
ExpenseGroup group = groupRepository.findById(groupId).orElseThrow(() -> new ResourceNotFoundException("Group not found"));
List<User> members = new ArrayList<>(group.getMembers());
if (members.size() < 2) throw new InsufficientMembersException("Group needs at least 2 members");
List<Expense> expenses = expenseRepository.findByGroupId(groupId);
Map<Long, BigDecimal> netBalances = new HashMap<>();
for (Expense expense : expenses) {
SplitStrategy strategy = strategies.get(expense.getSplitType());
Map<Long, BigDecimal> splits = strategy.calculateSplits(expense, members);
splits.forEach((userId, share) -> {
if (!userId.equals(expense.getPayer().getId())) {
netBalances.merge(userId, share.negate(), BigDecimal::add);
netBalances.merge(expense.getPayer().getId(), share, BigDecimal::add);
}
});
}
return simplifyDebts(netBalances, members);
}
private List<DebtRecord> simplifyDebts(Map<Long, BigDecimal> netBalances, List<User> members) {
Map<Long, String> nameMap = members.stream().collect(Collectors.toMap(User::getId, User::getName));
List<DebtRecord> debts = new ArrayList<>();
netBalances.entrySet().stream()
.filter(e -> e.getValue().compareTo(BigDecimal.ZERO) < 0)
.forEach(e -> {
netBalances.entrySet().stream()
.filter(c -> c.getValue().compareTo(BigDecimal.ZERO) > 0)
.forEach(c -> {
BigDecimal amount = e.getValue().abs().min(c.getValue());
if (amount.compareTo(BigDecimal.ZERO) > 0) {
debts.add(new DebtRecord(nameMap.get(e.getKey()), nameMap.get(c.getKey()), amount));
}
});
});
return debts;
}
}
Add custom exceptions and validation
Your strategies and service reference three exception classes that do not exist yet. These are custom unchecked exceptions that enforce business rules: you cannot split an expense among fewer than 2 people, and split amounts must add up correctly.
- Create a new package called exception inside com.splitwise.
- Inside exception, create ResourceNotFoundException.java with this code:
package com.splitwise.exception;
// Thrown when a requested entity (group, user, expense) does not exist
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) { super(message); }
}
- Create InvalidSplitException.java in the same package:
package com.splitwise.exception;
// Thrown when split amounts or percentages do not add up correctly
public class InvalidSplitException extends RuntimeException {
public InvalidSplitException(String message) { super(message); }
}
- Create InsufficientMembersException.java in the same package:
package com.splitwise.exception;
// Thrown when a group has fewer than 2 members for expense splitting
public class InsufficientMembersException extends RuntimeException {
public InsufficientMembersException(String message) { super(message); }
}
Why extend RuntimeException?
Java has two kinds of exceptions: checked (extend Exception) and unchecked (extend RuntimeException). Unchecked exceptions do not need to be declared in method signatures with throws. They represent programming errors or violated business rules that callers should not be forced to handle at every call site.
In a Spring Boot REST API, unchecked exceptions are the standard choice. Spring catches them and converts them into HTTP error responses automatically.
- Save all three exception files.
- Return to your terminal and verify the project compiles by running:
./mvnw spring-boot:run
- You should see the Spring Boot banner and a line containing Started SplitwiseApiApplication in the terminal output.
- Press Ctrl+C to stop the server.
Seeing a compilation error?
Check that all three exception class names match exactly: ResourceNotFoundException, InvalidSplitException, InsufficientMembersException. The import statements in your strategy and service files must reference com.splitwise.exception as the package.
Also verify that your Expense entity has a getSplitDetails() method returning Map<Long, BigDecimal>. If it is missing, add a getter to your Expense.java class.
help me fix a compilation error in my Spring Boot split calculation engine
✔️ Awesome, I've got everything!
Great. Double check you have saved all three exception files in the exception package.
ⓧ I'd like to double check the full code
package com.splitwise.exception;
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) { super(message); }
}
package com.splitwise.exception;
public class InvalidSplitException extends RuntimeException {
public InvalidSplitException(String message) { super(message); }
}
package com.splitwise.exception;
public class InsufficientMembersException extends RuntimeException {
public InsufficientMembersException(String message) { super(message); }
}
Your calculation engine is ready. You have three interchangeable split strategies, a balance service that ties them together with streams, and custom exceptions guarding the business rules. Next up, you will wire all of this to HTTP endpoints so clients can actually call your API.
Wire Up the REST API
You have a working domain model, split strategies, and a balance calculation engine. The business logic is ready, but there is no way for the outside world to use it.
In this step, you will expose your split engine as a REST API using Spring Boot controllers. You will define record-based DTOs for clean request/response shapes, build service classes that orchestrate your repositories, and test the full flow with curl.
In this step, get ready to:
- Create DTO records and the UserController.
- Create GroupController and ExpenseController endpoints.
- Test the full flow with curl commands.
Create DTO records and the UserController
Your entities hold JPA annotations and database concerns. You do not want to expose those directly to API consumers. Instead, you will create lightweight Data Transfer Objects (DTOs) that define the exact shape of your request and response JSON.
Java records are perfect for this. A record is an immutable data carrier that auto-generates a constructor, accessors, equals, hashCode, and toString from its component list.
- Create a new package dto inside src/main/java/com/splitwise/ by right-clicking the splitwise folder in VS Code's Explorer sidebar and selecting New Folder.
- Inside the dto folder, create a file called CreateUserRequest.java and paste the following code:
package com.splitwise.dto;
// Record that defines the shape of a "create user" request body
public record CreateUserRequest(String name, String email) {}
- Create UserResponse.java in the same dto folder:
package com.splitwise.dto;
// Record that defines the shape of a user in API responses
public record UserResponse(Long id, String name, String email) {}
- Create CreateGroupRequest.java:
package com.splitwise.dto;
// Record for the "create group" request body
public record CreateGroupRequest(String name, String description) {}
- Create GroupResponse.java:
package com.splitwise.dto;
import java.util.Set;
// Record for group data in API responses, including its members
public record GroupResponse(Long id, String name, String description, Set<UserResponse> members) {}
- Create CreateExpenseRequest.java:
package com.splitwise.dto;
import com.splitwise.model.SplitType;
import java.math.BigDecimal;
import java.util.Map;
// Record for the "add expense" request body with split configuration
public record CreateExpenseRequest(String description, BigDecimal amount, Long payerId, SplitType splitType, Map<Long, BigDecimal> splitDetails) {}
- Create DebtRecord.java:
package com.splitwise.dto;
import java.math.BigDecimal;
// Record representing a simplified debt between two users
public record DebtRecord(String from, String to, BigDecimal amount) {}
What are records doing here?
Each record is a single line that replaces an entire class with fields, a constructor, getters, equals, hashCode, and toString. Java auto-generates all of those from the component list in the parentheses.
Records are immutable. Once created, their values cannot change. This makes them ideal for DTOs because API request/response shapes should never be modified after construction.
- Save all six files.
- In your terminal, run ./mvnw compile to confirm everything compiles without errors.
Seeing a compile error?
Check that each file has the correct package com.splitwise.dto; declaration at the top. Also confirm that CreateExpenseRequest.java imports com.splitwise.model.SplitType correctly.
Help me fix a compile error in my DTO records.
Now create the service layer that uses these DTOs. The UserService will translate between your User entity and the UserResponse record.
- Create UserService.java inside the service package (src/main/java/com/splitwise/service/) by pasting the following code:
package com.splitwise.service;
import com.splitwise.dto.CreateUserRequest;
import com.splitwise.dto.UserResponse;
import com.splitwise.exception.ResourceNotFoundException;
import com.splitwise.model.User;
import com.splitwise.repository.UserRepository;
import org.springframework.stereotype.Service;
@Service
public class UserService {
private final UserRepository userRepository;
// Constructor injection - Spring provides the repository automatically
public UserService(UserRepository userRepository) { this.userRepository = userRepository; }
// Creates a new user and returns a DTO response
public UserResponse createUser(CreateUserRequest request) {
User user = new User(); user.setName(request.name()); user.setEmail(request.email());
User saved = userRepository.save(user);
return new UserResponse(saved.getId(), saved.getName(), saved.getEmail());
}
// Finds a user by ID, throwing if not found
public UserResponse getUserById(Long id) {
User user = userRepository.findById(id).orElseThrow(() -> new ResourceNotFoundException("User not found"));
return new UserResponse(user.getId(), user.getName(), user.getEmail());
}
}
What does this code do?
- The @Service annotation tells Spring to manage this class as a bean and inject it wherever needed.
- The constructor receives UserRepository via dependency injection. Spring automatically provides the repository because it is a single-constructor class.
- The getUserById method uses Optional.orElseThrow() to safely handle the case where no user exists with that ID.
- Save UserService.java.
Now create the controller that maps HTTP requests to service calls.
- Create a new package controller inside src/main/java/com/splitwise/.
- Create UserController.java inside the controller package with the following code:
package com.splitwise.controller;
import com.splitwise.dto.CreateUserRequest;
import com.splitwise.dto.UserResponse;
import com.splitwise.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
// Spring injects the UserService through the constructor
public UserController(UserService userService) { this.userService = userService; }
// POST /api/users - creates a new user and returns 201 Created
@PostMapping public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
}
// GET /api/users/{id} - retrieves a single user by ID
@GetMapping("/{id}") public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
}
What does this code do?
- The @RestController annotation combines @Controller and @ResponseBody, meaning every method automatically serializes its return value to JSON.
- The @RequestMapping("/api/users") sets the base URL path for all endpoints in this controller.
- The ResponseEntity wrapper lets you set explicit HTTP status codes like 201 Created for new resources.
- Save UserController.java.
- Run ./mvnw compile in your terminal to verify everything compiles.
Compile errors mentioning imports?
Make sure your UserController.java imports jakarta.validation.Valid (not javax.validation). Spring Boot 3.x uses the Jakarta EE namespace.
Help me fix import errors in my UserController.
Create GroupController and ExpenseController
The group, expense, and balance endpoints are all scoped under /api/groups. Since expenses and balances belong to a group, you will combine them into a single GroupController alongside a GroupService that handles the orchestration logic.
- Create GroupService.java inside the service package:
package com.splitwise.service;
import com.splitwise.dto.*;
import com.splitwise.exception.ResourceNotFoundException;
import com.splitwise.model.*;
import com.splitwise.repository.*;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class GroupService {
private final GroupRepository groupRepository;
private final UserRepository userRepository;
private final ExpenseRepository expenseRepository;
// Constructor injection for all three repositories
public GroupService(GroupRepository groupRepository, UserRepository userRepository, ExpenseRepository expenseRepository) {
this.groupRepository = groupRepository; this.userRepository = userRepository; this.expenseRepository = expenseRepository;
}
// Creates a new expense group
public GroupResponse createGroup(CreateGroupRequest request) {
ExpenseGroup group = new ExpenseGroup();
group.setName(request.name());
group.setDescription(request.description());
group.setCreatedDate(LocalDate.now());
ExpenseGroup saved = groupRepository.save(group);
return toGroupResponse(saved);
}
// Adds an existing user to a group
public GroupResponse addMember(Long groupId, Long userId) {
ExpenseGroup group = groupRepository.findById(groupId)
.orElseThrow(() -> new ResourceNotFoundException("Group not found"));
User user = userRepository.findById(userId)
.orElseThrow(() -> new ResourceNotFoundException("User not found"));
group.getMembers().add(user);
ExpenseGroup saved = groupRepository.save(group);
return toGroupResponse(saved);
}
// Creates an expense in the group with the specified split configuration
public void addExpense(Long groupId, CreateExpenseRequest request) {
ExpenseGroup group = groupRepository.findById(groupId)
.orElseThrow(() -> new ResourceNotFoundException("Group not found"));
User payer = userRepository.findById(request.payerId())
.orElseThrow(() -> new ResourceNotFoundException("Payer not found"));
Expense expense = new Expense();
expense.setDescription(request.description());
expense.setAmount(request.amount());
expense.setPayer(payer);
expense.setGroup(group);
expense.setDate(LocalDate.now());
expense.setSplitType(request.splitType());
expense.setSplitDetails(request.splitDetails());
expenseRepository.save(expense);
}
// Helper to convert entity to response DTO
private GroupResponse toGroupResponse(ExpenseGroup group) {
Set<UserResponse> memberResponses = group.getMembers().stream()
.map(u -> new UserResponse(u.getId(), u.getName(), u.getEmail()))
.collect(Collectors.toSet());
return new GroupResponse(group.getId(), group.getName(), group.getDescription(), memberResponses);
}
}
What does this code do?
- Each method uses Optional.orElseThrow() to throw a ResourceNotFoundException when an entity is not found. This keeps the code null-safe without manual if (result == null) checks.
- The addExpense method reads the split configuration (splitType and splitDetails) directly from the DTO record using accessor methods like request.splitType().
- The toGroupResponse helper uses a stream to map each User entity into a UserResponse record.
- Save GroupService.java.
Now create the controller that exposes group, expense, and balance endpoints.
- Create GroupController.java inside the controller package:
package com.splitwise.controller;
import com.splitwise.dto.*;
import com.splitwise.service.BalanceService;
import com.splitwise.service.GroupService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/groups")
public class GroupController {
private final GroupService groupService;
private final BalanceService balanceService;
// Injects both services through the constructor
public GroupController(GroupService groupService, BalanceService balanceService) {
this.groupService = groupService; this.balanceService = balanceService;
}
// POST /api/groups - creates a new expense group
@PostMapping public ResponseEntity<GroupResponse> createGroup(@RequestBody @Valid CreateGroupRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(groupService.createGroup(request));
}
// POST /api/groups/{groupId}/members/{userId} - adds a user to the group
@PostMapping("/{groupId}/members/{userId}") public ResponseEntity<GroupResponse> addMember(@PathVariable Long groupId, @PathVariable Long userId) {
return ResponseEntity.ok(groupService.addMember(groupId, userId));
}
// POST /api/groups/{groupId}/expenses - logs a new expense with split config
@PostMapping("/{groupId}/expenses") public ResponseEntity<Void> addExpense(@PathVariable Long groupId, @RequestBody @Valid CreateExpenseRequest request) {
groupService.addExpense(groupId, request); return ResponseEntity.status(HttpStatus.CREATED).build();
}
// GET /api/groups/{groupId}/balances - returns simplified debts
@GetMapping("/{groupId}/balances") public ResponseEntity<List<DebtRecord>> getBalances(@PathVariable Long groupId) {
return ResponseEntity.ok(balanceService.calculateBalances(groupId));
}
}
What does this code do?
- The addExpense endpoint returns ResponseEntity<Void> with a 201 Created status. It creates the expense but returns no body.
- The getBalances endpoint delegates to the BalanceService you built in the previous step. It returns the simplified debt list directly.
- Both GroupService and BalanceService are injected through the constructor. Spring resolves them from its application context automatically.
- Save GroupController.java.
Finally, add a global exception handler that translates your custom exceptions into proper HTTP error responses.
- Create GlobalExceptionHandler.java inside the exception package (src/main/java/com/splitwise/exception/):
package com.splitwise.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
// Catches exceptions thrown by any controller and returns structured HTTP errors
@RestControllerAdvice
public class GlobalExceptionHandler {
// Maps ResourceNotFoundException to 404 Not Found
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<String> handleNotFound(ResourceNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); }
// Maps InvalidSplitException to 400 Bad Request
@ExceptionHandler(InvalidSplitException.class)
public ResponseEntity<String> handleInvalidSplit(InvalidSplitException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); }
// Maps InsufficientMembersException to 400 Bad Request
@ExceptionHandler(InsufficientMembersException.class)
public ResponseEntity<String> handleInsufficientMembers(InsufficientMembersException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); }
}
What is @RestControllerAdvice?
The @RestControllerAdvice annotation marks a class that intercepts exceptions from all controllers globally. Each @ExceptionHandler method maps a specific exception type to an HTTP status code.
Without this handler, Spring would return a generic 500 error for every unhandled exception. Now your API returns meaningful status codes: 404 for missing resources, 400 for invalid split configurations.
- Save GlobalExceptionHandler.java.
- Run ./mvnw compile to confirm all files compile.
Compile error about BalanceService not matching?
If you see an error about DebtRecord not being found, make sure the BalanceService import statement points to com.splitwise.dto.DebtRecord. The record you just created in the dto package is what BalanceService returns.
Help me resolve a DebtRecord import issue.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here are the key files for this substep. Compare yours against these:
package com.splitwise.controller;
import com.splitwise.dto.CreateUserRequest;
import com.splitwise.dto.UserResponse;
import com.splitwise.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
public UserController(UserService userService) { this.userService = userService; }
@PostMapping public ResponseEntity<UserResponse> createUser(@RequestBody @Valid CreateUserRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(userService.createUser(request));
}
@GetMapping("/{id}") public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.ok(userService.getUserById(id));
}
}
package com.splitwise.controller;
import com.splitwise.dto.*;
import com.splitwise.service.BalanceService;
import com.splitwise.service.GroupService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/groups")
public class GroupController {
private final GroupService groupService;
private final BalanceService balanceService;
public GroupController(GroupService groupService, BalanceService balanceService) {
this.groupService = groupService; this.balanceService = balanceService;
}
@PostMapping public ResponseEntity<GroupResponse> createGroup(@RequestBody @Valid CreateGroupRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(groupService.createGroup(request));
}
@PostMapping("/{groupId}/members/{userId}") public ResponseEntity<GroupResponse> addMember(@PathVariable Long groupId, @PathVariable Long userId) {
return ResponseEntity.ok(groupService.addMember(groupId, userId));
}
@PostMapping("/{groupId}/expenses") public ResponseEntity<Void> addExpense(@PathVariable Long groupId, @RequestBody @Valid CreateExpenseRequest request) {
groupService.addExpense(groupId, request); return ResponseEntity.status(HttpStatus.CREATED).build();
}
@GetMapping("/{groupId}/balances") public ResponseEntity<List<DebtRecord>> getBalances(@PathVariable Long groupId) {
return ResponseEntity.ok(balanceService.calculateBalances(groupId));
}
}
package com.splitwise.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<String> handleNotFound(ResourceNotFoundException ex) { return ResponseEntity.status(HttpStatus.NOT_FOUND).body(ex.getMessage()); }
@ExceptionHandler(InvalidSplitException.class)
public ResponseEntity<String> handleInvalidSplit(InvalidSplitException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); }
@ExceptionHandler(InsufficientMembersException.class)
public ResponseEntity<String> handleInsufficientMembers(InsufficientMembersException ex) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(ex.getMessage()); }
}
Test the full flow with curl commands
Everything is wired up. Time to start the application and test the complete expense-splitting flow using curl.
- Start the application by running this command:
./mvnw spring-boot:run
- Wait until you see the line that says the application started on port 8080. Open a second terminal for the curl commands below.
First, create three users.
- Create the users by running these commands:
curl -s -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "email": "alice@example.com"}'
curl -s -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Bob", "email": "bob@example.com"}'
curl -s -X POST http://localhost:8080/api/users \
-H "Content-Type: application/json" \
-d '{"name": "Charlie", "email": "charlie@example.com"}'
Each command should return a JSON response with the user's id, name, and email.
- Create a group and add all three users as members by running these commands:
curl -s -X POST http://localhost:8080/api/groups \
-H "Content-Type: application/json" \
-d '{"name": "Weekend Trip", "description": "Beach house expenses"}'
curl -s -X POST http://localhost:8080/api/groups/1/members/1
curl -s -X POST http://localhost:8080/api/groups/1/members/2
curl -s -X POST http://localhost:8080/api/groups/1/members/3
The group response should now show all three members in its members array.
- Add an expense with an EQUAL split (Alice paid $90 dinner, split three ways) by running:
curl -s -X POST http://localhost:8080/api/groups/1/expenses \
-H "Content-Type: application/json" \
-d '{"description": "Dinner", "amount": 90, "payerId": 1, "splitType": "EQUAL", "splitDetails": {}}'
- Add an expense with an EXACT split (Bob paid $60 for groceries, custom amounts) by running:
curl -s -X POST http://localhost:8080/api/groups/1/expenses \
-H "Content-Type: application/json" \
-d '{"description": "Groceries", "amount": 60, "payerId": 2, "splitType": "EXACT", "splitDetails": {"1": 20, "2": 15, "3": 25}}'
- Add an expense with a PERCENTAGE split (Charlie paid $100 for the rental, 50/30/20) by running:
curl -s -X POST http://localhost:8080/api/groups/1/expenses \
-H "Content-Type: application/json" \
-d '{"description": "Rental", "amount": 100, "payerId": 3, "splitType": "PERCENTAGE", "splitDetails": {"1": 50, "2": 30, "3": 20}}'
- Query the simplified balances by running:
curl -s http://localhost:8080/api/groups/1/balances | python3 -m json.tool
You should see a JSON array showing who owes whom after simplification. The exact amounts depend on how your debt simplification algorithm resolves the net balances.
What should I see?
The response is a JSON array of debt objects. Each object has from (the debtor), to (the creditor), and amount (what is owed). The algorithm simplifies multiple transactions into the fewest possible payments.
Getting a connection refused error?
Make sure ./mvnw spring-boot:run is still running in your first terminal. The API only responds while the application is active. Also confirm you are hitting http://localhost:8080 (not https).
If you see a 500 error, check the application logs in the first terminal for the full stack trace. A common cause is PostgreSQL not running.
Help me debug my Spring Boot API returning errors.
Your Splitwise API is fully functional. You can create users, organize them into groups, log shared expenses with three different split strategies, and query simplified balances. Next up, you will extend this with a settlement feature in the Secret Mission.
Secret mission
Add a Settle Up Feature
Your API tracks who owes whom, but there is no way to record payments. Add a settlement endpoint that lets users pay each other back and automatically recalculates the outstanding balances.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- PostgreSQL database splitwise_db and user splitwise running locally.
- Spring Boot application running at http://localhost:8080.
- Project folder splitwise-api on your machine.
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with Project 2 (a React Native frontend that consumes this API).
- Your PostgreSQL service continues running in the background.
- Your Spring Boot app stays available at http://localhost:8080 as long as the terminal process is running.
- Your project folder and all source code remain untouched.
Pause - I'll come back to this later
Shut down running processes to free up memory. Your files and database stay intact for when you return.
- Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
- Stop the PostgreSQL service by running:
sudo systemctl stop postgresql
- When you are ready to resume, start PostgreSQL again with sudo systemctl start postgresql and run ./mvnw spring-boot:run from your project folder.
Delete - I don't want to use this again
Remove all project resources and start fresh. This is irreversible.
- Stop the Spring Boot application by pressing Ctrl+C in the terminal where it is running.
- Drop the database and user by running these commands:
sudo -u postgres dropdb splitwise_db
sudo -u postgres dropuser splitwise
- Stop the PostgreSQL service by running:
sudo systemctl stop postgresql
- Delete the project folder by running:
rm -rf ~/Desktop/splitwise-api
- If you saved the project in a different location, navigate to that folder and delete splitwise-api from there instead.
Nice Work!
Nice Work!
You just built a production-style Splitwise expense splitting API from scratch using Spring Boot and PostgreSQL. Every intermediate Java concept you practiced here is directly transferable to backend roles and technical interviews.
You've learned how to:
- Build a complete REST API with Spring Boot using clean architecture (controller, service, and repository layers) backed by PostgreSQL with JPA entity relationships.
- Use OOP inheritance and the Strategy pattern to implement polymorphic split calculations (equal, exact, percentage) with interfaces, enums, records, and custom exceptions.
- Leverage Java Streams and collections to calculate net balances across group expenses and simplify debts between members.
- Secret Mission: Added a settlement/payment system that records payments between users and recalculates outstanding balances with validation.
Ready to quiz yourself?