Module 10 · Capstone: Professional Java Project ⏱ 20 min

Milestone 1: Core Domain Classes

By the end of this lesson you will be able to:
  • Build a class whose constructor enforces invariants and refuses invalid data
  • Protect object state with private final fields and a minimal public surface
  • Spot and fix the classic trap of leaking mutable internal state through a getter

An object that can hold nonsense is an object that will eventually hold nonsense. Give a Book a public copies field and sooner or later somebody writes book.copies = -7; — maybe a typo, maybe a half-finished refactor — and that broken value then spreads through your system, silently, until a report shows a library with negative stock.

The defence is to make invalid state unrepresentable: build classes whose objects simply cannot exist in a broken condition. You do that in one place — the constructor — by checking every argument before it becomes a field, and refusing to build the object if a check fails. Everything you learned about classes comes together in this milestone, which turns the Book and Member entities from the kickoff into properly guarded classes.

flowchart TD
  N["new Book(title, year, copies)"] --> V{"each argument<br/>passes its check?"}
  V -->|"no"| X["throw IllegalArgumentException<br/>object never exists"]
  V -->|"yes"| S["assign the fields<br/>object starts valid"]
  style X fill:#b45309,color:#fff
  style S fill:#3776ab,color:#fff
Every argument must pass its check before it becomes a field. A failed check aborts construction, so no invalid object is ever created.

Validate in the constructor, not after

A constructor is the gate every object walks through exactly once, which makes it the natural place to enforce invariants — the rules an object promises to hold for its entire life. For a Book, sensible invariants are that the title is not blank, the year is plausible, and the copy count is never negative.

The pattern is check-then-assign. Test each argument first; if a test fails, throw an IllegalArgumentException describing the problem. Only if every check passes do you copy the arguments into the fields. Because the throw aborts construction, no half-built object ever escapes — the caller either gets a valid Book or gets nothing at all.

Main.java — a Book whose constructor refuses bad data. Real, compiling Java.
public class Main {
    static class Book {
        private final String title;
        private final String author;
        private final int year;
        private final int copies;

        Book(String title, String author, int year, int copies) {
            if (title == null || title.trim().isEmpty()) {
                throw new IllegalArgumentException("title required");
            }
            if (year < 1450 || year > 2100) {
                throw new IllegalArgumentException("year out of range: " + year);
            }
            if (copies < 0) {
                throw new IllegalArgumentException("copies cannot be negative: " + copies);
            }
            this.title = title;
            this.author = author;
            this.year = year;
            this.copies = copies;
        }

        String getTitle() { return title; }
        int getCopies()   { return copies; }

        public String toString() {
            return title + " x" + copies;
        }
    }

    public static void main(String[] args) {
        Book b = new Book("Dune", "Herbert", 1965, 2);
        System.out.println(b);
    }
}
Exercise

Why put the checks in the constructor instead of trusting callers and cleaning up bad data later?

Exercise

A valid Book prints its summary. Read the program and predict its exact output (one line).

public class Main {
    static class Book {
        private final String title;
        private final int copies;

        Book(String title, int copies) {
            if (title == null || title.trim().isEmpty()) {
                throw new IllegalArgumentException("title required");
            }
            if (copies < 0) {
                throw new IllegalArgumentException("copies cannot be negative");
            }
            this.title = title;
            this.copies = copies;
        }

        public String toString() {
            return title + " x" + copies;
        }
    }

    public static void main(String[] args) {
        Book b = new Book("Dune", 2);
        System.out.println(b);
    }
}

Why throw, instead of returning null

When a constructor cannot build a valid object, it has two honest choices: refuse loudly, or pretend it succeeded and hand back something broken. Some older styles return null or a special empty object and hope the caller notices. That hope is the problem — a null returned where an object was expected spreads silently until someone, far away, calls a method on it and the program crashes with no clue why.

Throwing an unchecked exception such as IllegalArgumentException is the louder choice, and the right one for a programming error. The bad call stops at the source, carrying a message that points straight at the broken value — so the bug becomes easy to find precisely because it refuses to be ignored.

Private final fields, public getters

Two keywords do most of the protective work. private means only code inside Book can see or touch a field, so the outside world cannot scribble on it. final means a field is assigned exactly once — in the constructor — and can never be reassigned afterward. Together they make a field read-only to the world and write-once to the object.

Reading is still allowed, through getters: small public methods like getTitle() that hand a value back without exposing the field itself. You expose exactly the access the domain needs and nothing more. A Book with no setter for copies cannot have its count changed by accident — if the count must change, you add a deliberate method for it, and the rules live there.

flowchart LR
  C["Calling code<br/>outside the class"] -->|"calls"| G["public getters<br/>getTitle, getCopies"]
  G -->|"read from"| F["private final fields<br/>title, year, copies"]
  style G fill:#3776ab,color:#fff
  style F fill:#b45309,color:#fff
Encapsulation: outside code reaches private fields only through public getters — it cannot read or write them directly.
Main.java — a Member whose borrowed count can change, but only through a rule-checking method. Real, compiling Java.
public class Main {
    static class Member {
        private final String name;
        private final int memberId;
        private int booksBorrowed;

        Member(String name, int memberId) {
            if (name == null || name.trim().isEmpty()) {
                throw new IllegalArgumentException("name required");
            }
            if (memberId <= 0) {
                throw new IllegalArgumentException("memberId must be positive");
            }
            this.name = name;
            this.memberId = memberId;
            this.booksBorrowed = 0;
        }

        String getName()       { return name; }
        int getBooksBorrowed() { return booksBorrowed; }

        // The one deliberate way to change the count, with its own rule.
        void borrow() {
            if (booksBorrowed >= 5) {
                throw new IllegalStateException("borrowing limit reached");
            }
            booksBorrowed++;
        }
    }

    public static void main(String[] args) {
        Member m = new Member("Mara", 101);
        m.borrow();
        m.borrow();
        System.out.println(m.getName() + ": " + m.getBooksBorrowed());
    }
}

final where you can, methods where you must

Not every field can be final. A Book's title never changes, so it is final and stays safe. But a Member's borrowed count genuinely changes as books come and go — mark it final and the member could never borrow anything at all.

The resolution is deliberate: keep fields final wherever the value is permanent, and where a value must change, funnel every change through a single method that enforces the rule. borrow() is that method — it checks the borrowing limit before it increments, so the count can rise but can never exceed the cap. Mutability is not the enemy; uncontrolled mutability is.

Exercise

What happens if a caller runs new Book("Dune", -1) against the Book class shown above?

The trap: handing out your internals

Encapsulation looks solid while your fields are int and String, because those cannot be changed by the caller once they have been handed over. The danger arrives the moment a field is a mutable object — a List, a Map, or an array. A getter that does return books; does not hand the caller a copy; it hands them a reference to your internal list. They can now add, remove, or clear it, and your object's private state shifts behind your back.

private is no defence here. The field is private, but you handed out a live reference to it, so the privacy is punctured. This is the most common encapsulation bug in real Java codebases, and it stays invisible until a report shows data nobody remembers adding.

flowchart LR
  S["Shelf internal list<br/>Dune, Beloved"] -->|"getBooks returns<br/>the actual list"| V["caller holds<br/>a reference"]
  V -->|"caller adds HACKED"| S
  style S fill:#b45309,color:#fff
  style V fill:#c2410c,color:#fff
A getter that returns a mutable field hands out a live reference. The caller's mutations flow straight back into the object.
Exercise

A class stores its data in a private List and exposes List<String> getItems() { return items; }. A caller writes shelf.getItems().add("X");. What is the result?

Main.java — the fixed Shelf: callers can read the titles but cannot mutate the internal list. Real, compiling Java.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class Main {
    static class Shelf {
        private final List<String> books = new ArrayList<>();

        void add(String title) { books.add(title); }

        // FIXED: hand back a view callers can read but cannot change.
        List<String> getBooks() {
            return Collections.unmodifiableList(books);
        }
    }

    public static void main(String[] args) {
        Shelf shelf = new Shelf();
        shelf.add("Dune");
        shelf.add("Beloved");
        for (String title : shelf.getBooks()) {   // reads fine
            System.out.println(title);
        }
    }
}
Exercise

The leak, made visible. This getter returns the internal list directly. The caller adds one item to what it received. Predict the exact output (one line).

import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Shelf {
        private final List<String> books = new ArrayList<>();

        void add(String title) { books.add(title); }

        List<String> getBooks() { return books; }   // hands out the real list
    }

    public static void main(String[] args) {
        Shelf shelf = new Shelf();
        shelf.add("Dune");
        shelf.add("Beloved");
        List<String> view = shelf.getBooks();
        view.add("HACKED");
        System.out.println(shelf.getBooks().size());
    }
}

Recap

  • Make invalid state unrepresentable: check every argument in the constructor, and throw IllegalArgumentException if a check fails, so no broken object is ever created.
  • Mark fields private and, wherever the value is permanent, final; expose reading through small getters.
  • Where a value must change, route every change through one method that enforces the rule (like borrow() capping the count).
  • Never return a mutable field directly — hand back an unmodifiable view or a defensive copy, or callers will mutate your private state.

Next you will store many of these objects together and write the search and sort operations the library actually needs.

Checkpoint quiz

Marking a field final means:

What is the safest return for a getter over a private mutable List?

Go deeper — technical resources