A step-by-step build log explaining every decision made while building this project. Intended as a learning reference alongside the codebase.
pom.xml stands for Project Object Model. It is the configuration file for Maven, a build tool for Java projects. Every Maven project has exactly one pom.xml at its root.
It tells Maven everything it needs to know about your project:
- What your project is — group ID, artifact ID, version
- What it depends on — external libraries (SQLite driver, logging, testing frameworks)
- How to build it — Java version, plugins, output format
Without a build tool like Maven, you would have to:
- Manually download every
.jarlibrary your project needs - Manually add them to your classpath when compiling
- Manually compile every
.javafile in the right order - Manually bundle everything into a runnable JAR
Maven automates all of this. You declare what you need, and Maven handles the rest.
Sets the Java version (17) and file encoding. Maven uses these when compiling.
The libraries our project needs. Maven downloads them automatically from the internet (Maven Central repository) and caches them locally (~/.m2/).
| Dependency | Purpose |
|---|---|
sqlite-jdbc |
JDBC driver that lets Java talk to a SQLite database |
slf4j-api + logback-classic |
Logging framework — write log messages at INFO/DEBUG/ERROR levels |
junit-jupiter |
Test framework — write and run unit tests |
mockito-core + mockito-junit-jupiter |
Mocking framework — fake out dependencies in unit tests |
Dependencies marked <scope>test</scope> are only available during testing — they are not included in the final JAR.
maven-surefire-plugin — runs unit tests when you call mvn test. Required explicitly for JUnit 5 support.
maven-shade-plugin — bundles the app and all its dependencies into a single fat JAR (also called an uber JAR). This is what makes java -jar subscription-tracker.jar work on any machine without needing to install anything extra. It also sets the Main-Class manifest entry so Java knows where to start execution.
mvn compile # Compile source code
mvn test # Compile and run all tests
mvn package # Compile, test, and produce the fat JAR in target/
mvn clean # Delete the target/ folder (compiled output)
mvn clean package # Full clean rebuildThe model layer defines the data structures the rest of the application works with. It has no logic, no database code, and no user interaction — it purely represents what a subscription is.
An enum (enumeration) is a special Java type for a fixed set of named constants. Instead of storing raw strings like "MONTHLY" throughout the code (which can be mistyped or inconsistent), we define them once as an enum and use the type everywhere.
public enum BillingCycle {
MONTHLY, ANNUAL, WEEKLY
}Benefits:
- The compiler catches typos at build time —
BillingCycle.MONTLYwon't compile - IDEs autocomplete the valid values
- Easy to
switchon in business logic
In the database, these are still stored as plain strings (TEXT column). When we read them back from SQLite we convert: BillingCycle.valueOf(resultSet.getString("billing_cycle")).
A record is a Java 16+ feature for creating immutable data carriers with minimal boilerplate. Declaring:
public record Subscription(int id, String name, ...) {}automatically gives you:
- A constructor with all fields
- Getters for every field (called
id(),name(), etc. — nogetprefix) equals(),hashCode(), andtoString()implementations
Records are immutable — once created, the values cannot be changed. This is intentional: if something about a subscription needs updating, a new object is created. This makes the data easier to reason about and prevents accidental mutation.
Two fields can be null:
cancelledDate— only set when a subscription is cancellednotes— optional free text
We use plain null (not Optional) consistently across the entire codebase. This was a deliberate convention choice — mixing both would be inconsistent and confusing.
Keeping data structures in their own package means:
- The
repositoryknows what shape to return data in - The
serviceknows what shape to validate and process - The
CLIknows what shape to display
All three layers speak the same language — Subscription objects — without any layer needing to know how the others work.
The repository layer is the only place in the codebase that touches SQL. It owns the database connection, all queries, and the mapping from raw SQL result rows back into Subscription objects.
Runs once on startup. Creates the subscriptions table if it doesn't already exist (CREATE TABLE IF NOT EXISTS). This means the app works on a brand-new machine with no manual setup — just run it and the database is ready.
It takes a JDBC URL string in its constructor (e.g. "jdbc:sqlite:subscriptions.db"), which makes it easy to pass an in-memory URL in tests.
Contains every SQL statement in the app. Broken into three sections:
Write operations
add(Subscription)— inserts a new rowupdate(Subscription)— updates all mutable fields by IDdelete(int id)— removes a row
Read operations
findById(int)— returnsOptional<Subscription>(empty if not found)findAll()— all subscriptions, sorted by namefindByStatus(Status)— filter by ACTIVE or CANCELLEDfindRenewingBefore(LocalDate)— upcoming renewals within a date window
Aggregates
getTotalMonthlyCost()— normalises all billing cycles to monthly in SQLgetCostByCategory()— same normalisation, grouped by category, ordered by cost
Every query uses PreparedStatement with ? placeholders — never string concatenation. This prevents SQL injection and also lets the DB engine reuse query plans.
Each method opens a fresh Connection and closes it in a try-with-resources block. This is simple and correct for a single-user desktop app. A multi-user server app would use a connection pool instead.
Every Connection, PreparedStatement, and ResultSet is opened inside a try (...) block. Java automatically calls .close() on them when the block exits — even if an exception is thrown. This prevents resource leaks.
The billing cycle normalisation (monthly → ×1, annual → ÷12, weekly → ×52/12) is written as a CASE expression directly in SQL. Doing it in SQL means the database does the aggregation in one pass rather than loading all rows into Java and summing them up there.
All the logic for converting a result row into a Subscription object lives in one private method. Every read operation calls it, so there's no duplication of field-name strings or type conversions.
Enums are stored as their .name() string in the database (e.g. "MONTHLY", "ACTIVE"). On read, BillingCycle.valueOf(rs.getString(...)) converts back. This is simple and readable in the DB, and the enum type gives compile-time safety in Java.
Before parsing cancelled_date, we check if the column is null in the result set. A null string passed to LocalDate.parse() would throw — the null check prevents that.
Added the remaining Maven standard directories:
src/main/resources/— for config files (e.g.logback.xml)src/test/java/com/tracker/— for all test classessrc/test/resources/— for test config overrides
Each directory contains a .gitkeep file so git tracks them even when empty. These are removed once real files are placed inside.
Also deleted the stale com/subscriptiontracker/ tree left over from an earlier draft — the active package is com/tracker/.
The service layer sits between the CLI and the repository. It owns all business logic and validation. It never writes SQL — it calls the repository for all data access.
The single service class. Takes a SubscriptionRepository in its constructor (injected dependency — makes it easy to mock in tests).
Add
add(name, category, cost, billingCycle, startDate, notes)— validates input, calculates renewal date, builds aSubscriptionwithid=0(DB assigns the real ID), callsrepository.add().
Update
update(id, ...)— looks up the existing record first (getOrThrow), validates, recalculates renewal date, preservesstatus,cancelledDate, andcreatedAtfrom the original.
Delete
delete(int id)— verifies the subscription exists before delegating to the repository.
Cancel / reactivate
cancel(int id)— sets status toCANCELLED, records today ascancelledDate. Throws if already cancelled.reactivate(int id)— sets status back toACTIVE, clearscancelledDate, recalculates a fresh renewal date from today. Throws if already active.
Queries
getAll(),getActive(),getCancelled(),findById(int)getRenewingWithinDays(int days)— wrapsfindRenewingBefore(today + days)
Aggregates
getTotalMonthlyCost()— delegates to repositorygetTotalAnnualCost()— multiplies monthly × 12 (no extra SQL needed)getCostByCategory()— delegates to repository
The service never imports java.sql. All data access goes through the repository. This is enforced by the package boundary.
Rather than scattering Optional.orElseThrow calls, one private method fetches by ID and throws IllegalArgumentException if missing. All write operations call it first, so the CLI always gets a clear error if the user types a bad ID.
validateName and validateCost are private methods called by both add and update. The CLI does not validate — it passes raw input to the service and catches IllegalArgumentException to show the user an error message.
Marked static and not private so the service tests can call it directly to verify renewal date logic without going through add().
When a cancelled subscription is reactivated, the old renewal date is stale. A fresh renewal date is calculated from LocalDate.now() using the original billing cycle.
Main.java is the entry point of the application — it's where execution starts (public static void main). It also contains the entire CLI: the menu loop, all user prompts, and all display formatting. No business logic lives here.
Wires everything together on startup:
- Creates the database via
DatabaseInitialiser - Creates
SubscriptionRepositorywith the SQLite URL - Creates
SubscriptionServicewith the repository - Hands control to the menu loop
The menu loop (run) prints options 0–10, reads the user's choice, and dispatches to a handler method. Each handler is a private static method responsible for exactly one action.
Menu options
| Option | What it does |
|---|---|
| 1 | Add subscription |
| 2 | List all |
| 3 | List active |
| 4 | List cancelled |
| 5 | Update subscription |
| 6 | Cancel subscription |
| 7 | Reactivate subscription |
| 8 | Delete subscription (with confirmation) |
| 9 | Upcoming renewals (user picks day window) |
| 10 | Cost summary (monthly + annual total, breakdown by category) |
| 0 | Exit |
The switch block is wrapped in a single try/catch for IllegalArgumentException and IllegalStateException. The service throws these on bad input or invalid state transitions. The CLI catches them and prints a friendly message. This means the service never needs to know about System.out.
The loop is extracted into a static run(service, scanner) method so tests can call it directly with a fake Scanner and a mock service — without needing to actually start the app.
promptInt, promptDouble, and promptDate all loop until the user enters valid input rather than crashing on bad input. The "or keep" variants (promptDoubleOrKeep, promptEnumOrKeep, etc.) accept a blank line as "keep current value" — useful for the update flow.
promptEnum uses reflection (type.getEnumConstants()) to list all values dynamically. Adding a new Category or BillingCycle value in the future automatically shows up in the menu with no code change needed.
Option 8 asks "Are you sure? (yes/no)" before deleting. Only the literal string "yes" proceeds. Any other input cancels. This prevents accidental data loss.
Output is formatted with printf and fixed column widths so rows line up regardless of content length. Long names are truncated with … to keep the table readable.
Integration tests that exercise SubscriptionRepository against a real SQLite database. They test that every SQL statement in the repository actually works — things that unit tests with mocks cannot catch.
12 tests covering every public method on the repository:
| Test | What it verifies |
|---|---|
add_and_findById_returnsSubscription |
Round-trip: insert then fetch by ID |
findById_unknownId_returnsEmpty |
Returns Optional.empty() for missing IDs |
findAll_returnsAllSubscriptions_sortedByName |
Alphabetical ordering |
findAll_emptyDatabase_returnsEmptyList |
Empty list on fresh DB |
findByStatus_returnsOnlyMatchingStatus |
Filters correctly by ACTIVE / CANCELLED |
update_changesStoredValues |
All mutable fields are persisted |
delete_removesSubscription |
Row is gone after delete |
findRenewingBefore_returnsOnlyActiveWithinWindow |
Date window + excludes cancelled |
getTotalMonthlyCost_normalisesAllBillingCycles |
MONTHLY/ANNUAL/WEEKLY all normalise correctly |
getTotalMonthlyCost_excludesCancelledSubscriptions |
Cancelled rows not counted |
getCostByCategory_groupsAndSortsByMonthlyCost |
Groups by category, sorted descending |
nullableFields_roundtripCorrectly |
null cancelled date and notes survive a round-trip |
SQLite in-memory databases are connection-scoped — each DriverManager.getConnection call gets its own empty database. Using jdbc:sqlite:file:testdb?mode=memory&cache=shared creates a named in-memory database shared across connections within the same process.
A static Connection keeper is opened in @BeforeAll and closed in @AfterAll. Its only job is to keep the named in-memory database alive. Without it, the DB is destroyed when the last connection closes, which would happen between DatabaseInitialiser and the repository's first query.
Each test starts with a fresh schema. Dropping and recreating the table in @BeforeEach guarantees no data leaks between tests, and auto-increment IDs reset to 1.
subscription(), subscriptionWithCost(), subscriptionWithCategory(), etc. are private helper methods that construct Subscription objects with sensible defaults. Tests only set the fields they care about — keeps each test focused and readable.
For getTotalMonthlyCost_normalisesAllBillingCycles, the weekly cost is chosen as £3/week because 3 × 52 ÷ 12 = 13 exactly — a clean integer that makes the assertion obvious. Choosing an arbitrary weekly cost would produce a recurring decimal and obscure the intent.
Unit tests for SubscriptionService using Mockito to mock the repository. No database is involved — the service's logic is tested in complete isolation.
17 tests covering all service methods:
| Test | What it verifies |
|---|---|
add_validInput_callsRepositoryWithCorrectFields |
Correct Subscription is built and passed to the repository |
add_blankName_throwsIllegalArgumentException |
Blank name is rejected before touching repository |
add_zeroCost_throwsIllegalArgumentException |
Zero cost is rejected |
add_negativeCost_throwsIllegalArgumentException |
Negative cost is rejected |
add_blankNotes_savedAsNull |
Whitespace-only notes are stored as null |
update_validInput_preservesStatusAndCreatedAt |
Status and createdAt are carried over from the existing record |
update_unknownId_throwsIllegalArgumentException |
Unknown ID rejected, repository update never called |
delete_existingId_callsRepositoryDelete |
Repository delete is called with the correct ID |
delete_unknownId_throwsIllegalArgumentException |
Unknown ID rejected, repository delete never called |
cancel_activeSubscription_setsStatusAndCancelledDate |
Status set to CANCELLED, cancelled date populated |
cancel_alreadyCancelled_throwsIllegalStateException |
Double-cancel is rejected |
reactivate_cancelledSubscription_setsActiveAndClearsDate |
Status set to ACTIVE, cancelled date cleared |
reactivate_alreadyActive_throwsIllegalStateException |
Double-reactivate is rejected |
getTotalAnnualCost_returnsMonthlyTimestwelve |
Annual = monthly × 12 |
calculateRenewalDate_monthly_addOneMonth |
MONTHLY adds exactly one month |
calculateRenewalDate_annual_addOneYear |
ANNUAL adds exactly one year |
calculateRenewalDate_weekly_addOneWeek |
WEEKLY adds exactly one week |
The repository is mocked with @Mock. This means tests run in milliseconds and never need file I/O. The service's logic is tested independently of whether the SQL is correct — that's the repository tests' job.
Tells JUnit 5 to activate Mockito's annotations. Without this, @Mock fields would be null.
For add, update, cancel, and reactivate, we use ArgumentCaptor<Subscription> to capture the exact object passed to repository.add() / repository.update(). This lets us assert on every field of the built object, not just that the method was called.
Validation tests assert that when bad input is given, the repository is never touched at all. This confirms validation runs before any data access.
The project runs on Java 25. Mockito's underlying bytecode library (Byte Buddy) officially supports up to Java 22. Adding -Dnet.bytebuddy.experimental=true to the Surefire <argLine> in pom.xml enables support for newer JVM versions without upgrading Mockito.
Configures how log messages are written at runtime. Two appenders:
- CONSOLE — writes to
System.errso log output doesn't mix with the CLI'sSystem.outmenu text. Pattern shows time, level, logger name, and message. - FILE — writes to
logs/app.logwith daily rolling (7 days kept). Useful for debugging without cluttering the terminal.
The com.tracker logger is set to WARN, which silences all the INFO messages the repository and service emit during normal operation. The user only sees the CLI output — not internal log lines for every add/update/delete.
Running mvn clean package:
- Compiles all source
- Runs all 29 tests (12 repository + 17 service)
- Produces
target/subscription-tracker.jar— a 14MB fat JAR containing the app and all dependencies
The app can now be run on any machine with Java installed:
java -jar target/subscription-tracker.jarThe build prints warnings about overlapping MANIFEST.MF entries across bundled JARs. These are harmless — the shade plugin picks one and moves on. They do not affect runtime behaviour.
All 9 steps are done. The full architecture from bottom to top:
Main.java (CLI — Scanner menu, all user I/O)
↓
SubscriptionService (validation, business logic, no SQL)
↓
SubscriptionRepository (all SQL, PreparedStatements only)
↓
SQLite (subscriptions.db — schema created on first run)
29 tests, 0 failures.
# Run the app
java -jar target/subscription-tracker.jar
# Run tests
mvn test
# Rebuild
mvn clean package