Command-Line Expense Tracker
Using Java with a database-backed architecture
Challenge Outlines
Object-Oriented Programming (OOP):
is a programming paradigm based on the concept of objects that contain data (fields)
and behavior (methods). . It models real-world entities and improves code maintenance,
scalability, and collaboration.Clean Code principles:
includes using meaningful names, adhering to the Single Responsibility Principle
(functions do one thing), keeping code DRY (Don't Repeat Yourself),
and maintaining consistent formatting to minimize cognitive load.Layered architecture (Controller → Service → Repository):
used for separation of concerns in applications.Working with a database (SQLite or MySQL)
mySql was used for this application.JCBC (Java Database Connectivity):
used to interact with your relational database.Exception handling and validation:
complementary techniques to ensure application stability and data integrityWriting maintainable and scalable code: Maintainability
refers to how easily code can be modified, debugged, and enhanced, while
scalability refers to the system’s ability to handle increasing loads.
Dependency Configuration (Maven)
The project uses Maven for dependency management.
Key dependencies include:
Spring Shell → enables CLI interaction
Spring Boot Starter JDBC → simplifies database access
MySQL Connector → connects Java to MySQL
Lombok → reduces boilerplate code
These dependencies are defined in the pom.xml file.
<dependencies>
<!-- Spring Boot JDBC Starter -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Spring Shell for CLI interaction -->
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter</artifactId>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok for reducing boilerplate code -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Testing Dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/expense
spring.datasource.username=*****
spring.datasource.password=*****
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
Project Structure
src/
└── main/
├── java/
│ └── myclexpensetracker/
│ ├── controller/
│ │ └── ExpenseController.java
│ ├── model/
│ │ └── Expense.java
│ ├── repository/
│ │ └── ExpenseRepository.java
│ ├── service/
│ │ ├── ExpenseService.java
│ │ └── ExpenseServiceInterface.java
│ ├── utilities/
│ │ ├── Category.java
│ │ └── Listformat.java
│ └── MyClExpenseTrackerApplication.java
└── resources/
└── application.properties
Core Data Model
The application revolves around a single core entity: Expense.
This model represents an expense record and is used throughout the system.
public class Expense {
private int id;
private double amount;
private String description;
private String category;
private LocalDate date;
private LocalDateTime createdAt;
}
The Repository Layer
The Repository layer is responsible for interacting directly with the database using JDBC.
It uses JdbcTemplate provided by Spring to execute SQL queries and map results to Java objects.
Key Responsibilities
- Saving expenses to the database
- Retrieving data (all, by ID, category, date, etc.)
- Updating and deleting records
- Running aggregate queries (e.g., total spending)
Example Operations
//Get All Expenses
public List<Expense> findAll();
//Get Expense by ID
public Expense findById(int id);
//Get Expenses by Category
public List<Expense> findByCategory(String category);
//Get Expenses by Date
public List<Expense> findByDate(LocalDate date);
//Get Expenses by Date Range
public List<Expense> findByDateRange(LocalDate startDate, LocalDate endDate);
//Update Expense
public void update(int id, String description, String category);
//Delete Expense
public void deleteById(int id);
//Total Spending
public Double getTotalSpending();
// Spending by Category
public List<Map<String, Object>> getSpendingByCategory();
JDBC Queries & Prepared Statements
The application uses Spring's JdbcTemplate, which internally uses Prepared Statements to safely execute SQL queries.
Prepared statements help to:
- Prevent SQL Injection
- Improve performance (queries are precompiled)
- Safely insert dynamic values into SQL
➤ Insert Query (Save Expense)
jdbc.update(
"INSERT INTO expense(amount, description, category, date, createdAt) VALUES (?, ?, ?, ?, ?)",
expense.getAmount(),
expense.getDescription(),
expense.getCategory(),
expense.getDate(),
expense.getCreatedAt()
);
Expense Repository
@Repository
@RequiredArgsConstructor
public class ExpenseRepository {
private final JdbcTemplate jdbc;
// ===================== SAVE =====================
public void save(Expense expense) {
jdbc.update(
"INSERT INTO expense(amount, description, category, date, createdAt) VALUES (?, ?, ?, ?, ?)",
expense.getAmount(),
expense.getDescription(),
expense.getCategory().name(),
expense.getDate(),
expense.getCreatedAt()
);
}
// ===================== FIND ALL =====================
public List<Expense> findAll() {
return jdbc.query("SELECT * FROM expense", (rs, rowNum) -> {
Expense expense = new Expense();
expense.setId(rs.getInt("id"));
expense.setAmount(rs.getDouble("amount"));
expense.setDescription(rs.getString("description"));
expense.setCategory(Category.valueOf(rs.getString("category").toUpperCase()));
expense.setDate(rs.getDate("date").toLocalDate());
expense.setCreatedAt(rs.getTimestamp("createdAt").toLocalDateTime());
return expense;
});
}
// ===================== FIND BY ID =====================
public Expense findById(int id) {
return jdbc.queryForObject(
"SELECT * FROM expense WHERE id = ?",
(rs, rowNum) -> new Expense(
rs.getInt("id"),
rs.getDouble("amount"),
rs.getString("description"),
Category.valueOf(rs.getString("category").toUpperCase()),
rs.getDate("date").toLocalDate(),
rs.getTimestamp("createdAt").toLocalDateTime()
),
id
);
}
// ===================== FIND BY CATEGORY =====================
public List<Expense> findByCategory(String category) {
return jdbc.query(
"SELECT * FROM expense WHERE category = ?",
(rs, rowNum) -> new Expense(
rs.getInt("id"),
rs.getDouble("amount"),
rs.getString("description"),
Category.valueOf(rs.getString("category").toUpperCase()),
rs.getDate("date").toLocalDate(),
rs.getTimestamp("createdAt").toLocalDateTime()
),
category
);
}
// ===================== FIND BY DATE =====================
public List<Expense> findByDate(LocalDate date) {
return jdbc.query(
"SELECT * FROM expense WHERE date = ?",
(rs, rowNum) -> new Expense(
rs.getInt("id"),
rs.getDouble("amount"),
rs.getString("description"),
Category.valueOf(rs.getString("category").toUpperCase()),
rs.getDate("date").toLocalDate(),
rs.getTimestamp("createdAt").toLocalDateTime()
),
date
);
}
// ===================== FIND BY DATE RANGE =====================
public List<Expense> findByDateRange(LocalDate startDate, LocalDate endDate) {
return jdbc.query(
"SELECT * FROM expense WHERE date BETWEEN ? AND ?",
(rs, rowNum) -> new Expense(
rs.getInt("id"),
rs.getDouble("amount"),
rs.getString("description"),
Category.valueOf(rs.getString("category").toUpperCase()),
rs.getDate("date").toLocalDate(),
rs.getTimestamp("createdAt").toLocalDateTime()
),
startDate,
endDate
);
}
// ===================== UPDATE =====================
public void update(int id, String description, String category) {
jdbc.update(
"UPDATE expense SET description = ?, category = ? WHERE id = ?",
description,
Category.valueOf(category.toUpperCase()).name(),
id
);
}
// ===================== DELETE =====================
public void deleteById(int id) {
jdbc.update("DELETE FROM expense WHERE id = ?", id);
}
// ===================== TOTAL SPENDING =====================
public Double getTotalSpending() {
Double total = jdbc.queryForObject(
"SELECT SUM(amount) FROM expense",
Double.class
);
return (total != null) ? total : 0.0;
}
// ===================== SPENDING BY CATEGORY =====================
public List<Map<String, Object>> getSpendingByCategory() {
return jdbc.queryForList(
"SELECT category, SUM(amount) AS total FROM expense GROUP BY category"
);
}
}
Service Layer (Business Logic)
The Service layer is responsible for handling the core business logic of the application.
It acts as a bridge between the Controller and the Repository.
Responsibilities
- Validating user input
- Enforcing business rules
- Handling exceptions
- Coordinating data flow between layers
📌 Service Implementation
@Service
@RequiredArgsConstructor
public class ExpenseService implements ExpenseServiceInterface {
private final ExpenseRepository repository;
public Expense getExpenseById(int id) {
if (id <= 0) {
throw new IllegalArgumentException("Invalid expense ID");
}
try {
return repository.findById(id);
} catch (Exception e) {
throw new RuntimeException("Expense with ID " + id + " not found");
}
}
public List<Expense> getAllExpenses() {
List<Expense> expenseList = repository.findAll();
if (expenseList.isEmpty()) {
throw new RuntimeException("Expense list is empty");
}
return expenseList;
}
public void addExpense(Double amount, String category, String description) {
Expense expense = new Expense();
if (amount <= 0) {
throw new IllegalArgumentException("Amount must be greater than 0");
}
if (category == null || category.isEmpty()) {
throw new IllegalArgumentException("Category cannot be empty");
}
if (description == null || description.isEmpty()) {
throw new IllegalArgumentException("Description cannot be empty");
}
expense.setAmount(amount);
expense.setCategory(category);
expense.setDescription(description);
expense.setCreatedAt(LocalDateTime.now());
expense.setDate(LocalDate.now());
repository.save(expense);
}
public List<Expense> getByCategory(String category) {
if (category == null || category.isEmpty()) {
throw new IllegalArgumentException("Category cannot be empty");
}
List<Expense> expenseList = repository.findByCategory(category);
if (expenseList.isEmpty()) {
throw new RuntimeException("Expense list is empty");
}
return expenseList;
}
public List<Expense> getByDate(LocalDate date) {
if (date == null) {
throw new IllegalArgumentException("Date cannot be null");
}
try {
return repository.findByDate(date);
} catch (Exception e) {
throw new RuntimeException("No expenses found for date: " + date);
}
}
public List<Expense> getByDateRange(LocalDate startDate, LocalDate endDate) {
if (startDate == null || endDate == null) {
throw new IllegalArgumentException("Start date and end date cannot be null");
}
if (startDate.isAfter(endDate)) {
throw new IllegalArgumentException("Start date cannot be after end date");
}
try {
return repository.findByDateRange(startDate, endDate);
} catch (Exception e) {
throw new RuntimeException("No Expenses Available for the provided Age range");
}
}
public List<Map<String, Object>> getSpendingByCategory() {
try {
return repository.getSpendingByCategory();
} catch (Exception e) {
throw new RuntimeException("No Spending found");
}
}
}
Controller Layer (CLI Interaction)
The Controller layer handles user interaction through the command line using Spring Shell.
It receives commands from the user and delegates them to the Service layer.
📌 Controller Implementation
@ShellComponent
@RequiredArgsConstructor
public class ExpenseController {
private final ExpenseService service;
@ShellMethod(value="Add a new expense", key = "add")
public String addExpense(double amount, String category, String description) {
service.addExpense(amount, category, description);
return "Expense added successfully";
}
@ShellMethod(value = "View all expenses", key = "view all")
public List<Expense> list() {
return service.getAllExpenses();
}
@ShellMethod(value= "Get expense by ID", key= "getById")
public Expense get(int id) {
return service.getExpenseById(id);
}
@ShellMethod("Search expenses by category")
public List<Expense> category(String category) {
return service.getByCategory(category);
}
@ShellMethod("Search expenses by date (YYYY-MM-DD)")
public List<Expense> date(String date) {
return service.getByDate(LocalDate.parse(date));
}
@ShellMethod("Search expenses by date range")
public List<Expense> range(String start, String end) {
return service.getByDateRange(
LocalDate.parse(start),
LocalDate.parse(end)
);
}
@ShellMethod("View spending summary by category")
public List<Map<String, Object>> summary() {
return service.getSpendingByCategory();
}
}
🧾 Expense Tracker Utility Documentation
This document explains the utility classes used in the Expense Tracker CLI application built with Spring Boot and Spring Shell.
📦 1. Listformat Utility Class
📌 Overview
The Listformat class is a utility/formatter component responsible for converting Expense objects and related data structures into readable CLI-friendly output.
It ensures that all responses displayed in the terminal are:
- Clean
- Consistent
- Easy to read
- Structured (not raw object dumps)
🧱 Class Definition
@Component
public class Listformat
// Format a single expense
public String formatExpense(Expense e) {
return """
------------------------
ID: %d
Amount: %.2f
Category: %s
Description: %s
Date: %s
Created At: %s
------------------------
""".formatted(
e.getId(),
e.getAmount(),
e.getCategory(),
e.getDescription(),
e.getDate(),
e.getCreatedAt()
);
}
// Format a list of expenses with a title
public String formatExpenseList(String title, List<Expense> expenses) {
if (expenses == null || expenses.isEmpty()) {
return "\n========== " + title.toUpperCase() + " ==========\n\nNo expenses found.";
}
StringBuilder sb = new StringBuilder();
sb.append("\n========== ")
.append(title.toUpperCase())
.append(" ==========\n\n");
for (Expense e : expenses) {
sb.append(formatExpense(e)).append("\n");
}
return sb.toString();
}
// Format spending summary (category totals)
public String formatSummary(List<Map<String, Object>> summary) {
if (summary == null || summary.isEmpty()) {
return "No summary data found.";
}
StringBuilder sb = new StringBuilder();
sb.append("\n========== SPENDING SUMMARY ==========\n\n");
for (Map<String, Object> row : summary) {
sb.append("Category: ").append(row.get("category")).append("\n")
.append("Total: ").append(row.get("total")).append("\n")
.append("------------------------\n");
}
return sb.toString();
}
Format a Single Expense(Example)
------------------------
ID: 1
Amount: 5000.00
Category: FOOD
Description: Jollof rice
Date: 2026-03-31
Created At: 2026-03-31T15:49:06
------------------------
Category Enum Documentation
Overview
The Category enum defines a fixed set of allowed expense categories in the Expense Tracker application. It is used to standardize how expenses are grouped, filtered, and stored across the system.
By using an enum instead of raw strings, the application ensures data consistency, type safety, and easier maintenance.
Enum Definition
package myclexpensetracker.utilities;
public enum Category {
FOOD,
TRANSPORT,
UTILITIES,
ENTERTAINMENT,
HEALTH,
EDUCATION,
OTHER
}
Spring Shell Controller Testing Guide
# ➕ Add Expense
add 323 FOOD "adsfdkfnajdf;"
# 📄 View All Expenses
view all
# 🔍 Get Expense by ID
getById 1
# 🏷️ Search by Category
category FOOD
# 📅 Search by Date
date 2026-03-31
# 📆 Search by Date Range
range 2026-03-01 2026-03-31
# 📊 Summary
summary
Top comments (0)