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
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.
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);
}
}
Why put the checks in the constructor instead of trusting callers and cleaning up bad data later?
The constructor is the one moment every object passes through. Checking there means a bad argument aborts construction, so no half-built object escapes. Cleaning up later assumes you will spot the bad data in time — and you usually do not.
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);
}
}
All three constructor checks pass, so the object is built normally.
toString builds
title + " x" + copies, joining the title and the count.println calls toString automatically when handed an object.
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
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.
What happens if a caller runs new Book("Dune", -1) against the Book class shown above?
The constructor checks copies before assigning it. A negative value fails the check and throws IllegalArgumentException, which aborts construction — the caller receives an exception, not a broken Book. That is the invariant doing exactly its job.
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 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?
Returning a mutable field hands out a reference to the actual internal list, not a copy. private protected the field's name, but the live reference punches through it — so the caller's add changes the object's private state. Return an unmodifiable view or a defensive copy instead.
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);
}
}
}
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());
}
}
getBooks()returns the actual internal list, not a copy, soviewIS the shelf's list.Adding to
viewtherefore also adds to the shelf.The shelf's list now holds three items, so its size is 3.
Recap
- Make invalid state unrepresentable: check every argument in the constructor, and throw
IllegalArgumentExceptionif a check fails, so no broken object is ever created. - Mark fields
privateand, 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
returna 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.