We have been carrying a debt through the whole module, and the time has come to pay it. The fields of Book, Employee, Loan and Material are still public, and that means any line of any file can write book.available = true skipping lend()'s warnings, or employee.totalLoans = -7, or assign a loan a made-up fine that corresponds to no calculation at all. All the work the constructors do validating data collapses the moment somebody can write directly into the fields. Encapsulation is the pillar that closes that door. But careful: encapsulating is not "putting a getter and a setter on every field" —that mechanical recipe, so often repeated, leaves the object as exposed as before with more lines of code—. Encapsulating is hiding the internal representation and exposing only operations that preserve the business rules. This lesson teaches you the difference, which is what separates an anaemic data model from a genuine domain model.
Contents
- What encapsulating really is
- The four access modifiers
- Complete visibility table
- Getters and setters: what the mechanical recipe does not tell you
- Class invariants
- Business operations instead of setters
- Immutability
- The danger of returning mutable references: defensive copying
- Package-level encapsulation
- The Law of Demeter
- BiblioTech: the encapsulated domain
- Common Mistakes and Tips
- Exercises
- What encapsulating really is
Encapsulating is separating what an object offers from how it does it, so that the interior can be changed without breaking anyone.
Think of a cash machine. Its public interface is: insert card, type PIN, ask for an amount, take the notes. What is inside —the layout of the cassettes, the order in which notes are dispensed, the protocol with the bank— is not only hidden, it is that it must not matter to you. If the cassettes change tomorrow, you still take out money the same way.
Applied to code, encapsulating well produces three concrete benefits:
| Benefit | What it means in practice |
|---|---|
| Guaranteed invariants | If totalLoans can only change through methods, it is impossible for it to be negative |
| Freedom to change the interior | You can replace a field with a calculation without touching the class's users |
| Small error surface | If something goes wrong in an object's state, the culprit is inside its class, not in 40 files |
The second benefit is the most underrated, and it deserves an example. Suppose Loan stores a fine field:
If one day you decide the fine should not be stored but always computed from the days, you have to modify those twenty files. Whereas if from the start you expose a method:
...you can change the implementation as often as you like: the twenty files keep calling getFine() without noticing. A method is a contract; a field is a leak.
- The four access modifiers
Java offers four levels of visibility. Three have a reserved word and one is what applies when you write nothing:
| Modifier | Keyword | Idea in one sentence |
|---|---|---|
| Private | private |
Only inside the class itself |
| Package | (none) | Inside the same package |
| Protected | protected |
Same package and subclasses, wherever they are |
| Public | public |
From anywhere |
public class Book {
private String title; // Book only
String internalNote; // classes in the domain package
protected boolean available; // domain package + subclasses of Book
public String getTitle() { return title; } // everyone
}They apply to classes, fields, methods and constructors. With one nuance: a top-level class can only be public or package-private; private and protected make no sense there (they do in nested classes, lesson 04-03).
- Complete visibility table
This is the table worth keeping to hand until it is internalised:
| Modifier | Same class | Same package | Subclass in another package | Any class |
|---|---|---|---|---|
private |
Yes | No | No | No |
| (default) | Yes | Yes | No | No |
protected |
Yes | Yes | Yes | No |
public |
Yes | Yes | Yes | Yes |
Two nuances that get asked a lot:
About protected. It includes package visibility: a protected member is visible to every class in the same package, whether or not they are subclasses. And from a subclass in another package, it is only accessed through a reference of the subclass's type, not the superclass's. It is a subtle rule that rarely gets in the way in practice.
About private and nested classes. A class can access the private members of another class nested inside it, and vice versa: the unit of encapsulation in Java is the top-level class's file, not each individual class. You will see it in 04-03.
Professional starting criterion:
Declare everything
privateby default. Raise the visibility only when you have a concrete reason, and document that reason.
In particular, be wary of protected on fields: it turns every present and future subclass into a client that depends on your internal representation, and it is exactly the fragile base class scenario you saw in 03-05. If a subclass needs something, prefer giving it a protected method.
- Getters and setters: what the mechanical recipe does not tell you
The recipe taught everywhere is: private fields, and a getX() and a setX() for each. The IDE even generates them on its own. And the result is this:
public class Employee {
private String name;
private int totalLoans;
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public int getTotalLoans() { return totalLoans; }
public void setTotalLoans(int t) { this.totalLoans = t; }
}Ask yourself what has been gained over public fields. The honest answer: almost nothing.
employee.setTotalLoans(-7); // still possible
employee.setTotalLoans(9999); // skips the limit of 3
employee.setName(null); // goodbye to the constructor's validationA setter without validation is a public field with two extra lines. Worse: it gives the false impression of encapsulating.
The correct criterion is to ask, field by field, two things:
- Does anyone outside need to read this data? If not, there is no getter.
- Does it make sense for someone outside to change it to an arbitrary value? If not —and it almost never does—, there is no setter: there is a business operation.
Applied to BiblioTech:
| Field | Getter? | Setter? | Why |
|---|---|---|---|
Book.title |
Yes | No | A book's title does not change |
Book.isbn |
Yes | No | It is its identity |
Material.available |
Yes, as isAvailable() |
No | Changes only through lend() / returnItem() |
Employee.name |
Yes | No | Identity data |
Employee.totalLoans |
Yes | No | Changes through registerLoan() / registerReturn() |
Loan.elapsedDays |
Yes | Yes, validated | It does evolve over time |
Loan.fine |
Yes, computed | Never | It is a consequence, not a piece of data |
That last row deserves a pause. If setFine(double) existed:
loan.setFine(0.0); // I "forgive" a EUR 20 fine without anyone knowing
loan.setFine(1000.0); // I charge a thousand euros for a three-day delayNexus Software's entire fine policy —daily rate, €20 cap, clamping to zero— would be voided by one line. The rule becomes a suggestion.
- Class invariants
An invariant is a condition that must hold always throughout the object's life, from the moment the constructor finishes until it is collected. They are the rules that define what it means for an object to be valid.
BiblioTech's invariants:
| Class | Invariant |
|---|---|
Material |
title is never null or blank |
Material |
reference is never null |
Employee |
0 <= totalLoans <= MAX_CONCURRENT_LOANS |
Loan |
elapsedDays >= 0 |
Loan |
dueDay == loanDay + material.getLoanDays() |
Loan |
0 <= computed fine <= MAX_FINE |
Encapsulation is the mechanism that enforces them, and it works on two fronts:
- The constructor establishes the invariants at birth (03-04).
- The methods are the only ones that can modify the state, and therefore the only ones responsible for maintaining them.
If any field is public, there are no invariants: there are only good intentions.
When a mutator really is needed, it must validate:
/**
* Updates the days elapsed since the loan was made.
* @param days number of days, never negative
*/
public void setElapsedDays(int days) {
if (days < 0) {
System.out.println("WARNING: negative days (" + days + "). The update is ignored.");
return; // the invariant is preserved
}
this.elapsedDays = days;
}As in 03-04: the correct way to reject would be to throw an exception, and that arrives in module 6.
- Business operations instead of setters
This is the lesson's key change of mindset. Instead of asking yourself "what fields does this class have and how do I expose them?", ask "what can happen to this object in the real business?".
In BiblioTech, very concrete things happen to a loan: its return is registered, it is extended, its status is queried. What does not happen to it is "being assigned a fine".
Before, with the mechanical recipe:
// The caller computes, decides and assigns: the rule lives OUTSIDE the object
int late = days - 15;
if (late < 0) late = 0;
double fine = late * 0.25;
if (fine > 20.0) fine = 20.0;
loan.setDaysLate(late);
loan.setFine(fine);
loan.setReturned(true);
book.setAvailable(true);
employee.setTotalLoans(employee.getTotalLoans() - 1);Five calls that must all be made and in order. If the caller forgets one, the system is left inconsistent: a returned loan whose book is still marked as on loan.
After, with a business operation:
A single call, impossible to leave half-done:
/**
* Registers the return of the material after the given number of days.
* Calculates the fine according to the rules in force, releases the material
* and discounts the loan from the employee.
*
* @param elapsedDays days since handover, never negative
* @return amount of the applied fine, between 0 and MAX_FINE
*/
public double registerReturn(int elapsedDays) {
if (returned) {
System.out.println("WARNING: loan " + reference + " had already been returned.");
return appliedFine;
}
setElapsedDays(elapsedDays); // validates the invariant
this.appliedFine = material.calculateFine(this.elapsedDays);
this.returned = true;
material.returnItem(); // the material goes back on the shelf
employee.registerReturn(); // the employee frees up a slot
return appliedFine;
}Compare the two versions in a table:
| Criterion | Five setters | registerReturn(int) |
|---|---|---|
| Where the fine rule lives | In the caller (and in every caller) | In Loan, only once |
| Can be left half-done | Yes | No |
| The fine can be faked | Yes | No |
What you read in main |
Arithmetic | A business sentence |
| Changing the rate affects | Every caller | One constant |
That is the difference between an anaemic model (objects that only hold data, with the logic outside) and a rich domain model (objects that protect their rules). The first is OOP only in its syntax.
- Immutability
An immutable object is one whose state cannot change after construction. It is achieved with four rules:
- All fields
private final. - No setter and no method that modifies the state.
- The class declared
final, so that no subclass adds mutability. - If any field is a mutable reference, it is not exposed directly (section 8).
The advantages, and there are many:
| Advantage | Explanation |
|---|---|
| Impossible to corrupt the state | There is no way to leave it invalid after the constructor |
| Thread-safe | With no writes, there are no race conditions (module 8) |
| Can be shared fearlessly | Copying and sharing become equivalent |
| Good candidate for a key | Its hashCode never changes (lesson 03-09 and module 5) |
| Easier to reason about | If you saw it valid once, it will always be |
String is the canonical example, and you already suffered its consequences in module 1: text.toUpperCase() does not change text, it returns another string.
Why is Book a good candidate for immutability? Because its bibliographic data —title, author, ISBN, year— never changes. A book published in 2018 will not become a 2019 one, and its ISBN is its identity. The only thing that changes is availability, and that is a property of the lent copy, arguably even of another class.
A fully immutable Book would look like this:
public final class ImmutableBook {
private final String title;
private final String author;
private final String isbn;
private final int publicationYear;
public ImmutableBook(String title, String author, String isbn, int publicationYear) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.publicationYear = publicationYear;
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getIsbn() { return isbn; }
public int getPublicationYear() { return publicationYear; }
/** "Modifying" an immutable means creating another one. */
public ImmutableBook withTitle(String newTitle) {
return new ImmutableBook(newTitle, author, isbn, publicationYear);
}
}Look at withTitle: in the immutable world you do not modify, you derive a new object. It is the same pattern as String.toUpperCase().
Note on
record. Writing an immutable class like this is so repetitive that Java 16 introduced records:public record ImmutableBook(String title, String author, String isbn, int publicationYear) { }automatically generates theprivate finalfields, the constructor, the accessors, and alsoequals,hashCodeandtoString. They are studied in lesson 04-07. Write the manual version first: understanding what therecordgenerates is more useful than using it blindly.
In BiblioTech we will keep available mutable —the copy is lent and returned— but everything else final. It is a partial immutability, and it is a reasonable compromise: it minimises what can change.
- The danger of returning mutable references: defensive copying
Here is the subtlest encapsulation leak and the one that ruins the most "apparently correct" code. Look:
public class Loan {
private final String[] incidents; // array: provisional solution (module 5)
public String[] getIncidents() {
return incidents; // LEAK!
}
}The field is private. The field is final. And even so:
String[] outside = loan.getIncidents();
outside[0] = "Made-up incident"; // the loan's interior has been modifiedfinal protects the reference, not the object it points to (you saw it in 03-02). By returning the array, you have handed the outside world a key to your object's interior. The invariant is no longer guaranteed.
The solution is the defensive copy: return a copy, not the original.
/**
* @return a copy of the recorded incidents; modifying it does not affect the loan
*/
public String[] getIncidents() {
return java.util.Arrays.copyOf(incidents, incidents.length);
}The same danger exists on the way in. If a constructor stores an array it is given, the caller keeps a reference to it:
// Naive constructor
public Loan(String[] incidents) {
this.incidents = incidents; // the caller can keep modifying it
}
// Constructor with a defensive copy
public Loan(String[] incidents) {
this.incidents = (incidents == null)
? new String[0]
: java.util.Arrays.copyOf(incidents, incidents.length);
}General rule:
Copy defensively on receiving and on returning any mutable object that forms part of your class's state.
When is it unnecessary? When the object is immutable. String, Integer or an ImmutableBook can be returned directly with no risk, because nobody can alter them. It is another argument in favour of immutability: it eliminates a whole category of precautions.
And an important qualification: Loan.getMaterial() returns a mutable Material (its available changes). Should it be copied? No, and here is the nuance: the material is not an internal part of the loan, it is a shared entity of the catalog. Having two loans and the catalog see the same object is precisely what we want. The defensive copy applies to what is part of the object (composition), not to what is a collaborator (association). Go back to the relationships table from lesson 03-01: that distinction, which looked theoretical there, has a direct practical consequence here.
- Package-level encapsulation
Encapsulation does not stop at the class. Packages are the second barrier, and BiblioTech's current organisation already anticipates it:
com.nexussoftware.bibliotech
├── BiblioTechApp.java startup and presentation
├── domain/
│ ├── Material.java business rules
│ ├── Book.java
│ ├── Magazine.java
│ ├── Dvd.java
│ ├── Employee.java
│ └── Loan.java
└── service/ (from module 5 onwards)
└── LoanManager.java use-case orchestrationThe criterion: public only what another package genuinely needs. A helper class used only by the domain can be declared without public, and then it is invisible from outside. That gives you total freedom to change it, because you are certain nobody else uses it.
Java 9 took this idea one level higher with the module system and module-info.java, which lets you declare which packages an entire library exports. It is studied in lesson 10-06.
- The Law of Demeter
The Law of Demeter, or principle of least knowledge, boils down to one sentence:
Talk only to your immediate friends, not to your friends' friends.
In code: avoid chains of dots that traverse several objects.
// BAD: main knows the internal structure of Loan AND of Material
String title = loan.getMaterial().getTitle();
if (loan.getEmployee().getTotalLoans() >= 3) { ... }
// GOOD: each object answers for itself
String title = loan.getMaterialTitle();
if (loan.employeeAtLimit()) { ... }Why does it matter? Because loan.getMaterial().getTitle() couples main to two classes: if Material replaces getTitle() with something else, every place that chained has to be touched. With loan.getMaterialTitle(), the change affects only Loan.
Two clarifications so as not to apply it as dogma:
- Do not count dots:
System.out.println(...)orsb.append(a).append(b)chain and are perfectly fine. What the law discourages is navigating someone else's structure in order to make decisions. - Taking it to the extreme generates piles of trivial delegating methods. Apply it where the coupling hurts: in business decisions.
- BiblioTech: the encapsulated domain
Let us refactor the project's classes. This is the state they end up in.
Material
package com.nexussoftware.bibliotech.domain;
/** Lendable material from Nexus Software's catalog. */
public class Material {
public static final int LOAN_DAYS = 15;
public static final double DAILY_RATE = 0.25;
public static final double MAX_FINE = 20.0;
public static final int MINOR_THRESHOLD = 7;
private static int materialsCreated = 0;
private final String title;
private final String reference;
private boolean available;
public Material(String title, String reference, boolean available) {
this.title = (title == null || title.isBlank()) ? "Untitled" : title.trim();
this.reference = (reference == null || reference.isBlank())
? "000-0000000000" : reference.trim();
this.available = available;
materialsCreated++;
}
// --- Queries (getters only where they make sense) ---
public String getTitle() { return title; }
public String getReference() { return reference; }
public boolean isAvailable() { return available; }
public static int getMaterialsCreated() { return materialsCreated; }
// --- Parameters each format redefines ---
public int getLoanDays() { return LOAN_DAYS; }
public double getDailyRate() { return DAILY_RATE; }
public String getType() { return "Material"; }
// --- Business rules ---
public int calculateDaysLate(int elapsedDays) {
return Math.max(0, elapsedDays - getLoanDays());
}
public double calculateFine(int elapsedDays) {
return Math.min(calculateDaysLate(elapsedDays) * getDailyRate(),
MAX_FINE);
}
public String classifySeverity(int elapsedDays) {
int late = calculateDaysLate(elapsedDays);
if (late == 0) return "ON TIME";
if (late <= MINOR_THRESHOLD) return "MINOR";
return "SEVERE";
}
// --- Business operations (there is NO setAvailable) ---
/** @return true if the material could be lent. */
public boolean lend() {
if (!available) {
System.out.println("WARNING: '" + title + "' was already on loan.");
return false;
}
available = false;
return true;
}
/** @return true if the material could be returned. */
public boolean returnItem() {
if (available) {
System.out.println("WARNING: '" + title + "' was already available.");
return false;
}
available = true;
return true;
}
public String describe() {
return title + " (ref. " + reference + ")";
}
}Note the change in lend() and returnItem(): they now return boolean instead of void. That way the caller can know whether the operation had any effect, without having to read the field.
And note what is not there: setTitle, setReference or setAvailable. Availability changes only through the two business operations.
Book
package com.nexussoftware.bibliotech.domain;
/** Technical book from the catalog. Its bibliographic data is immutable. */
public class Book extends Material {
private final String author;
private final int publicationYear;
public Book(String title, String author, String isbn,
int publicationYear, boolean available) {
super(title, isbn, available);
this.author = (author == null || author.isBlank()) ? "Unknown" : author.trim();
this.publicationYear = (publicationYear < 1450 || publicationYear > 2100)
? 0 : publicationYear;
}
public Book(String title, String author, String isbn, int publicationYear) {
this(title, author, isbn, publicationYear, true);
}
public String getAuthor() { return author; }
public int getPublicationYear() { return publicationYear; }
/** The ISBN is a book's reference. */
public String getIsbn() { return getReference(); }
@Override public String getType() { return "Book"; }
@Override
public String describe() {
return super.describe() + " - " + author + ", " + publicationYear;
}
}Notice that Book reaches the title through the inherited getTitle(), not directly: Material's field is private and not even its subclass can touch it. That would let Material change its internal representation tomorrow without breaking Book.
Employee
package com.nexussoftware.bibliotech.domain;
/** Nexus Software employee authorised to take materials on loan. */
public class Employee {
public static final int MAX_CONCURRENT_LOANS = 3;
private static int employeesRegistered = 0;
private final String name;
private final String identifier;
private int totalLoans; // invariant: 0..MAX
public Employee(String name, String identifier, int totalLoans) {
this.name = (name == null || name.isBlank())
? "Unnamed employee" : name.trim();
this.identifier = (identifier == null || !identifier.startsWith("EMP-"))
? "EMP-000" : identifier.trim();
this.totalLoans = Math.max(0,
Math.min(totalLoans, MAX_CONCURRENT_LOANS));
employeesRegistered++;
}
public Employee(String name, String identifier) {
this(name, identifier, 0);
}
public String getName() { return name; }
public String getIdentifier() { return identifier; }
public int getTotalLoans() { return totalLoans; }
public boolean canBorrow() { return totalLoans < MAX_CONCURRENT_LOANS; }
public static int getEmployeesRegistered() { return employeesRegistered; }
/** @return true if the loan could be registered. */
public boolean registerLoan() {
if (!canBorrow()) {
System.out.printf("WARNING: %s already has %d loans (maximum %d).%n",
name, totalLoans, MAX_CONCURRENT_LOANS);
return false;
}
totalLoans++;
return true;
}
public void registerReturn() {
totalLoans = Math.max(0, totalLoans - 1);
}
public String getInitials() {
StringBuilder sb = new StringBuilder();
for (String part : name.trim().split(" ")) {
if (!part.isEmpty()) {
sb.append(part.charAt(0)).append('.');
}
}
return sb.toString().toUpperCase();
}
}The invariant 0 <= totalLoans <= MAX is guaranteed at the only three points where the field changes: the constructor (with Math.max/Math.min), registerLoan() (with the guard) and registerReturn() (with Math.max). There is no fourth door. That is encapsulation.
Loan
package com.nexussoftware.bibliotech.domain;
import java.util.Arrays;
/** Record of a loan of a material to an employee. */
public class Loan {
private static int loansCreated = 0;
private final Material material;
private final Employee employee;
private final int loanDay;
private final int dueDay;
private final String reference;
private int elapsedDays;
private boolean returned;
private double appliedFine;
private String[] incidents; // provisional array (module 5)
public Loan(Material material, Employee employee,
int loanDay, int elapsedDays) {
this.material = (material != null) ? material
: new Book("Unknown material", "Unknown", "000-0000000000", 0, false);
this.employee = (employee != null) ? employee
: new Employee("Unknown employee", "EMP-000");
this.loanDay = Math.max(0, loanDay);
this.dueDay = this.loanDay + this.material.getLoanDays();
this.elapsedDays = Math.max(0, elapsedDays);
this.returned = false;
this.appliedFine = 0.0;
this.incidents = new String[0];
loansCreated++;
this.reference = String.format("LN-%04d", loansCreated);
this.material.lend();
this.employee.registerLoan();
}
public Loan(Material material, Employee employee, int loanDay) {
this(material, employee, loanDay, 0);
}
// ---------- Queries ----------
public String getReference() { return reference; }
public Material getMaterial() { return material; } // shared collaborator
public Employee getEmployeeDoNotUse() { return employee; } // see note below
public int getLoanDay() { return loanDay; }
public int getDueDay() { return dueDay; }
public int getElapsedDays() { return elapsedDays; }
public boolean isReturned() { return returned; }
/** Delegations that comply with the Law of Demeter. */
public String getMaterialTitle() { return material.getTitle(); }
public String getEmployeeName() { return employee.getName(); }
/** @return defensive copy: modifying it does not affect the loan. */
public String[] getIncidents() {
return Arrays.copyOf(incidents, incidents.length);
}
public static int getLoansCreated() { return loansCreated; }
// ---------- Derived rules (computed, never stored) ----------
public int calculateDaysLate() { return material.calculateDaysLate(elapsedDays); }
public double calculateFine() { return material.calculateFine(elapsedDays); }
public String classifySeverity() { return material.classifySeverity(elapsedDays); }
public boolean isOverdue() { return calculateDaysLate() > 0; }
public int daysRemaining() { return Math.max(0, dueDay - (loanDay + elapsedDays)); }
// ---------- Business operations ----------
/**
* Updates the elapsed days. The only mutator, and validated.
* @param days days since handover; negative values are ignored
*/
public void setElapsedDays(int days) {
if (days < 0) {
System.out.println("WARNING: negative days in " + reference + ". Ignored.");
return;
}
this.elapsedDays = days;
}
/**
* Registers the return: computes and sets the fine according to the rules
* in force, releases the material and discounts the loan from the employee.
*
* @param elapsedDays days since handover
* @return applied fine, between 0 and MAX_FINE
*/
public double registerReturn(int elapsedDays) {
if (returned) {
System.out.println("WARNING: " + reference + " had already been returned.");
return appliedFine;
}
setElapsedDays(elapsedDays);
this.appliedFine = calculateFine();
this.returned = true;
material.returnItem();
employee.registerReturn();
return appliedFine;
}
/** @return the fine actually applied; 0 while it has not been returned. */
public double getAppliedFine() { return appliedFine; }
/** Records an incident of the loan (tears, loose pages...). */
public void recordIncident(String text) {
if (text == null || text.isBlank()) {
return;
}
String[] extended = Arrays.copyOf(incidents, incidents.length + 1);
extended[incidents.length] = text.trim();
this.incidents = extended; // module 5 will do this much better
}
public boolean employeeAtLimit() { return !employee.canBorrow(); }
}About
getEmployeeDoNotUse. That deliberately ugly name flags a design decision: exposing the wholeEmployeelets anyone doloan.getEmployee().registerLoan()and throw the system out of balance. That is why the loan offersgetEmployeeName()andemployeeAtLimit()instead. In lesson 03-08 you will formally reduce this class's public surface, and that getter will be one of the first to go.
Use from BiblioTechApp
Book effectiveJava = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Employee marta = new Employee("Marta Ruiz", "EMP-001");
Loan l = new Loan(effectiveJava, marta, 100, 0);
System.out.printf("%s: '%s' to %s, due on day %d%n",
l.getReference(), l.getMaterialTitle(),
l.getEmployeeName(), l.getDueDay());
System.out.println("Available after lending: " + effectiveJava.isAvailable());
l.recordIncident("Page 42 underlined");
double fine = l.registerReturn(20);
System.out.printf("Returned %d days late. Fine: %.2f EUR (%s)%n",
l.calculateDaysLate(), fine, l.classifySeverity());
System.out.println("Available after returning: " + effectiveJava.isAvailable());
System.out.println("Marta's loans: " + marta.getTotalLoans());
// Attempts to break the invariants from outside:
// effectiveJava.available = true; // DOES NOT COMPILE: private field
// l.appliedFine = 0.0; // DOES NOT COMPILE: private field
// marta.totalLoans = 99; // DOES NOT COMPILE: private field
l.setElapsedDays(-5); // warns and is ignoredOutput:
LN-0001: 'Effective Java' to Marta Ruiz, due on day 115 Available after lending: false Returned 5 days late. Fine: 1.25 EUR (MINOR) Available after returning: true Marta's loans: 0 WARNING: negative days in LN-0001. Ignored.
The three commented lines are what matters in this lesson: they no longer compile. Nexus Software's rules have stopped depending on the goodwill of whoever uses the classes.
Common Mistakes and Tips
- Confusing encapsulation with "adding getters and setters". A public setter with no validation leaves the object as open as a public field. Encapsulating is deciding which operations make sense, not automating access.
- Generating all the accessors with the IDE without thinking. The generator does not know which fields must be immutable. Generate and then delete whatever should not exist.
- Returning an internal mutable reference. The mistake from section 8.
finaldoes not protect the object pointed to. - Using
protectedon fields "just in case a subclass needs it". It turns the internal representation into public API for every subclass. Prefer aprotectedmethod. - Storing derived data. If
finecan be computed from the days, compute it. Duplicated data is data that can fall out of sync. (InLoanwe storeappliedFineonly after the return, because then it is a recorded fact, not a live calculation.) - Long chains of getters.
a.getB().getC().getD()couples your code to three classes. Delegate. - Making a domain helper class
public. If only its own package uses it, leave it with no modifier. - Tip: write the class from the outside in. First decide which operations it will offer, then which fields it needs to fulfil them. The other way round you end up with getters for everything.
- Tip: if a field does not change, make it
finaltoday. It is the cheapest way to document and guarantee an invariant. - Tip: try commenting out a getter. If the project still compiles, delete it. The safest public API is the smallest one.
Exercises
Exercise 1: encapsulate Reservation
Take the Reservation class from lesson 03-04 (room, requester, start hour, end hour, attendees, active, code) and encapsulate it properly:
- Make every field
private, andfinalthose that must not change. - Decide, field by field, whether it needs a getter, justifying it in a comment.
- Replace any setter with business operations:
cancel(),extendOneHour()andchangeAttendees(int)(which must reject out-of-range values). - List the class's invariants in Javadoc and check that no operation can break them.
Exercise 2: find and close an encapsulation leak
This code has three different encapsulation leaks. Identify them, explain how each one would be exploited from outside, and fix them:
public class EmployeeHistory {
private final String name;
private final String[] loanReferences;
private int totalFines;
public EmployeeHistory(String name, String[] loanReferences) {
this.name = name;
this.loanReferences = loanReferences;
}
public String[] getLoanReferences() {
return loanReferences;
}
public void setTotalFines(int totalFines) {
this.totalFines = totalFines;
}
}Exercise 3: from anaemic model to rich model
A colleague has written this logic in BiblioTechApp using setters. Refactor it by moving each rule to the domain class it belongs to, so that main is left with a single call. State which new methods appear and in which class.
// In main
if (marta.getTotalLoans() < 3 && effectiveJava.isAvailable()) {
effectiveJava.setAvailable(false);
marta.setTotalLoans(marta.getTotalLoans() + 1);
Loan l = new Loan();
l.setMaterial(effectiveJava);
l.setEmployee(marta);
l.setLoanDay(100);
l.setDueDay(100 + 15);
System.out.println("Loan registered");
} else {
System.out.println("Cannot be lent");
}Solutions
Solution 1
package com.nexussoftware.bibliotech.domain;
/**
* Reservation of a Nexus Software meeting room.
*
* Invariants guaranteed throughout the object's life:
* - 0 <= startHour <= 23
* - startHour < endHour <= 23
* - attendees >= 1
* - code non-null and unique
*/
public class Reservation {
private static int reservationsCreated = 0;
private final String room; // no setter: changing room is another reservation
private final Employee requester; // no setter: the reservation's identity
private final int startHour; // no setter: fixed when booking
private final String code; // no setter: generated by the system
private int endHour; // mutates only through extendOneHour()
private int attendees; // mutates only through changeAttendees()
private boolean active; // mutates only through cancel()
public Reservation(String room, Employee requester,
int startHour, int endHour, int attendees) {
this.room = (room == null || room.isBlank()) ? "Unnamed room" : room.trim();
this.requester = (requester != null) ? requester
: new Employee("Unknown employee", "EMP-000");
this.startHour = (startHour < 0 || startHour > 23) ? 9 : startHour;
if (endHour <= this.startHour || endHour > 23) {
this.endHour = Math.min(23, this.startHour + 1);
} else {
this.endHour = endHour;
}
this.attendees = Math.max(1, attendees);
this.active = true;
reservationsCreated++;
this.code = String.format("RES-%04d", reservationsCreated);
}
// --- Queries ---
public String getCode() { return code; } // the user asks for it
public String getRoom() { return room; } // shown in listings
public int getStartHour() { return startHour; } // shown
public int getEndHour() { return endHour; } // shown
public int getAttendees() { return attendees; } // shown
public boolean isActive() { return active; } // queried when listing
public int durationHours() { return endHour - startHour; }
/** Delegation: stops the outside world navigating to the Employee. */
public String getRequesterName() { return requester.getName(); }
// There is NO getRequester(): it would expose a mutable domain object.
// --- Business operations (no setters at all) ---
/** @return true if the reservation has just been cancelled. */
public boolean cancel() {
if (!active) {
System.out.println("WARNING: reservation " + code + " was already cancelled.");
return false;
}
active = false;
return true;
}
/** @return true if it could be extended without running past the day. */
public boolean extendOneHour() {
if (!active) {
System.out.println("WARNING: a cancelled reservation cannot be extended.");
return false;
}
if (endHour >= 23) {
System.out.println("WARNING: reservation " + code + " already reaches the end of the day.");
return false;
}
endHour++; // the invariant startHour < endHour holds
return true;
}
/** @return true if the change of attendees has been applied. */
public boolean changeAttendees(int newCount) {
if (newCount < 1) {
System.out.println("WARNING: invalid number of attendees (" + newCount + ").");
return false;
}
attendees = newCount;
return true;
}
}Justification of the key decisions: room, requester, startHour and code are final because changing them would turn the reservation into a different one; endHour, attendees and active do mutate, but only through operations that validate, so the three invariants in the Javadoc hold at all times. And there is no getRequester(): exposing the Employee would let anyone alter it from a reservation, which is exactly the coupling the Law of Demeter discourages.
Solution 2
Leak 1: the constructor stores the array it receives.
String[] refs = { "LN-0001", "LN-0002" };
EmployeeHistory h = new EmployeeHistory("Marta Ruiz", refs);
refs[0] = "FAKE REFERENCE"; // h's interior has been modifiedThe caller keeps a reference to the very array that is now the object's internal state.
Leak 2: the getter returns the internal array.
Neither private nor final protects: final prevents reassigning the variable, not modifying the array.
Leak 3: setTotalFines allows any value.
It is a setter with no validation over cumulative data that should only grow through business operations.
Corrected version:
package com.nexussoftware.bibliotech.domain;
import java.util.Arrays;
/**
* Loan history of an employee.
* Invariants: loanReferences is never null; totalFines >= 0.
*/
public class EmployeeHistory {
private final String name;
private final String[] loanReferences;
private int totalFines;
public EmployeeHistory(String name, String[] loanReferences) {
this.name = (name == null || name.isBlank()) ? "Unknown" : name.trim();
// DEFENSIVE COPY on the way in: the caller can no longer reach our state.
this.loanReferences = (loanReferences == null)
? new String[0]
: Arrays.copyOf(loanReferences, loanReferences.length);
this.totalFines = 0;
}
public String getName() { return name; }
public int getTotalFines() { return totalFines; }
public int getLoanCount() { return loanReferences.length; }
/** @return defensive copy; modifying it does not affect the history. */
public String[] getLoanReferences() {
return Arrays.copyOf(loanReferences, loanReferences.length);
}
/**
* Adds a fine to the history. It replaces the setter: the total can
* only grow, and only with valid amounts.
*
* @param amount positive amount in euros
* @return true if it has been accumulated
*/
public boolean addFine(int amount) {
if (amount <= 0) {
System.out.println("WARNING: invalid fine amount (" + amount + ").");
return false;
}
totalFines += amount;
return true;
}
}Check that the leaks are closed:
String[] refs = { "LN-0001", "LN-0002" };
EmployeeHistory h = new EmployeeHistory("Marta Ruiz", refs);
refs[0] = "FAKE REFERENCE"; // no longer has any effect
h.getLoanReferences()[1] = "ANOTHER FAKE"; // modifies a copy
h.addFine(-500); // rejected with a warning
System.out.println(Arrays.toString(h.getLoanReferences())); // [LN-0001, LN-0002]
System.out.println(h.getTotalFines()); // 0A performance note: copying on every call has a cost. When it matters, the alternatives are returning a read-only view (List.copyOf, module 5) or making the content immutable. The starting rule is still to copy: correctness first, optimisation afterwards and only with measurements.
Solution 3
The fragment checks preconditions, mutates three objects and computes the due day from outside. All of that belongs to the domain.
The complete rule "can this material be lent to this employee?" has two parts that already live in their classes (isAvailable() and canBorrow()), and there is no place to combine them. Since Loan is what relates them, a static pre-check method is added there:
In Loan:
/**
* Says whether it is possible to register a loan of this material to this employee.
* @return true if the material is available and the employee has not reached the limit
*/
public static boolean canBeLent(Material material, Employee employee) {
return material != null && employee != null
&& material.isAvailable()
&& employee.canBorrow();
}And the canonical constructor already does the rest —it marks the material, increments the employee's counter and computes the due day with loanDay + material.getLoanDays()—, so main ends up like this:
if (Loan.canBeLent(effectiveJava, marta)) {
Loan l = new Loan(effectiveJava, marta, 100);
System.out.println("Loan " + l.getReference() + " registered, due on day "
+ l.getDueDay());
} else {
System.out.println("Cannot be lent");
}Comparison:
| Before | After |
|---|---|
11 lines in main |
1 check call + 1 constructor |
The 15-day term written in main |
Supplied by the material (and it works with magazines and DVDs) |
| Three manual mutations that can be forgotten | Done by the constructor, always |
Public setAvailable |
Does not exist |
If a new rule is added, you have to hunt for it in every main |
It is added in canBeLent |
New methods: just one, Loan.canBeLent(Material, Employee). The rest already existed. And every setter from the original fragment disappears: setAvailable, setTotalLoans, setMaterial, setEmployee, setLoanDay and setDueDay. Six doors closed.
One detail worth discussing: canBeLent is static because it answers a question before the loan exists. It is one of the few occasions when a static method in a domain class is justified.
Conclusion
You have closed the door that had been open all module. You know that encapsulating is not adding accessors but hiding the representation and exposing only operations that preserve the rules; you know the four access modifiers and the complete visibility table, and you have the professional criterion of starting from private and raising visibility only with a reason. You know how to decide field by field whether it deserves a getter and, above all, why a setter for every field destroys what it was meant to protect: setFine would turn Nexus Software's fine policy into a suggestion. You have learned to formulate a class's invariants and to guarantee them by closing every door through which the state can change. You handle immutability —final fields, no setters, final class— and you understand why a Book's bibliographic data is its ideal case. And you know the subtlest leak of all: returning an internal mutable reference, with the defensive copy as the remedy on the way in and on the way out, and with the nuance that a shared collaborator such as Material is not copied. You close with package-level encapsulation and the Law of Demeter.
BiblioTech's domain can no longer be corrupted from outside: effectiveJava.available = true does not compile, and the three invariants of Employee, Loan and Material hold because there are only a few methods that can touch them.
But a new problem has appeared, and you saw it in getEmployeeDoNotUse: the public surface of your classes has grown out of control. Loan exposes more than twenty methods, some of which nobody should call. Encapsulation answers how to hide; what is left is answering what to expose. In the next lesson, Abstraction, you will learn to decide what goes into a class's public API and what does not, not to mix abstraction levels inside a single method, to document contracts with preconditions and postconditions, and to recognise both leaky abstractions and the opposite vice, abstracting what nobody asked for.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
