RizTech Academy logo
RizTech Academy
Build Tools and TestingLesson 4 of 420 min

Structuring a Java project

A hundred lines can live in one file. Ten thousand cannot, and the difference between a project that stays workable and one that does not is mostly decided in the first week, by someone who had not thought about it.

This lesson is the layout this course's capstone uses, and why each decision was made.

Packages

src/main/java/com/riztech/tiffin/
├── Main.java              the command line, and nothing else
├── Money.java             paise formatting and parsing
├── Plan.java              the enum
├── Subscriber.java        a record with validation
├── Delivery.java          a record with validation
├── Parsed.java            rows plus problems
├── DeliveryStore.java     reading and writing the CSV
└── BillingService.java    the calculations

Eight files, one package. That is correct for a project this size — splitting eight classes across four packages adds import noise and hides nothing.

The question to ask is when to split, and the answer is: when you can name the groups after what they do for the user, not after what kind of thing they are.

com.riztech.tiffin.billing      good — a capability
com.riztech.tiffin.deliveries   good
com.riztech.tiffin.model        bad — a kind of thing
com.riztech.tiffin.util         worse — a kind of nothing
com.riztech.tiffin.impl         worst

A model package tells you nothing about the application. A billing package tells you the system does billing, and everything about billing is in it.

This is called packaging by feature rather than by layer, and it matters because a package should be something you could delete. Delete billing and the application loses billing. Delete model and nothing works at all, which means it was never a unit.

Package names are lowercase, dot-separated, conventionally a reversed domain you control. com.riztech.tiffin — organisation, then project.

Classes: one responsibility each

Look at what each file in the capstone is allowed to do, and what it is not:

Class Does Deliberately does not
Main Parses arguments, prints, sets exit codes Calculate or read files
BillingService Totals, groups, renders report text Print anything
DeliveryStore Reads and writes the CSV Know about billing
Delivery, Subscriber Hold valid data, reject invalid Do I/O
Money Format and parse paise Anything else
Parsed<T> Carry rows and problems together Decide what to do about them

BillingService.renderReport returns a String and Main prints it. That one split is why the report can be tested at all, and it is the most reusable idea in this lesson: separate deciding from doing.

The same applies to DeliveryStore, which takes a Path rather than knowing one. The test passes a @TempDir; production passes a real path. Neither the store nor the test had to change for that to work.

The test for a class's responsibility: can you describe it in one sentence without "and"? "Reads and writes the delivery CSV" is one job with two directions. "Reads the CSV and calculates the bill" is two jobs.

Visibility

Start at private. Widen when something outside genuinely needs it.

public record Delivery(LocalDate date, String customer, int tiffins) { ... }

static String quote(String field) { ... }        // package-private: tested, not public API
private void writeAtomically(String content) { } // an implementation detail

Package-private is the level people forget exists, and it is exactly right for helpers that tests need to reach but callers should not. quote and splitCsv are tested directly and are not part of the store's public surface.

That is also why tests go in the same package as the class under test.

Constants and configuration

public static final int MAX_PER_DAY = 4;

On the class the rule belongs to — Delivery.MAX_PER_DAY — not in a Constants class. A single bucket of unrelated constants is the same anti-pattern as a util package: it groups by kind rather than by meaning.

Anything that varies by environment — a file path, a port — comes in from outside:

Path dataFile = Path.of(System.getProperty("tiffin.data", "deliveries.csv"));

Configuration is an argument, not a constant. The moment it is hard-coded, the same build cannot run in two places.

The entry point

Main should be thin enough to read in one screen:

public static void main(String[] args) {
    try {
        System.exit(run(args));
    } catch (IllegalArgumentException e) {
        System.err.println("Error: " + e.getMessage());
        System.exit(2);
    } catch (UncheckedIOException e) {
        System.err.println("Error: " + e.getMessage());
        System.exit(3);
    }
}

Three things worth copying.

main delegates to a run returning an exit code, so the logic is testable — main calls System.exit, which a test cannot survive.

Errors go to System.err, so a user can redirect normal output to a file and still see problems.

Exit codes are distinct and meaningful. 0 success, 1 usage error, 2 bad input, 3 I/O failure, 4 completed with data problems. A shell script or CI job can act on them; System.exit(1) for everything cannot be acted on at all.

A README is part of the project

The file that stops somebody — including you, in a year — from having to read the code to run it:

# Tiffin Tracker

A command-line tracker for a Pune tiffin service.

## Build
    ./mvnw clean package

## Run
    java -jar target/tiffin-tracker.jar help
    java -jar target/tiffin-tracker.jar add 2026-09-01 Priya 2
    java -jar target/tiffin-tracker.jar report 2026-09

## Data
CSV at ./deliveries.csv, or -Dtiffin.data=<path>

## Test
    ./mvnw test

Build, run, data, test. Four sections, and it is the difference between a project somebody can use and one they cannot.

.gitignore

target/
*.class
.idea/
*.iml
.DS_Store

Build output, IDE files and operating-system clutter. Never commit target/.

What good structure is not

  • Not a package per class. Depth is not organisation.
  • Not an interface per class. BillingService has one implementation and needs no BillingServiceInterface. Add the interface when there is a second implementation or a test double that needs one — which the interfaces lesson already argued.
  • Not a framework. A command-line tool with eight classes needs no dependency injection container.
  • Not decided up front. Start with one package. Split when a group of classes has an obvious shared purpose and a name you can defend.

Structure that arrives before the code it organises is a guess. Structure that arrives when a file gets hard to find is a response to evidence.

Check your work

When should you split into packages? When you can name the groups after capabilities — billing, deliveries — rather than kinds of thing.

What is wrong with a model or util package? It groups by kind rather than meaning, so it is not something you could delete, and its name tells a reader nothing about the application.

What is the test for a class's responsibility? You can describe it in one sentence without "and".

Why does renderReport return a String? So the report can be tested. Printing is Main's job — separate deciding from doing.

What is package-private access for? Helpers that tests need to reach but callers should not, which is why tests share the class's package.

Where does a constant belong? On the class whose rule it is — Delivery.MAX_PER_DAY — not in a shared Constants class.

Why should main delegate to a method returning an exit code? main calls System.exit, which a test cannot survive. A run method returning an int is testable.

What are the four sections of a minimal README? Build, run, data, test.

Practice 3, the one-sentence test. DeliveryStore — "reads and writes the delivery CSV" — passes; two directions of one job. A class doing "reads the CSV and calculates the bill" fails, and splitting it gives you DeliveryStore and BillingService, which is exactly the capstone's layout. If every class in your project passes, you do not need packages yet.

Practice 5, the exit codes. echo $? after each command gives 0 for a successful report, 1 for an unknown command, 2 for an invalid argument, 3 for an unreadable data file and 4 for a report that completed with bad rows. The 4 is the interesting one: the command succeeded and something was wrong, which a single failure code cannot express.

Practice

  1. Lay out a project. pom.xml, src/main/java, src/test/java, a .gitignore and a README with the four sections. Build it with mvn clean package.

  2. Name the packages badly, then well. Put your classes in model, util and impl. Then rename to capability names. Read both lists of package names and say which tells you what the application does.

  3. Apply the one-sentence test. Write one sentence for each class you have. Split any that need an "and".

  4. Separate deciding from doing. Find a method that both computes and prints. Split it, and write a test for the computing half that would have been impossible before.

  5. Give your program exit codes. Distinct codes for success, usage error, bad input and I/O failure. Check each with echo $? after running. Then add a fifth for "finished, but some rows were bad".

  6. Harder — write the README first. For a program you have not built yet, write the README as though it existed: the commands, the arguments, the data file, the output. Then build it to match. You will find at least one design decision you would otherwise have made badly, because describing an interface before implementing it is the cheapest design review there is.


That is module ten. You can create a Maven project, add dependencies and know where they came from, package a jar that runs anywhere, write tests that say what broke, tell a test worth keeping from one that will be ignored, and lay out a project that still makes sense when it grows.

Next module: the capstone, where all eleven modules become one application.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship