Skip to content
SparkTime, home
Java From Zero to Job-Ready
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
8 · Databases and JDBC
9 · Web and Spring
10 · Persistence with JPA
11 · Secure, Tested, Production-Ready
12 · Interviews and Readiness
Lesson 29 of 41

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.

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

#
Code
   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 environment

File

Status

Category, Expense, Money, InvalidInputException

Unchanged

ExpenseRepository

One new method, addAll

InMemoryExpenseRepository, CsvExpenseRepository

Implement addAll

ExpenseService, Cli

A new import command and its service method. The rest is unchanged

Main

Chooses CSV or PostgreSQL

JdbcExpenseRepository, Schema, StorageException

New

schema.sql, simplelogger.properties

New resources

pom.xml

New dependencies, and a plugin that builds a fat jar

Tests

One shared contract test, run against three implementations

Decisions, and the reasoning:

  1. A separate StorageException. A SQLException is a checked exception that belongs to JDBC. The repository catches it and throws its own unchecked StorageException, 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.
  2. addAll is 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.
  3. The database does the heavy lifting that Java did before. There is no read-modify-write cycle in the program any more: INSERT with a generated id is a single statement, so two programs cannot overwrite each other (R9).
  4. Categories exist in two places, the Java enum and the categories table, 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.
  5. 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:

Diff
--- 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:

  • dependencyManagement with 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 scope import makes 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 only java.sql), HikariCP is compile (the code uses its classes), logging is runtime, and JUnit and the embedded database are test.
  • The Shade plugin builds a fat jar, which solves the problem from article 23: a plain jar contains only your classes, so java -jar would fail with NoClassDefFoundError. The shaded jar is about 1.7 MB and runs on its own. Two settings matter: the ServicesResourceTransformer merges 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.

SQL
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:

Code
org.slf4j.simpleLogger.defaultLogLevel=warn

Milestone 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.

Java
// 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:

Diff
--- 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:

Diff
--- 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:

Java
// 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:

  1. 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.
  2. add uses RETURNING id so that it learns the generated id in the same statement, and each method uses try-with-resources so connections go back to the pool.
  3. addAll is the transaction pattern from article 28: turn autocommit off, batch the inserts, commit, and on failure roll back and rethrow. In finally it restores autocommit.
  4. findAll maps rows to Expense records. toCategory handles a case the first version of this class got wrong, explained in the review below.
  5. Every SQLException becomes a StorageException with a message a human can read.
Java
// 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:

Java
// 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.

Java
// 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:

Java
// 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:

Java
// 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 addAll stores nothing, tested by making the second row fail (the OTHER category's row is deleted so the second insert violates NOT 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.
Java
// 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:

Diff
--- 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):

Diff
--- 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:

Diff
--- 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);
Diff
--- 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:

Java
// 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):

Shell
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:

Code
# 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 list against the empty database prints "No expenses found." The tables did not exist before, and the program created them.
  • R12: import old-expenses.csv copied the three expenses. The imported rows got fresh database ids, and the next add continued with #4. The ids of the old file were not reused or required to match.
  • R11: list and summary print 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 database and exits with code 2. The wording after the colon is the operating system's and the JDK's, not the program's: JDK 21 printed Connection refused: no further information here and JDK 25 printed Connection 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 CREATE TABLE IF NOT EXISTS

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

findAll loads every row, and the service filters and sums in Java

Fine for thousands of rows, wasteful for millions

Push WHERE and GROUP BY into SQL, and add paging

The Category enum and the categories table can disagree

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 addAll does

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 JdbcExpenseRepository and Schema import java.sql? (It should not.)
  • Is every Connection, Statement and ResultSet closed 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

#
  1. Move the summary into SQL. Add summary(YearMonth) to the repository interface, implement it with GROUP BY in the JDBC version, and keep the contract test for all three implementations.
  2. Paging. Add findPage(offset, limit) using ORDER BY id LIMIT ? OFFSET ?, and discuss why large offsets are slow.
  3. Migrations. Replace schema.sql with versioned migration files and a tool such as Flyway, after the production article.
  4. A Testcontainers variant of the PostgreSQL test, if you have Docker.
  5. update. Add it to the contract first, then implement it three times.

Common mistakes in projects like this

#
  • Letting JDBC leak upward, so SQLException appears 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 .env file with the password.
  • Applying schema changes by editing CREATE TABLE IF NOT EXISTS and 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
SQL
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

  1. jdbc.postgresql.org
  2. github.com
  3. github.com
  4. postgresql.org
  • 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