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.
BillingServicehas one implementation and needs noBillingServiceInterface. 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
-
Lay out a project.
pom.xml,src/main/java,src/test/java, a.gitignoreand a README with the four sections. Build it withmvn clean package. -
Name the packages badly, then well. Put your classes in
model,utilandimpl. Then rename to capability names. Read both lists of package names and say which tells you what the application does. -
Apply the one-sentence test. Write one sentence for each class you have. Split any that need an "and".
-
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.
-
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". -
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