JAVA / CLASSES, CONSTRUCTORS AND ENCAPSULATION
Getters, setters and guarding invariants
Write getters and setters that enforce a class's rules: validate before assigning, reject bad input with exceptions, and stop getters leaking mutable state.
What you will learn
- Validate in the setter and throw IllegalArgumentException before touching the field
- Check the incoming value against the object's other fields, not just its own range
- Return copies or unmodifiable views so a getter cannot hand out your internals
- Skip the setter entirely for fields no caller should change, or derive them instead
Understanding Getters, setters and guarding invariants
Every class carries facts its own methods quietly rely on: a temperature range where low never exceeds high, a total that is never zero because something divides by it, a roster that never grows past its cap. Those facts are the class's invariants, and the reason fields are private is not secrecy but funnelling: if every write has to pass through code you wrote, there is exactly one place a bad value can be turned away. A getter and setter pair that only copies a value in and out adds nothing over a public field; the point of the setter is what it is allowed to refuse.
Order matters inside a setter. Check first, assign second, so a rejected call leaves the object exactly as it was and the caller who catches the exception is still holding something usable. Validation usually cannot look at the argument alone either: setHigh(10) is reasonable in isolation and wrong when low is already 16, so the check has to consult the object's current state. IllegalArgumentException says the value handed in was wrong; IllegalStateException says the value was fine but the object cannot accept it right now, such as hiring onto a full team.
A getter can break an invariant just as easily as a setter, and more quietly. If a field holds a List, an array, a StringBuilder or any other mutable object, returning that field hands the caller the very object your rules protect, and everything they do to it skips your checks. Return Collections.unmodifiableList(items) or a fresh copy instead, and remember the opposite freedom too: a field deserves a getter only if callers need the value, and a getter needs no field behind it when the value can be computed from what you already store.
public class Main {
static class TemperatureRange {
private int low;
private int high;
TemperatureRange(int low, int high) {
if (low > high) {
throw new IllegalArgumentException("low " + low + " exceeds high " + high);
}
this.low = low;
this.high = high;
}
public int getLow() {
return low;
}
public int getHigh() {
return high;
}
public void setLow(int low) {
if (low > high) { // compare against the other field
throw new IllegalArgumentException("low " + low + " would exceed high " + high);
}
this.low = low; // assign only after the check passes
}
public void setHigh(int high) {
if (high < low) {
throw new IllegalArgumentException("high " + high + " would fall below low " + low);
}
this.high = high;
}
public int span() {
return high - low; // safe: low <= high always holds
}
}
public static void main(String[] args) {
TemperatureRange r = new TemperatureRange(16, 24);
System.out.println("span = " + r.span());
r.setHigh(30);
System.out.println("high = " + r.getHigh() + ", span = " + r.span());
try {
r.setLow(35);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("low is still " + r.getLow() + ", span = " + r.span());
}
}A setter earns its existence by being the single gate that can reject a value, and a getter by never handing out a reference through which that gate can be bypassed.
Worked examples
A getter that leaks the list
Shows how returning the internal collection lets callers break a limit the class enforces in its own methods.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Main {
static class Team {
private static final int MAX = 3;
private final List<String> members = new ArrayList<>();
public void hire(String name) {
if (members.size() == MAX) {
throw new IllegalStateException("team already has " + MAX + " members");
}
members.add(name);
}
public List<String> getMembersLeaky() {
return members; // the real list
}
public List<String> getMembers() {
return Collections.unmodifiableList(members); // read-only view
}
public int size() {
return members.size();
}
}
public static void main(String[] args) {
Team team = new Team();
team.hire("ana");
team.hire("bo");
team.getMembersLeaky().add("cy");
team.getMembersLeaky().add("dee");
System.out.println("size after leak: " + team.size());
try {
team.getMembers().add("eve");
} catch (UnsupportedOperationException e) {
System.out.println("view refused the add");
}
System.out.println(String.join(", ", team.getMembers()));
}
}Example explained
Line 1hire() is the guarded door: it throws once members.size() reaches MAX.
Line 2getMembersLeaky() returns the field itself, so the caller now holds the same ArrayList the class depends on.
Line 3The two adds through that reference never run hire(), so size becomes 4 and the cap of 3 is already broken.
Line 4Collections.unmodifiableList wraps the list so add throws UnsupportedOperationException, closing the second write path.
One private check, plus a derived getter
Shares a single validation rule between the constructor and a setter, and computes a value instead of storing it.
public class Main {
static final class Progress {
private int done;
private int total;
Progress(int total) {
this.total = checkTotal(total, this.done); // done is still 0 here
}
private static int checkTotal(int total, int done) {
if (total <= 0) {
throw new IllegalArgumentException("total must be positive, got " + total);
}
if (total < done) {
throw new IllegalArgumentException("total " + total + " is below done " + done);
}
return total;
}
public void setTotal(int total) {
this.total = checkTotal(total, this.done);
}
public void setDone(int done) {
if (done < 0 || done > total) {
throw new IllegalArgumentException("done must be in 0.." + total + ", got " + done);
}
this.done = done;
}
public int getDone() {
return done;
}
public int getTotal() {
return total;
}
public int getPercent() {
return done * 100 / total; // no field behind this getter
}
public boolean isComplete() {
return done == total;
}
}
public static void main(String[] args) {
Progress p = new Progress(8);
p.setDone(3);
System.out.println(p.getPercent() + "% complete=" + p.isComplete());
p.setDone(8);
System.out.println(p.getPercent() + "% complete=" + p.isComplete());
try {
p.setTotal(5);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage());
}
System.out.println("total stays " + p.getTotal() + ", percent " + p.getPercent());
}
}Example explained
Line 1checkTotal is one private rule used by both the constructor and setTotal, so no write path skips it.
Line 2setTotal(5) fails not because 5 is a bad number but because done is already 8, which is why the check takes the object's state as input.
Line 3The throw happens inside checkTotal before this.total is assigned, so after the catch the object is untouched.
Line 4getPercent stores nothing: 3 * 100 / 8 is integer division, giving 37, and it can never fall out of sync with done and total.
Important notes
Collections.unmodifiableList gives a view, not a snapshot: the caller cannot add through it, but they will see every later change you make, so use new ArrayList<>(members) when you need a frozen copy.
Keep the getX/setX and isX naming for booleans; tools such as Jackson, JavaFX and JSP EL discover properties by that pattern, so isActive() is found where active() is not.
Common mistakes
Generating a setter for every field out of habit: setLow(999) is then perfectly legal, the invariant survives only as a comment, and the private fields bought nothing over public ones.
Assigning first and checking after (this.high = high; then if (high < low) throw ...): the exception is thrown but the object is already corrupt, so a caller who catches it keeps using a range whose high is below its low.
Returning the internal collection from a getter and then wondering how the roster passed its cap; the caller's add() never went through the guarded method, so the check was never consulted.
Try it yourself
Change, predict, then run
Write a Playlist class with private int currentTrack and private int trackCount, a setTrackCount that refuses any value at or below currentTrack, and a setCurrentTrack that refuses anything outside 0..trackCount-1. Call setCurrentTrack with an out-of-range number, catch the exception, and print getCurrentTrack() to prove it did not move.
Open the Java workspaceCheck your understanding
A class has a private int balance, a setBalance that throws when the amount is negative, and a constructor whose body runs this.balance = start; directly. Every other method assumes balance is never negative. What actually happens?
- new Account(-500) succeeds and the object starts life already violating the rule the setter exists to protect
- The compiler rejects the constructor because it writes a field that has a validating setter
- The first call to setBalance repairs the stored value, so the object corrects itself
- Nothing is wrong, because a private field can only ever be written through its setter
Show answer
A setter is just a method; it guards only the calls that actually go through it. The constructor assigns the field directly, so the negative check never runs and every later method inherits a bad value. Option 3 is tempting because private is usually summarised as 'no outside access', but private controls who can reach the field, not which of the class's own code paths validate it; the fix is for the constructor to call the setter or share a private check method with it.