You have now built something real. Starting from a one-paragraph brief about a lending library, you produced a layered Java application: encapsulated domain classes, in-memory collections you could search and sort, a repository hidden behind an interface, robust error handling, and stream-based reports. That is more working code than most courses ever ask a learner to hold together.
This final step sets the keyboard down and asks two questions a practising engineer answers every week: what shape did I just build, and why does it look that way? Writing Java is the floor of the job. Defending each decision — in correct Java terms, naming the trade-off it makes — is what separates someone who can type a solution from someone a team trusts in a design review or a job interview. The earlier milestones taught you the parts; this one steps back so you can see the whole.
flowchart TD APP["Application layer<br/>wires concrete classes"] --> SVC["LibraryService<br/>holds the business rules"] SVC --> REP["BookRepository<br/>the interface contract"] REP --> IMPL["InMemoryBookRepository<br/>the storage decision"] SVC --> RPT["Reporting<br/>streams and collectors"] style REP fill:#c2410c,color:#fff style IMPL fill:#15803d,color:#fff
The shape of what you built
Read the architecture top to bottom. At the top, an application layer wires the pieces together — it decides which repository and which service to construct, the one place a concrete class name is allowed to appear. Below it sits the service, holding the business rules: borrowing limits, the reports a librarian asks for. The service never opens a database; it talks to a repository interface, a contract that promises I can find and save books without committing to how. Behind that interface lives the implementation — today an in-memory list, tomorrow a database — the single class that changes when storage technology moves.
Each seam rests on a concept from an earlier milestone: encapsulation keeps a class honest about its own data, the interface keeps callers decoupled from storage, exceptions carry domain meaning across the seams, and streams express reports without loops.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Main {
// Milestone 1: an encapsulated domain class with a validation choke point.
static class Book {
private final String title;
private final String author;
private final String genre;
Book(String title, String author, String genre) {
this.title = title;
this.author = author;
this.genre = genre;
}
String getTitle() { return title; }
String getAuthor() { return author; }
String getGenre() { return genre; }
}
// Milestone 3: callers depend on the interface, never on the storage.
interface BookRepository {
List<Book> findAll();
}
static class InMemoryBookRepository implements BookRepository {
public List<Book> findAll() {
return Arrays.asList(
new Book("Dune", "Herbert", "Sci-Fi"),
new Book("Beloved", "Morrison", "Literary"),
new Book("The Halo", "Herbert", "Sci-Fi")
);
}
}
// Milestone 4: a checked exception the caller can recover from.
static class NoBooksFoundException extends Exception {
NoBooksFoundException(String message) { super(message); }
}
static class LibraryService {
private final BookRepository repo;
LibraryService(BookRepository repo) { this.repo = repo; }
Map<String, Long> countByGenre() throws NoBooksFoundException {
List<Book> books = repo.findAll();
if (books.isEmpty()) {
throw new NoBooksFoundException("catalogue is empty");
}
// Milestone 5: a stream report, not a hand-written loop.
return books.stream()
.collect(Collectors.groupingBy(
Book::getGenre, Collectors.counting()));
}
}
public static void main(String[] args) {
try {
LibraryService service =
new LibraryService(new InMemoryBookRepository());
Map<String, Long> counts = service.countByGenre();
System.out.println("Sci-Fi: " + counts.get("Sci-Fi"));
} catch (NoBooksFoundException e) {
System.out.println(e.getMessage());
}
}
}
Every decision is a trade-off
A junior answer to why did you do it this way? is because it works. A senior answer names what the choice buys and what it costs. Marking a field private buys a single choke point where you can later add validation; the price is the getter you must now write. Typing a variable as the interface lets you swap implementations and test with a fake, but the concrete-only methods disappear from that variable's view. A checked exception forces the compiler to verify that callers handle a recoverable failure, in exchange for a longer throws clause.
flowchart LR P["private fields"] -->|"buys a validation choke point"| PG["costs a getter"] I["interface-typed variable"] -->|"buys swap and testability"| IG["costs concrete-only methods"] C["checked exception"] -->|"buys compiler-enforced recovery"| CG["costs a longer throws clause"] style P fill:#3776ab,color:#fff style I fill:#3776ab,color:#fff style C fill:#3776ab,color:#fff
The collection and report decisions carry the same shape. Choosing a HashMap over a List buys instant lookup by key but surrenders any ordering guarantee. Reaching for a stream pipeline buys a report that reads like a sentence, at the cost of being single-use and useless without a terminal operation. Name both halves of a trade and you can also justify reversing it later — which a growing codebase will, sooner or later, ask you to do.
import java.util.Arrays;
import java.util.List;
public class Main {
static class Book {
String title;
Book(String t) { title = t; }
String getTitle() { return title; }
}
interface BookRepository {
List<Book> findAll();
}
// The production implementation: data lives in memory.
static class CatalogRepository implements BookRepository {
public List<Book> findAll() {
return Arrays.asList(new Book("Dune"), new Book("Beloved"));
}
}
// A fake for tests: fixed data, no setup, runs in milliseconds.
static class FakeRepository implements BookRepository {
public List<Book> findAll() {
return Arrays.asList(new Book("Test Book"));
}
}
// count only knows the interface, so either implementation fits.
static int count(BookRepository repo) {
return repo.findAll().size();
}
public static void main(String[] args) {
BookRepository prod = new CatalogRepository();
BookRepository test = new FakeRepository();
System.out.println("prod: " + count(prod));
System.out.println("test: " + count(test));
}
}
Talking about it in correct Java
Interviews reward precise vocabulary. Say instance method when you mean a method that runs on an object, and static when it belongs to the class as a whole. Say reference where a newcomer might say pointer — Java has none. Distinguish declaring a variable, which gives it a type, from initialising it, which gives it a value. The candidate who calls a checked exception an error I have to catch has grasped the idea; the one who calls it a checked exception and explains that the compiler verifies the handler has shown they can read their own code aloud.
The same precision guards the classic traps. Knowing that == compares references while .equals() compares contents, that a HashMap makes no ordering promise, and that String sorts by character code — these are not trivia. They are the gap between code that behaves the way you assumed and code that behaves the way you stated.
flowchart LR A["name the choice"] --> B["state the trade-off"] B --> C["weigh the alternative"] C --> D["land the decision"] style A fill:#3776ab,color:#fff style D fill:#15803d,color:#fff
In the layered design you built, which component owns the decision of HOW books are stored — the one you would swap when moving from memory to a database?
Storage lives behind the repository interface. The implementation is the single class that changes when the storage technology moves, and because every caller depends on the interface, that swap touches one new line rather than dozens of files.
An interviewer asks why you typed the repository variable as the interface instead of the concrete class. Which answer names the real trade-off?
Programming to the interface buys swappability and testability — you can inject a fake in tests or swap in a database later — but it narrows the variable to the methods the interface declares. That constraint is the price, and it is almost always worth paying.
The reference trap. An object is passed to a method, mutated, and then the local is reassigned. Predict the exact output (two lines) — interviewers love this one.
public class Main {
static class Member {
String name;
int loans;
Member(String name, int loans) {
this.name = name;
this.loans = loans;
}
}
static void checkout(Member m) {
m.loans = m.loans + 1; // mutation is visible to the caller
m = new Member("temp", 0); // reassigning the local is NOT
}
public static void main(String[] args) {
Member ada = new Member("Ada", 2);
checkout(ada);
System.out.println(ada.name);
System.out.println(ada.loans);
}
}
Java is pass-by-value, but the value handed over is a reference — so
mandadastart by pointing at the same object.Mutating
m.loanschanges that shared object, soadasees it.Reassigning
mto a brand-new object only changes the local;adastill points at the original.
The polymorphism trap. A variable is typed as the parent but holds a subclass instance. Predict the exact output (two lines).
public class Main {
static class Book {
String describe() {
return "generic book";
}
}
static class EBook extends Book {
String describe() {
return "ebook with a download";
}
}
public static void main(String[] args) {
Book a = new Book();
Book b = new EBook(); // declared type Book, actual object EBook
System.out.println(a.describe());
System.out.println(b.describe());
}
}
The declared type of
bisBook, which decides which methods are visible.But the actual object is an
EBook, and Java dispatches to the overriding method at run time.So
b.describe()runsEBook's version, notBook's.
Transfer task. A repository is used through its interface, a count is taken, and a missing lookup is checked for null. Predict the exact output (three lines).
import java.util.Arrays;
import java.util.List;
public class Main {
static class Book {
String title, author;
Book(String t, String a) { title = t; author = a; }
}
interface BookRepository {
List<Book> findAll();
Book findByTitle(String title);
}
static class InMemoryRepository implements BookRepository {
private final List<Book> books = Arrays.asList(
new Book("Dune", "Herbert"),
new Book("Dune Messiah", "Herbert"),
new Book("Beloved", "Morrison")
);
public List<Book> findAll() { return books; }
public Book findByTitle(String title) {
for (Book b : books) {
if (b.title.equals(title)) return b;
}
return null;
}
}
static int countByAuthor(BookRepository repo, String author) {
int count = 0;
for (Book b : repo.findAll()) {
if (author.equals(b.author)) count++;
}
return count;
}
public static void main(String[] args) {
BookRepository repo = new InMemoryRepository();
System.out.println(countByAuthor(repo, "Herbert"));
Book found = repo.findByTitle("Dune");
System.out.println(found.author);
Book missing = repo.findByTitle("1984");
System.out.println(missing == null);
}
}
Two books in the repository are by Herbert: Dune and Dune Messiah, so the count is 2.
findByTitle("Dune") returns the matching Book, whose author is Herbert.
No book is titled "1984", so findByTitle returns null and the null check prints true.
Recap
- You built a layered application: the application layer wires concrete classes, the service holds business rules, a repository interface hides storage, and reports run on streams.
- Every decision is a trade-off:
privatebuys a validation choke point, the interface buys swappability and testability, checked exceptions buy compiler-enforced recovery. - Interviews reward precise vocabulary — instance versus static, reference, declare versus initialise — and punish the classic traps (
==versus.equals(), interface-typing, checked versus unchecked). - Defending a design means naming the choice, the trade-off, and the alternative you weighed and set aside.
You have now taken a brief to a working system, and you can explain it in the terms a professional team uses. That is the standard the rest of your Java career is built on.