Lessons
1 · Java Foundations
2 · Object-Oriented Java
3 · Core Java
4 · Collections and Generics
5 · Functional Java
6 · The JVM and Concurrency
7 · Tools and Craft
- 21File I/O and Serialization
- 22Debugging: Errors, Stack Traces, the Debugger and Logging
- 23Maven: What It Does and How to Use It
- 24Git for Java Projects: Commits, Branches, Merging and Pull Requests
- 25Testing with JUnit and Mockito: Unit Tests, Mocks and Their Limits
- 26Project 1: Build an Expense Tracker CLI in Plain Java
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
- 36Security Fundamentals and Spring Security: Passwords, JWT, Access Control, CORS and Secrets
- 37Testing Spring Boot Applications: Slice Tests, MockMvc, Real Databases and the Test Pyramid
- 38Production Practices: Configuration, Logging, Docker and Health Checks for Spring Boot
- 39Project 3: The Job Application Tracker API, Assembled and Reviewed
Lesson 29 · Databases and JDBC
Project 2: Move the Expense Tracker to PostgreSQL with JDBC
Take the Expense Tracker CLI and add a PostgreSQL repository behind the same interface: an idempotent schema, a transactional bulk import, a connection pool, one shared contract test for every implementation, a fat jar, and an honest review.
For
Learners who finished Project 1 and the SQL and JDBC articles and want to see a storage layer replaced without touching the rest of the program.
Level
Before you start
Project 1 (article 26), SQL and PostgreSQL fundamentals (article 27) and JDBC (article 28). Verified on JDK 21 and JDK 25 with PostgreSQL 18.6, PostgreSQL JDBC driver 42.7.13 and HikariCP 7.1.0.
On this page
Key takeaways
- Because the service and the CLI depend only on the ExpenseRepository interface, replacing the CSV file with PostgreSQL meant adding one class and changing a few lines in Main. That is what the interface was for.
- A contract test is an abstract test class holding the behaviour every implementation must have. The in-memory, CSV and PostgreSQL repositories all run the same tests, so they cannot drift apart.
- A multi-row operation must be a transaction. The bulk import stores all rows or none, and a test proves that a failure on the second row also undoes the first.
- Configuration, including the password, comes from the environment. The schema is applied idempotently at start-up, which is fine for a small tool and is replaced by migrations in a real system.
- Reviewing your own work found a real defect, a category in the database that the code does not know crashed the program with a stack trace, and the fix came with a test.
In Project 1 you built an expense tracker that stores its data in a CSV file, and its review listed the weaknesses: every command rewrites the whole file, two programs can overwrite each other's changes, and ids can be reused. You then learned SQL (article 27) and JDBC (article 28). In this project you apply them: the same program, with its data in PostgreSQL.
The point of the exercise is as much about what you do not change. The domain, the service rules and the command line stay almost exactly as they were, because they never knew where expenses were stored. If that holds, the design from Project 1 was right.
Everything here was built and run: the project's 64 tests pass on JDK 21 and JDK 25 (including tests against a real PostgreSQL 18.6 server), and the sample session is real output from the packaged jar.
What you will learn
#- How to add a new storage implementation behind an existing interface.
- How to apply a schema safely and repeatedly, and why that is only a first step.
- How to write one contract test for several implementations, including a real database.
- How to make a multi-row operation atomic, and prove it.
- How to package a program with its dependencies, and review your own design.
Requirements for version 2
#All of version 1's requirements (R1 to R7) still hold. New ones:
# | The program must |
|---|---|
R8 | Store the data in PostgreSQL when a database is configured, and keep working with the CSV file when it is not |
R9 | Never lose an update when two copies of the program add expenses at the same time (this is a database guarantee, not something the program has to code) |
R10 | Create its tables itself if they are missing, and be safe to start many times |
R11 | Behave identically whichever repository is used, enforced by tests |
R12 | Import a version 1 CSV file into the database all or nothing |
R13 | Take the database address, user and password from the environment, never from the code |
R14 | Report an unreachable or broken database with a clear message and exit code 2, without a stack trace |
The design: what changes and what does not
# command line | v +---------+ +----------------+ +--------------------+ | Cli | ---> | ExpenseService | ---> | ExpenseRepository | (interface, one new method) +---------+ +----------------+ +--------------------+ +import +importAll ^ ^ ^ | | | CsvExpenseRepository InMemory... JdbcExpenseRepository <- NEW | DataSource (HikariCP) -> PostgreSQL Main chooses the repository from the environmentFile | Status |
|---|---|
| Unchanged |
| One new method, |
| Implement |
| A new |
| Chooses CSV or PostgreSQL |
| New |
| New resources |
| New dependencies, and a plugin that builds a fat jar |
Tests | One shared contract test, run against three implementations |
Decisions, and the reasoning:
- A separate
StorageException. ASQLExceptionis a checked exception that belongs to JDBC. The repository catches it and throws its own uncheckedStorageException, so no other class needs to know that JDBC exists (article 10). The CLI treats it like the CSV storage problems: exit code 2, no stack trace. addAllis part of the interface contract. "All or nothing" is a rule about behaviour, so it is written in the interface's documentation and enforced by a test for the real database.- The database does the heavy lifting that Java did before. There is no read-modify-write cycle in the program any more:
INSERTwith a generated id is a single statement, so two programs cannot overwrite each other (R9). - Categories exist in two places, the Java enum and the
categoriestable, because a foreign key needs rows. The start-up code makes the table follow the enum, so the enum remains the single source of truth. - The schema is applied at start-up with
IF NOT EXISTS. This is acceptable for a small tool and wrong for a real system, where changes need versioned migrations. The review below says so.
Milestone 1: the build
#Copy Project 1 into a new folder and update the pom.xml. The changes: the PostgreSQL driver and HikariCP, a logging backend, an embedded PostgreSQL for the tests, and the Shade plugin to build one jar containing everything. The pom.xml diff against version 1 is:
--- a/pom.xml+++ b/pom.xml@@ -7,5 +7,5 @@ <groupId>com.example</groupId> <artifactId>expense-tracker</artifactId>- <version>1.0.0</version>+ <version>2.0.0</version> <packaging>jar</packaging> @@ -14,11 +14,49 @@ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <junit.version>6.1.3</junit.version>+ <postgres.binaries.version>18.6.0</postgres.binaries.version> </properties> + <dependencyManagement>+ <dependencies>+ <!-- Makes the embedded test database a PostgreSQL 18 server -->+ <dependency>+ <groupId>io.zonky.test.postgres</groupId>+ <artifactId>embedded-postgres-binaries-bom</artifactId>+ <version>${postgres.binaries.version}</version>+ <type>pom</type>+ <scope>import</scope>+ </dependency>+ </dependencies>+ </dependencyManagement>+ <dependencies>+ <dependency>+ <groupId>org.postgresql</groupId>+ <artifactId>postgresql</artifactId>+ <version>42.7.13</version>+ <scope>runtime</scope>+ </dependency>+ <dependency>+ <groupId>com.zaxxer</groupId>+ <artifactId>HikariCP</artifactId>+ <version>7.1.0</version>+ </dependency>+ <dependency>+ <groupId>org.slf4j</groupId>+ <artifactId>slf4j-simple</artifactId>+ <version>2.0.18</version>+ <scope>runtime</scope>+ </dependency>+ <dependency> <groupId>org.junit.jupiter</groupId> <artifactId>junit-jupiter</artifactId> <version>${junit.version}</version>+ <scope>test</scope>+ </dependency>+ <dependency>+ <groupId>io.zonky.test</groupId>+ <artifactId>embedded-postgres</artifactId>+ <version>2.2.2</version> <scope>test</scope> </dependency>@@ -34,13 +72,35 @@ <plugin> <groupId>org.apache.maven.plugins</groupId>- <artifactId>maven-jar-plugin</artifactId>- <version>3.4.2</version>- <configuration>- <archive>- <manifest>- <mainClass>com.example.expenses.Main</mainClass>- </manifest>- </archive>- </configuration>+ <artifactId>maven-shade-plugin</artifactId>+ <version>3.6.0</version>+ <executions>+ <execution>+ <phase>package</phase>+ <goals>+ <goal>shade</goal>+ </goals>+ <configuration>+ <createDependencyReducedPom>false</createDependencyReducedPom>+ <transformers>+ <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">+ <mainClass>com.example.expenses.Main</mainClass>+ </transformer>+ <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>+ </transformers>+ <filters>+ <filter>+ <artifact>*:*</artifact>+ <excludes>+ <exclude>META-INF/*.SF</exclude>+ <exclude>META-INF/*.DSA</exclude>+ <exclude>META-INF/*.RSA</exclude>+ <exclude>module-info.class</exclude>+ <exclude>META-INF/versions/*/module-info.class</exclude>+ </excludes>+ </filter>+ </filters>+ </configuration>+ </execution>+ </executions> </plugin> </plugins>Three things to notice:
dependencyManagementwith a BOM. The embedded database library,io.zonky.test:embedded-postgres, brings default PostgreSQL binaries of an older major version. Importing its binaries BOM (a bill of materials, article 23) with scopeimportmakes every binary artifact use the version in the property, here 18.6.0, so tests run on the same major version as production. That is the BOM idea in practice.- Scopes. The driver is
runtime(the code uses onlyjava.sql), HikariCP iscompile(the code uses its classes), logging isruntime, and JUnit and the embedded database aretest. - The Shade plugin builds a fat jar, which solves the problem from article 23: a plain jar contains only your classes, so
java -jarwould fail withNoClassDefFoundError. The shaded jar is about 1.7 MB and runs on its own. Two settings matter: theServicesResourceTransformermerges the service files that JDBC uses to discover the driver, and the filters drop signature files that would otherwise make a merged jar invalid.
The embedded PostgreSQL library starts a real server from binaries on the classpath, without Docker. It depends on the Windows, macOS (Intel) and Linux x86-64 binaries by default. If your machine is a different platform, such as an Apple Silicon Mac, add the matching embedded-postgres-binaries-* artifact (not tested here, as this project was verified on Windows x86-64). Many teams run a real PostgreSQL in a Testcontainers container instead, which needs Docker, and a later article shows the Spring Boot way of doing so (the Docker route was not run in the environment where this course was verified).
Create the two small resource files. The schema is the one from article 27, written to be repeatable: IF NOT EXISTS makes each statement safe to run again.
CREATE TABLE IF NOT EXISTS categories ( id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE);CREATE TABLE IF NOT EXISTS expenses ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, spent_on date NOT NULL, amount_cents bigint NOT NULL CHECK (amount_cents > 0), category_id integer NOT NULL REFERENCES categories (id), description text NOT NULL DEFAULT '');CREATE INDEX IF NOT EXISTS idx_expenses_spent_on ON expenses (spent_on);The logging configuration keeps HikariCP's routine messages off the command line. Without it, every run would print pool start-up chatter before the real output:
org.slf4j.simpleLogger.defaultLogLevel=warnMilestone 2: the interface grows by one method
#The new method is the bulk import. Its Javadoc is the contract: the ids passed in are ignored, and the operation is atomic.
// File: src/main/java/com/example/expenses/ExpenseRepository.javapackage com.example.expenses;import java.time.LocalDate;import java.util.List;/** Where expenses are stored. The rest of the program depends only on this interface. */public interface ExpenseRepository { Expense add(LocalDate date, long amountCents, Category category, String description); /** * Stores copies of the given expenses, assigning new ids (the ids passed in are ignored). * It is all or nothing: if any expense cannot be stored, none is, and an exception is thrown. * * @return the number of expenses stored */ int addAll(List<Expense> expenses); /** Returns every expense in id order. */ List<Expense> findAll(); boolean deleteById(long id);}The two existing implementations get the method. For the in-memory one, adding to a map cannot fail half-way for valid expenses, so a simple loop is atomic enough:
--- a/src/main/java/com/example/expenses/InMemoryExpenseRepository.java+++ b/src/main/java/com/example/expenses/InMemoryExpenseRepository.java@@ -18,4 +18,12 @@ @Override+ public int addAll(List<Expense> expenses) {+ for (Expense e : expenses) {+ add(e.date(), e.amountCents(), e.category(), e.description());+ }+ return expenses.size();+ }++ @Override public List<Expense> findAll() { return new ArrayList<>(byId.values());For the CSV file the guarantee comes from the design of Project 1: store rewrites the file in one atomic move, so adding all rows in memory and storing once changes the file completely or not at all:
--- a/src/main/java/com/example/expenses/CsvExpenseRepository.java+++ b/src/main/java/com/example/expenses/CsvExpenseRepository.java@@ -29,4 +29,15 @@ store(all); return expense;+ }++ @Override+ public int addAll(List<Expense> expenses) {+ List<Expense> all = load();+ long nextId = all.stream().mapToLong(Expense::id).max().orElse(0) + 1;+ for (Expense e : expenses) {+ all.add(new Expense(nextId++, e.date(), e.amountCents(), e.category(), e.description()));+ }+ store(all); // one write: the file changes completely or not at all+ return expenses.size(); } Milestone 3: the PostgreSQL repository
#First, the exception. It is unchecked and keeps the original SQLException as its cause, so the detail is not lost:
// File: src/main/java/com/example/expenses/StorageException.javapackage com.example.expenses;/** A storage problem that is not the user's fault: the database is unreachable, the schema is wrong, and so on. */public class StorageException extends RuntimeException { public StorageException(String message, Throwable cause) { super(message, cause); }}Now the repository. Everything about SQL and JDBC stays in this one class. Read it in this order:
- The constructor takes a
DataSource(an interface, article 28), not a pool class and not a URL. The repository does not care whether connections come from a pool, from the test database, or from a framework. addusesRETURNING idso that it learns the generated id in the same statement, and each method uses try-with-resources so connections go back to the pool.addAllis the transaction pattern from article 28: turn autocommit off, batch the inserts, commit, and on failure roll back and rethrow. Infinallyit restores autocommit.findAllmaps rows toExpenserecords.toCategoryhandles a case the first version of this class got wrong, explained in the review below.- Every
SQLExceptionbecomes aStorageExceptionwith a message a human can read.
// File: src/main/java/com/example/expenses/JdbcExpenseRepository.javapackage com.example.expenses;import java.sql.Connection;import java.sql.PreparedStatement;import java.sql.ResultSet;import java.sql.SQLException;import java.time.LocalDate;import java.util.ArrayList;import java.util.List;import javax.sql.DataSource;/** Stores expenses in PostgreSQL. All SQL and all JDBC types stay inside this class. */public class JdbcExpenseRepository implements ExpenseRepository { private static final String INSERT = """ INSERT INTO expenses (spent_on, amount_cents, category_id, description) VALUES (?, ?, (SELECT id FROM categories WHERE name = ?), ?) """; private final DataSource dataSource; public JdbcExpenseRepository(DataSource dataSource) { this.dataSource = dataSource; } @Override public Expense add(LocalDate date, long amountCents, Category category, String description) { try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(INSERT + "RETURNING id")) { bind(statement, date, amountCents, category, description); try (ResultSet rs = statement.executeQuery()) { rs.next(); return new Expense(rs.getLong(1), date, amountCents, category, description); } } catch (SQLException e) { throw new StorageException("could not save the expense", e); } } @Override public int addAll(List<Expense> expenses) { try (Connection connection = dataSource.getConnection()) { boolean originalAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); // one transaction for the whole list try (PreparedStatement statement = connection.prepareStatement(INSERT)) { for (Expense e : expenses) { bind(statement, e.date(), e.amountCents(), e.category(), e.description()); statement.addBatch(); } statement.executeBatch(); connection.commit(); return expenses.size(); } catch (SQLException e) { connection.rollback(); // nothing from this list is kept throw e; } finally { connection.setAutoCommit(originalAutoCommit); } } catch (SQLException e) { throw new StorageException("could not save the expenses, none were stored", e); } } @Override public List<Expense> findAll() { String sql = """ SELECT e.id, e.spent_on, e.amount_cents, c.name, e.description FROM expenses e JOIN categories c ON c.id = e.category_id ORDER BY e.id """; try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement(sql); ResultSet rs = statement.executeQuery()) { List<Expense> result = new ArrayList<>(); while (rs.next()) { result.add(new Expense( rs.getLong("id"), rs.getObject("spent_on", LocalDate.class), rs.getLong("amount_cents"), toCategory(rs.getString("name")), rs.getString("description"))); } return result; } catch (SQLException e) { throw new StorageException("could not read the expenses", e); } } @Override public boolean deleteById(long id) { try (Connection connection = dataSource.getConnection(); PreparedStatement statement = connection.prepareStatement("DELETE FROM expenses WHERE id = ?")) { statement.setLong(1, id); return statement.executeUpdate() > 0; } catch (SQLException e) { throw new StorageException("could not delete the expense", e); } } private static Category toCategory(String name) { try { return Category.valueOf(name); } catch (IllegalArgumentException e) { throw new StorageException("the database contains a category this version does not know: " + name, e); } } private static void bind(PreparedStatement statement, LocalDate date, long amountCents, Category category, String description) throws SQLException { statement.setObject(1, date); statement.setLong(2, amountCents); statement.setString(3, category.name()); statement.setString(4, description); }}The Schema class applies schema.sql and then makes the categories table follow the enum. It runs everything in one transaction. ON CONFLICT (name) DO NOTHING is PostgreSQL's way of saying "insert unless it is already there", which makes the seeding repeatable:
// File: src/main/java/com/example/expenses/Schema.javapackage com.example.expenses;import java.io.IOException;import java.io.InputStream;import java.io.UncheckedIOException;import java.nio.charset.StandardCharsets;import java.sql.Connection;import java.sql.PreparedStatement;import java.sql.SQLException;import java.sql.Statement;import javax.sql.DataSource;/** Creates the tables if they are missing and makes sure every Category has a row. Safe to run repeatedly. */public final class Schema { private Schema() { } public static void apply(DataSource dataSource) { String script = readScript(); try (Connection connection = dataSource.getConnection()) { boolean originalAutoCommit = connection.getAutoCommit(); connection.setAutoCommit(false); try { try (Statement statement = connection.createStatement()) { for (String sql : script.split(";")) { if (!sql.isBlank()) { statement.execute(sql); } } } try (PreparedStatement insert = connection.prepareStatement( "INSERT INTO categories (name) VALUES (?) ON CONFLICT (name) DO NOTHING")) { for (Category category : Category.values()) { insert.setString(1, category.name()); insert.addBatch(); } insert.executeBatch(); } connection.commit(); } catch (SQLException e) { connection.rollback(); throw e; } finally { connection.setAutoCommit(originalAutoCommit); } } catch (SQLException e) { throw new StorageException("could not prepare the database schema", e); } } private static String readScript() { try (InputStream in = Schema.class.getResourceAsStream("/schema.sql")) { if (in == null) { throw new IllegalStateException("schema.sql is missing from the application"); } return new String(in.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); } }}Milestone 4: one contract, three implementations
#How do you know that the PostgreSQL repository behaves like the CSV one? You do not write a second set of tests that you hope match. You write one abstract test class describing what any ExpenseRepository must do, and every implementation extends it. This is a contract test.
// File: src/test/java/com/example/expenses/ExpenseRepositoryContract.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertFalse;import static org.junit.jupiter.api.Assertions.assertTrue;import java.time.LocalDate;import java.util.List;import org.junit.jupiter.api.BeforeEach;import org.junit.jupiter.api.Test;/** * The behaviour every ExpenseRepository must have. Each implementation's test class extends this one, * so all implementations are held to the same rules. */abstract class ExpenseRepositoryContract { protected ExpenseRepository repository; /** Returns a new, empty repository. */ protected abstract ExpenseRepository newRepository(); @BeforeEach void createRepository() { repository = newRepository(); } @Test void aNewRepositoryIsEmpty() { assertTrue(repository.findAll().isEmpty()); } @Test void addReturnsTheStoredExpenseWithAnId() { Expense saved = repository.add(LocalDate.of(2026, 3, 1), 1250, Category.FOOD, "lunch"); assertTrue(saved.id() > 0); assertEquals(LocalDate.of(2026, 3, 1), saved.date()); assertEquals(1250, saved.amountCents()); assertEquals(Category.FOOD, saved.category()); assertEquals("lunch", saved.description()); } @Test void idsIncreaseAndFindAllReturnsExpensesInIdOrder() { Expense first = repository.add(LocalDate.of(2026, 3, 5), 100, Category.FOOD, "first"); Expense second = repository.add(LocalDate.of(2026, 3, 1), 200, Category.HOUSING, "second"); assertTrue(second.id() > first.id()); assertEquals(List.of(first, second), repository.findAll()); } @Test void textSurvivesAStoreAndReload() { String tricky = "café, \"quoted\" and 100% fine"; repository.add(LocalDate.of(2026, 3, 1), 100, Category.OTHER, tricky); assertEquals(tricky, repository.findAll().get(0).description()); } @Test void deleteRemovesOnlyTheNamedExpense() { Expense first = repository.add(LocalDate.of(2026, 3, 1), 100, Category.FOOD, "a"); Expense second = repository.add(LocalDate.of(2026, 3, 2), 200, Category.FOOD, "b"); assertTrue(repository.deleteById(first.id())); assertEquals(List.of(second), repository.findAll()); } @Test void deletingAnUnknownIdReturnsFalse() { assertFalse(repository.deleteById(12345)); } @Test void addAllStoresCopiesWithNewIds() { List<Expense> source = List.of( new Expense(900, LocalDate.of(2026, 1, 1), 100, Category.FOOD, "x"), new Expense(901, LocalDate.of(2026, 1, 2), 200, Category.HEALTH, "y")); int stored = repository.addAll(source); List<Expense> all = repository.findAll(); assertEquals(2, stored); assertEquals(List.of("x", "y"), all.stream().map(Expense::description).toList()); assertEquals(List.of(Category.FOOD, Category.HEALTH), all.stream().map(Expense::category).toList()); assertTrue(all.stream().noneMatch(e -> e.id() >= 900), "the ids passed in must be ignored"); }}The abstract method newRepository() is the only thing each implementation provides. The in-memory repository is trivial:
// File: src/test/java/com/example/expenses/InMemoryExpenseRepositoryTest.javapackage com.example.expenses;class InMemoryExpenseRepositoryTest extends ExpenseRepositoryContract { @Override protected ExpenseRepository newRepository() { return new InMemoryExpenseRepository(); }}The CSV test class now inherits the seven contract tests, and keeps only the tests that are specific to files (survival across instances, folder creation, damaged files, the CSV splitter). JUnit creates a new test class instance for each test, and the @TempDir folder is injected before the inherited @BeforeEach runs:
// File: src/test/java/com/example/expenses/CsvExpenseRepositoryTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import java.io.IOException;import java.nio.file.Files;import java.nio.file.Path;import java.time.LocalDate;import java.util.List;import org.junit.jupiter.api.Test;import org.junit.jupiter.api.io.TempDir;class CsvExpenseRepositoryTest extends ExpenseRepositoryContract { @TempDir Path directory; @Override protected ExpenseRepository newRepository() { return new CsvExpenseRepository(directory.resolve("expenses.csv")); } @Test void dataSurvivesANewRepositoryInstance() { Path file = directory.resolve("survive.csv"); new CsvExpenseRepository(file).add(LocalDate.of(2026, 3, 1), 1250, Category.FOOD, "lunch"); List<Expense> loaded = new CsvExpenseRepository(file).findAll(); assertEquals(List.of(new Expense(1, LocalDate.of(2026, 3, 1), 1250, Category.FOOD, "lunch")), loaded); } @Test void missingFileMeansNoExpensesAndTheFileAndFolderAreCreatedOnFirstAdd() { Path file = directory.resolve("nested").resolve("expenses.csv"); CsvExpenseRepository repository = new CsvExpenseRepository(file); assertTrue(repository.findAll().isEmpty()); repository.add(LocalDate.of(2026, 3, 1), 100, Category.OTHER, ""); assertTrue(Files.exists(file)); } @Test void aDamagedFileIsReportedWithItsLineNumber() throws IOException { Path file = directory.resolve("damaged.csv"); Files.writeString(file, CsvExpenseRepository.HEADER + "\n1,2026-03-01,not-a-number,FOOD,lunch\n"); IllegalStateException e = assertThrows(IllegalStateException.class, () -> new CsvExpenseRepository(file).findAll()); assertTrue(e.getMessage().contains("damaged at line 2"), e.getMessage()); } @Test void splitsQuotedFieldsCorrectly() { assertEquals(List.of("1", "a,b", "say \"hi\"", ""), CsvExpenseRepository.splitCsv("1,\"a,b\",\"say \"\"hi\"\"\",")); }}The PostgreSQL test class does the same, with a real server. It starts the embedded server once for the class, applies the schema, and before each test empties the table and resets the identity counter, so every test starts from a known state. Then it adds the tests that only make sense for a database:
- the schema can be applied twice (R10);
- a failure in the middle of
addAllstores nothing, tested by making the second row fail (theOTHERcategory's row is deleted so the second insert violatesNOT NULL) and checking that even the valid first row is gone (R12); - a database problem becomes a
StorageException(R14); - a category the code does not know is reported cleanly. This last test was added after a review found a defect, described below.
// File: src/test/java/com/example/expenses/JdbcExpenseRepositoryTest.javapackage com.example.expenses;import static org.junit.jupiter.api.Assertions.assertEquals;import static org.junit.jupiter.api.Assertions.assertThrows;import static org.junit.jupiter.api.Assertions.assertTrue;import io.zonky.test.db.postgres.embedded.EmbeddedPostgres;import java.io.IOException;import java.sql.Connection;import java.sql.SQLException;import java.sql.Statement;import java.time.LocalDate;import java.util.List;import javax.sql.DataSource;import org.junit.jupiter.api.AfterAll;import org.junit.jupiter.api.BeforeAll;import org.junit.jupiter.api.Test;/** Runs the repository contract against a real PostgreSQL server started for the duration of the test class. */class JdbcExpenseRepositoryTest extends ExpenseRepositoryContract { private static EmbeddedPostgres postgres; private static DataSource dataSource; @BeforeAll static void startDatabase() throws IOException { postgres = EmbeddedPostgres.start(); dataSource = postgres.getPostgresDatabase(); Schema.apply(dataSource); } @AfterAll static void stopDatabase() throws IOException { postgres.close(); } @Override protected ExpenseRepository newRepository() { execute("TRUNCATE expenses RESTART IDENTITY"); execute("INSERT INTO categories (name) SELECT 'OTHER' WHERE NOT EXISTS " + "(SELECT 1 FROM categories WHERE name = 'OTHER')"); return new JdbcExpenseRepository(dataSource); } private static void execute(String sql) { try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { statement.execute(sql); } catch (SQLException e) { throw new IllegalStateException(e); } } @Test void applyingTheSchemaTwiceChangesNothing() { Schema.apply(dataSource); Schema.apply(dataSource); repository.add(LocalDate.of(2026, 3, 1), 100, Category.FOOD, "still works"); assertEquals(1, repository.findAll().size()); } @Test void addAllStoresNothingWhenOneRowFails() { execute("DELETE FROM categories WHERE name = 'OTHER'"); // reference data is missing List<Expense> source = List.of( new Expense(1, LocalDate.of(2026, 1, 1), 100, Category.FOOD, "fine"), new Expense(2, LocalDate.of(2026, 1, 2), 200, Category.OTHER, "cannot be stored")); StorageException e = assertThrows(StorageException.class, () -> repository.addAll(source)); assertTrue(e.getCause() instanceof SQLException, "the cause should be the SQLException"); assertTrue(repository.findAll().isEmpty(), "the valid row must have been rolled back too"); } @Test void aCategoryTheCodeDoesNotKnowIsReportedNotCrashed() { execute("INSERT INTO categories (name) VALUES ('LEGACY')"); execute("INSERT INTO expenses (spent_on, amount_cents, category_id, description) " + "SELECT DATE '2026-03-01', 100, id, 'old' FROM categories WHERE name = 'LEGACY'"); StorageException e = assertThrows(StorageException.class, () -> repository.findAll()); assertTrue(e.getMessage().contains("LEGACY"), e.getMessage()); } @Test void aDatabaseProblemBecomesAStorageException() { execute("ALTER TABLE expenses RENAME TO expenses_old"); try { assertThrows(StorageException.class, () -> repository.findAll()); } finally { execute("ALTER TABLE expenses_old RENAME TO expenses"); } }}The result of running all the tests: Tests run: 64, Failures: 0, Errors: 0, Skipped: 0. The contract's seven tests run against each of the three implementations, which is 21 of those 64 tests. The PostgreSQL class takes around ten seconds, mostly starting the server, while the others take milliseconds. That difference is one reason to keep most logic testable without a database, as this design does.
Milestone 5: the service, the CLI and Main
#The service gets an importAll method. It checks the whole list for future dates before touching storage (rejecting bad input early), then hands it to the repository's atomic addAll:
--- a/src/main/java/com/example/expenses/ExpenseService.java+++ b/src/main/java/com/example/expenses/ExpenseService.java@@ -31,4 +31,15 @@ } + /** Copies expenses (for example from another store) into this one, all or nothing. */+ public int importAll(List<Expense> expenses) {+ LocalDate today = LocalDate.now(clock);+ for (Expense e : expenses) {+ if (e.date().isAfter(today)) {+ throw new InvalidInputException("expense #" + e.id() + " has a date in the future: " + e.date());+ }+ }+ return repository.addAll(expenses);+ }+ /** Lists expenses oldest first. A null month means all months. */ public List<Expense> list(YearMonth month) {The CLI gets an import <csv-file> command that reads a version 1 CSV file with the existing CsvExpenseRepository and passes its expenses to the service, and it now also treats StorageException as a storage problem (exit code 2):
--- a/src/main/java/com/example/expenses/Cli.java+++ b/src/main/java/com/example/expenses/Cli.java@@ -3,4 +3,6 @@ import java.io.PrintStream; import java.io.UncheckedIOException;+import java.nio.file.Files;+import java.nio.file.Path; import java.time.LocalDate; import java.time.YearMonth;@@ -20,4 +22,5 @@ expenses delete <id> expenses categories+ expenses import <csv-file> """; @@ -46,4 +49,5 @@ case "delete" -> delete(rest); case "categories" -> categories(rest);+ case "import" -> importCsv(rest); default -> { err.println("error: unknown command '" + args[0] + "'");@@ -56,5 +60,5 @@ err.println("error: " + e.getMessage()); return 1;- } catch (UncheckedIOException | IllegalStateException e) {+ } catch (UncheckedIOException | IllegalStateException | StorageException e) { err.println("error: " + e.getMessage()); return 2;@@ -128,4 +132,17 @@ } + private void importCsv(List<String> args) {+ if (args.size() != 1) {+ throw new InvalidInputException("import needs exactly one <csv-file>");+ }+ Path file = Path.of(args.get(0));+ if (!Files.isRegularFile(file)) {+ throw new InvalidInputException("no such file: " + file);+ }+ List<Expense> source = new CsvExpenseRepository(file).findAll();+ int count = service.importAll(source);+ out.println("Imported " + count + " expense(s) from " + file);+ }+ /** Removes "--name value" from the arguments and returns the value, or null if absent. */ private static String takeOption(List<String> args, String name) {The new tests for these two changes keep the earlier style: the service tests use the in-memory repository and a fixed clock, the CLI tests use a real CSV file in a temporary folder:
--- a/src/test/java/com/example/expenses/ExpenseServiceTest.java+++ b/src/test/java/com/example/expenses/ExpenseServiceTest.java@@ -77,4 +77,27 @@ @Test+ void importCopiesEveryExpenseAndAssignsNewIds() {+ List<Expense> source = List.of(+ new Expense(40, LocalDate.of(2026, 1, 5), 100, Category.FOOD, "a"),+ new Expense(41, LocalDate.of(2026, 1, 6), 200, Category.HEALTH, "b"));++ int imported = service.importAll(source);++ assertEquals(2, imported);+ assertEquals(List.of(1L, 2L), service.list(null).stream().map(Expense::id).toList());+ }++ @Test+ void importStoresNothingIfAnyExpenseIsDatedInTheFuture() {+ List<Expense> source = List.of(+ new Expense(1, LocalDate.of(2026, 1, 5), 100, Category.FOOD, "fine"),+ new Expense(2, LocalDate.of(2026, 3, 16), 200, Category.FOOD, "tomorrow"));++ assertThrows(InvalidInputException.class, () -> service.importAll(source));++ assertTrue(service.list(null).isEmpty());+ }++ @Test void deleteReportsWhetherAnythingWasRemoved() { Expense saved = service.add(450, Category.FOOD, "", null);--- a/src/test/java/com/example/expenses/CliTest.java+++ b/src/test/java/com/example/expenses/CliTest.java@@ -110,4 +110,24 @@ @Test+ void importCopiesExpensesFromAnotherCsvFile() {+ Path other = directory.resolve("other.csv");+ new CsvExpenseRepository(other).add(java.time.LocalDate.of(2026, 2, 1), 5000, Category.HOUSING, "deposit");+ new CsvExpenseRepository(other).add(java.time.LocalDate.of(2026, 2, 2), 250, Category.FOOD, "bread");++ assertEquals(0, run("import", other.toString()));+ assertEquals("Imported 2 expense(s) from " + other, out().get(0));++ outBytes.reset();+ run("summary");+ assertEquals(" TOTAL 52.50", out().get(out().size() - 1));+ }++ @Test+ void importOfAMissingFileFails() {+ assertEquals(1, run("import", directory.resolve("nope.csv").toString()));+ assertTrue(err().get(0).startsWith("error: no such file: "), err().toString());+ }++ @Test void noArgumentsPrintsTheUsage() { assertEquals(1, run());Finally Main, the composition root, is where the decision is made. If DB_URL is set, it builds a HikariCP pool from DB_URL, DB_USER and DB_PASSWORD, applies the schema, and uses the PostgreSQL repository. Otherwise it uses the CSV file exactly as in version 1. The pool is closed by try-with-resources before the program exits:
// File: src/main/java/com/example/expenses/Main.javapackage com.example.expenses;import com.zaxxer.hikari.HikariConfig;import com.zaxxer.hikari.HikariDataSource;import com.zaxxer.hikari.pool.HikariPool;import java.nio.file.Path;import java.time.Clock;/** * Chooses the store from the environment: PostgreSQL when DB_URL is set (with DB_USER and DB_PASSWORD), * otherwise the CSV file from version 1. */public class Main { public static void main(String[] args) { String dbUrl = System.getenv("DB_URL"); int exitCode; if (dbUrl == null || dbUrl.isBlank()) { Path file = dataFile(System.getenv("EXPENSE_TRACKER_FILE"), System.getProperty("user.home")); exitCode = run(new CsvExpenseRepository(file), args); } else { try (HikariDataSource pool = new HikariDataSource(poolConfig(dbUrl))) { Schema.apply(pool); exitCode = run(new JdbcExpenseRepository(pool), args); } catch (StorageException | HikariPool.PoolInitializationException e) { System.err.println("error: cannot use the database: " + rootMessage(e)); exitCode = 2; } } System.exit(exitCode); } private static int run(ExpenseRepository repository, String[] args) { ExpenseService service = new ExpenseService(repository, Clock.systemDefaultZone()); return new Cli(service, System.out, System.err).run(args); } private static HikariConfig poolConfig(String url) { HikariConfig config = new HikariConfig(); config.setJdbcUrl(url); config.setUsername(System.getenv("DB_USER")); config.setPassword(System.getenv("DB_PASSWORD")); config.setMaximumPoolSize(4); config.setConnectionTimeout(5000); config.setPoolName("expenses-pool"); return config; } private static String rootMessage(Throwable e) { Throwable root = e; while (root.getCause() != null) { root = root.getCause(); } return root.getMessage(); } /** The file named by EXPENSE_TRACKER_FILE, or ~/.expense-tracker/expenses.csv. */ static Path dataFile(String override, String home) { if (override != null && !override.isBlank()) { return Path.of(override); } return Path.of(home, ".expense-tracker", "expenses.csv"); }}This is the payoff of the design. Everything below Main is unchanged: Cli and ExpenseService do not know which repository they have.
Run it
#Build with mvn clean verify, then use the fat jar. To run against PostgreSQL, put the connection details in the environment. For example, in Bash (with your own password, never in a file you commit):
export DB_URL=jdbc:postgresql://localhost:5432/expensesexport DB_USER=expensesexport DB_PASSWORD='the-password-you-chose'alias expenses='java -jar target/expense-tracker-2.0.0.jar'The session below is real output from the packaged jar on JDK 21, run against a real PostgreSQL 18.6 server (the JDK 25 run was identical except for the last error message, discussed below). The first three lines use version 1 (the CSV store) to create an old data file; then version 2 imports that file into the database. In this log, (exit code N) is added after a command that failed, and the $ lines show the arguments:
# Version 1 still works with a file$ expenses add 12.50 food "lunch with Sam" --date 2026-09-03Added #1: 2026-09-03 12.50 FOOD$ expenses add 1200 housing "rent" --date 2026-09-01Added #2: 2026-09-01 1200.00 HOUSING$ expenses add 3.20 food "coffee, and a muffin" --date 2026-10-02Added #3: 2026-10-02 3.20 FOOD# Version 2 with PostgreSQL$ expenses listNo expenses found.$ expenses import old-expenses.csvImported 3 expense(s) from old-expenses.csv$ expenses add 45 transport "monthly metro pass" --date 2026-09-05Added #4: 2026-09-05 45.00 TRANSPORT$ expenses list ID DATE AMOUNT CATEGORY DESCRIPTION 2 2026-09-01 1200.00 HOUSING rent 1 2026-09-03 12.50 FOOD lunch with Sam 4 2026-09-05 45.00 TRANSPORT monthly metro pass 3 2026-10-02 3.20 FOOD coffee, and a muffin4 expense(s), total 1260.70$ expenses summary --month 2026-09Summary for 2026-09 FOOD 12.50 TRANSPORT 45.00 HOUSING 1200.00 TOTAL 1257.50$ expenses delete 1Deleted #1$ expenses delete 1error: no expense with id 1(exit code 1)$ expenses add 5 snackserror: unknown category 'snacks', choose one of [FOOD, TRANSPORT, HOUSING, UTILITIES, HEALTH, ENTERTAINMENT, OTHER](exit code 1)$ expenses import missing.csverror: no such file: missing.csv(exit code 1)# Running it again changes nothing in the schema$ expenses list --month 2026-10 ID DATE AMOUNT CATEGORY DESCRIPTION 3 2026-10-02 3.20 FOOD coffee, and a muffin1 expense(s), total 3.20# The database holds a category this version does not know(another program adds a category called LEGACY, and an expense that uses it)$ expenses listerror: the database contains a category this version does not know: LEGACY(exit code 2)# The database cannot be reached$ DB_URL=jdbc:postgresql://localhost:1/postgres expenses listerror: cannot use the database: Connection refused: no further information(exit code 2)What the session shows:
- R8 and R10: the first
listagainst the empty database prints "No expenses found." The tables did not exist before, and the program created them. - R12:
import old-expenses.csvcopied the three expenses. The imported rows got fresh database ids, and the nextaddcontinued with #4. The ids of the old file were not reused or required to match. - R11:
listandsummaryprint exactly what version 1 printed for the same data, because the service and CLI are the same code. - The error cases (unknown id, unknown category, missing file) behave as before, with exit code 1.
- R10 again: running with a database that already has tables (the later
list --month) works. Nothing is recreated. - A category the code does not know: an exit code 2 and a clear message, not a stack trace. This is the case that the review found (below).
- R14: with no database listening on port 1, the program reports
cannot use the databaseand exits with code 2. The wording after the colon is the operating system's and the JDK's, not the program's: JDK 21 printedConnection refused: no further informationhere and JDK 25 printedConnection refused: getsockopt. Never write code or tests that depend on such text.
Review: what is still wrong
#Limitation | Consequence | What you would do |
|---|---|---|
The schema is applied at every start-up with | It cannot change an existing table. The first time you need a new column, you need versioned migrations | A migration tool such as Flyway, which a later article introduces |
The application needs permission to create tables | In production the application's database user should not have DDL rights (article 28) | A separate migration user and step, and a least-privilege application user |
The password is read from an environment variable | Better than source code, but it can leak through process listings and logs | A secret manager, and the container practices covered in the production article |
No TLS settings in the JDBC URL | Data crosses the network unencrypted | Enable TLS on the connection in a real deployment |
| Fine for thousands of rows, wasteful for millions | Push |
The | Removing an enum constant leaves rows the code cannot read (now reported cleanly) | Migrations that keep them aligned |
One-statement operations rely on autocommit | Correct today, easy to break when someone adds a second statement | Make transaction boundaries explicit, as |
Tests need a free port and a few seconds per class | Slower than unit tests | Keep logic in the unit-tested layers, as here |
The defect the review found
#The first version of JdbcExpenseRepository.findAll called Category.valueOf(name) directly. While checking the program against a database that another program had added a category to (the LEGACY row above), running list produced a raw stack trace and exit code 1: IllegalArgumentException: No enum constant com.example.expenses.Category.LEGACY. The enum conversion threw an exception that no layer expected, and nothing caught it, so it fell out of main.
It was found by trying the program against unfriendly data, not by the tests that existed. The fix has three parts, all visible above: a toCategory method that converts the failure into a StorageException with an explanation, the test aCategoryTheCodeDoesNotKnowIsReportedNotCrashed that proves it, and the demo line that shows the user-facing result. Write the test first when you find a bug like this: it fails, and then the fix makes it pass (article 25).
Self-review checklist for this version
#- Does any class outside
JdbcExpenseRepositoryandSchemaimportjava.sql? (It should not.) - Is every
Connection,StatementandResultSetclosed on every path? - Is any SQL built from user text? (No: all values are bound parameters.)
- Is any secret in the code or the repository? (No: the environment.)
- Which operations touch more than one row or statement, and are they transactions?
- What does the user see when the database is down, slow, or holds unexpected data?
Extension challenges
#- Move the summary into SQL. Add
summary(YearMonth)to the repository interface, implement it withGROUP BYin the JDBC version, and keep the contract test for all three implementations. - Paging. Add
findPage(offset, limit)usingORDER BY id LIMIT ? OFFSET ?, and discuss why large offsets are slow. - Migrations. Replace
schema.sqlwith versioned migration files and a tool such as Flyway, after the production article. - A Testcontainers variant of the PostgreSQL test, if you have Docker.
update. Add it to the contract first, then implement it three times.
Common mistakes in projects like this
#- Letting JDBC leak upward, so
SQLExceptionappears in the service or the CLI. - Testing the new implementation with its own tests only. A contract test is how you prove that two implementations really are interchangeable.
- Mocking the database in tests of the repository itself. You would be testing your mock. Test the SQL against a real PostgreSQL, and use fakes only for the layers above.
- Forgetting to close the pool, so the program hangs or leaks connections on exit.
- Hard-coding the connection details, or committing a
.envfile with the password. - Applying schema changes by editing
CREATE TABLE IF NOT EXISTSand expecting existing tables to change. - Writing code that depends on the text of an error message from the operating system or the driver.
- Not testing the failure paths of a transaction: success is the easy half.
Exercises
#Exercise 1
Why an interface?
#List three concrete things in this project that would have been harder if ExpenseService created a CsvExpenseRepository itself.
Exercise 2: Race condition
#Two copies of version 1 and two copies of version 2 each run add at the same instant. Which pair can lose an expense, and why?
Exercise 3: Why a transaction?
#What would happen to an import of 500 expenses if addAll ran with autocommit on, and the 300th row failed?
Exercise 4: Write the SQL
#Write the SQL for challenge 1: the total per category for September 2026, in category order, using the tables of this project.
Exercise 5: A new category
#You add EDUCATION to the Category enum. What happens to an existing database, and what happens if you later remove EDUCATION again?
Show solutionHide solution
(1) Testing the service would need real files, instead of the in-memory repository and a fixed clock, so the tests would be slower and messier. (2) Adding PostgreSQL would have meant editing the service, instead of writing one new class and changing Main. (3) Running one set of rules against all stores (the contract test) would not be possible. A fourth: the import command copies between two stores with no special code, because both are just ExpenseRepository.
Exercise 2
Show solutionHide solution
Version 1 can lose an expense. Each copy reads the whole file, adds its own expense in memory and rewrites the whole file, so whichever writes last silently discards the other's addition (the atomic file move prevents a half-written file, not a lost update). Version 2 cannot, because each add is one INSERT that PostgreSQL applies atomically, and both rows get distinct generated ids. Concurrency is handled by the database.
Exercise 3
Show solutionHide solution
With autocommit on, each insert commits on its own. Rows 1 to 299 would already be permanently stored when row 300 failed, leaving a partial import. Re-running the import would then duplicate those 299 rows, or the user would have to work out which rows made it. The transaction makes the import all or nothing, and the test addAllStoresNothingWhenOneRowFails proves it.
Exercise 4
Show solutionHide solution
SELECT c.name AS category, sum(e.amount_cents) AS total_centsFROM expenses eJOIN categories c ON c.id = e.category_idWHERE e.spent_on >= '2026-09-01' AND e.spent_on < '2026-10-01'GROUP BY c.id, c.nameORDER BY c.id;Grouping by c.id as well as the name makes the order follow the table's insertion order, which here is the enum order. (In JDBC, the two dates would be bound parameters.)
Exercise 5
Show solutionHide solution
On the next start-up, Schema.apply runs INSERT ... ON CONFLICT DO NOTHING for every enum constant, so the new EDUCATION row is added automatically and existing data is untouched. If you later remove the constant, the row stays in the table (the schema code only adds), and if any expense still uses it, findAll reports "the database contains a category this version does not know" (exit code 2), which is the behaviour the review fix gave us. A real project would handle removing a category with a migration that moves or deletes those expenses first.
Where to go next
#- Next: HTTP, JSON and REST APIs starts the web half of the course. The ideas from this project (interfaces, layers, configuration from the environment, tests at several levels) all carry straight into the Spring Boot API in Project 3.
- Later: JPA and Hibernate: The Persistence Model shows how a framework replaces the hand-written SQL in
JdbcExpenseRepository, and what it costs.
Sources & further reading
- Technology
- Software Engineering
- #Java
- #Java backend
- #Java interview
- #Java project
- #JDBC
- #JPA
- #learn Java
- #PostgreSQL
- #Spring Boot
- #SQL
- #testing
Connected lessons
References
Keep reading
More in Technology →Guide · Technology
Heaps and Priority Queues
Guide · Technology
Dynamic Programming Practice: From 1D to 2D
Course · Technology
DSA From Zero to Job-Ready: A Practical Guide with Java

