Module 10 · Capstone: Professional Java Project ⏱ 19 min

Milestone 6: Retrospective + Interview Readiness

By the end of this lesson you will be able to:
  • Reconstruct the layered design you built and name each layer's responsibility in correct Java terms
  • Defend a design decision by stating the trade-off it makes, not merely that it works
  • Predict the output of short Java programs that test the reference, equality, and polymorphism traps interviewers favour

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 the project: a thin application layer wires concrete classes onto a service that depends only on an interface, with reports built from streams.

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.

Main.java — the whole capstone in one file: an encapsulated Book, a repository interface with an in-memory implementation, a checked exception, and a stream report. Real, compiling Java.
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
Three core trades: each decision buys something and costs something else. Naming both halves is what defending a design means.

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.

Main.java — programming to the interface: one method accepts either the real repository or a fake, proving the design is testable. Real, compiling Java.
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
Defending a decision has a repeatable shape: name the choice, state the trade-off, weigh the alternative, then land it.
Exercise

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?

Exercise

An interviewer asks why you typed the repository variable as the interface instead of the concrete class. Which answer names the real trade-off?

Exercise

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);
    }
}
Exercise

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());
    }
}
Exercise

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);
    }
}

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: private buys 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.

Checkpoint quiz

You make BookNotFoundException extend Exception (checked) rather than RuntimeException. What does that buy the callers of your service?

Two Book objects are built separately but hold the same title, author, and year. What does a == b return, and what should you use instead to compare their contents?

Go deeper — technical resources