A small Java program still has a shape. The model package holds the data, the service package holds the logic, and the io package holds everything that touches the outside world. The entry point wires them together. Get that shape right in a program of five classes and you will not need to relearn it in a program of five hundred.
What are the basic rules?
- One public class per file, and the file name matches the class name.
- The package declaration matches the directory the file sits in.
- The entry point is a class with a
mainmethod, and it is the only class that knows about all the others. - Classes inside a package see each other without imports; classes in other packages need an import.
- Keep fields private and expose behaviour through methods.
The first rule catches people out when they move files. A file at service/PricingService.java that declares package com.tutorsbot.model; compiles, but the compiler will not find it where you expect it, and the import in another class fails.
What are the layers, and why?
Look at the layout below.
src/
main/
java/
com/tutorsbot/shop/
Main.java
model/
Product.java
Order.java
service/
PricingService.java
InventoryService.java
io/
ConsoleReport.java
FileReport.javaThree packages, each with a clear job. The model package knows nothing about anything else - it holds Product and Order, and those classes know about themselves and about the data they carry. The service package holds the rules and depends on the model. The io package knows how to read and write, and depends on the model but not on the services. And Main sits at the top, wires the pieces together and is the only class that imports from every package.
That dependency direction is the point. A model class never imports a service, so you can test the model without a database, a console or anything else. The services can be tested without a console. And the io classes can be swapped - a console report today, a web endpoint tomorrow - without touching a line of the business logic.
What should a model class look like?
A record is a natural fit when the class is just data. A short, valid, and the invariants are checked in one place.
package com.tutorsbot.shop.model;
public record Product(String sku, String name, double price, int stock) {
public Product {
if (sku == null || sku.isBlank()) {
throw new IllegalArgumentException("sku is required");
}
if (price < 0) {
throw new IllegalArgumentException("price cannot be negative");
}
}
public boolean inStock() {
return stock > 0;
}
public static void main(String[] args) {
Product product = new Product("KB-01", "Keyboard", 49.0, 12);
System.out.println("product : " + product);
System.out.println("in stock : " + product.inStock());
try {
new Product(" ", "Nameless", 10.0, 1);
} catch (IllegalArgumentException e) {
System.out.println("rejected : " + e.getMessage());
}
}
}product : Product[sku=KB-01, name=Keyboard, price=49.0, stock=12]
in stock : true
rejected : sku is requiredTwo things to notice. The compact constructor rejects a blank sku and a negative price, so an invalid Product cannot exist - the check happens at the point of creation rather than being repeated at every use. And inStock is behaviour that belongs with the data, not in a service that has to know how products work.
What goes in a service?
Services hold the rules that operate on the model. They take data in, do something with it, and give a result back.
package com.tutorsbot.shop.service;
import com.tutorsbot.shop.model.Product;
import java.util.List;
public class PricingService {
private final double taxRate;
public PricingService(double taxRate) {
this.taxRate = taxRate;
}
public double totalWithTax(List<Product> products) {
double net = products.stream().mapToDouble(Product::price).sum();
return round(net * (1 + taxRate));
}
public double priceOf(Product product) {
return round(product.price() * (1 + taxRate));
}
private double round(double value) {
return Math.round(value * 100) / 100.0;
}
}net total : 68.5
with tax : 82.2
one item : 58.8That service takes a list of products and works out the total. Notice what it does not do: it does not print, it does not read a file, and it does not reach for a global. Every one of those would make it untestable, because a test would have to capture the output or set up a file.
The rounding is deliberate too. Rounding to two decimals is the kind of business rule that belongs in the service, not scattered through the code that happens to display the number. When the rounding changes, you change it in one place.
What should the entry point do?
The main method wires things together and nothing else. Here it is next to the console reporter it uses.
package com.tutorsbot.shop;
import com.tutorsbot.shop.io.ConsoleReport;
import com.tutorsbot.shop.model.Product;
import com.tutorsbot.shop.service.PricingService;
import java.util.List;
public class Main {
public static void main(String[] args) {
List<Product> catalogue = List.of(
new Product("KB-01", "Keyboard", 49.0, 12),
new Product("MS-02", "Mouse", 19.5, 0),
new Product("MN-03", "Monitor", 189.0, 4));
PricingService pricing = new PricingService(0.2);
ConsoleReport report = new ConsoleReport();
report.printCatalogue(catalogue);
report.printTotal(pricing.totalWithTax(catalogue));
catalogue.stream()
.filter(product -> !product.inStock())
.forEach(product -> report.printOutOfStock(product));
}
}KB-01 Keyboard 49.0
MS-02 Mouse 19.5
MN-03 Monitor 189.0
total with tax : 309.0
out of stock : MS-02Read it top to bottom and you can see the whole program structure: build the data, create the service, create the reporter, then use them. There is no business rule here, and that is exactly right. The main method is the one place where it is acceptable to know about every class, because its job is to connect them.
If your main method grows past about thirty lines, it is usually a sign that some logic belongs in a service.
How do you keep the layers decoupled?
| Layer | May depend on | Must not contain |
|---|---|---|
| model | Nothing but the standard library | Printing, files, services |
| service | model | Console output, hard-coded paths |
| io | model | Business rules |
| main | Everything | Logic worth testing |
Two practical techniques keep this honest. Pass the things a class needs into its constructor rather than letting it create them, so a test can supply a different implementation. And keep classes in the same package when they are closely related - a package per class is its own kind of noise.
How should you name packages?
Start with a domain that will not change, like your organisation or project name, then the layer, then the feature. com.tutorsbot.shop.service.pricing is clearer than com.tutorsbot.utils.helpers, because it says what the code is and where it sits. Avoid generic names like util and common: they become dumping grounds, because anything fits in a package called "util".
The habits to keep
- One public class per file, with the package matching the folder.
- Keep the model layer free of any dependency on the rest of the code.
- Pass collaborators in through the constructor.
- Keep
mainshort - it wires, it does not calculate. - Name packages after the feature, not after their technical role.
- Do not create a package per class; group related classes.
Try it yourself: build a four-class program for tracking study sessions: a Session record with a topic, a duration and a date, a StudyService that totals time per topic, a ConsoleReport that prints the totals, and a Main that wires them together. Then write a second version where the report is written to a file instead of the console, and confirm that neither the model nor the service changed by a single line.