Encapsulation and access modifiers
Encapsulation gets taught as a ritual: make the fields private, add a getX
and a setX for each, done. That ritual produces classes with no encapsulation
whatsoever, just more lines.
What encapsulation actually buys you is the ability to guarantee something. This lesson is about what you can guarantee, and about the getter that quietly hands that guarantee away.
Access modifiers
Four levels. Two of them you will use constantly, and two rarely.
| Modifier | Visible from |
|---|---|
private |
Inside this class only |
| (nothing) | Inside this class and others in the same package |
protected |
Same package, plus subclasses anywhere |
public |
Everywhere |
The no-modifier level is called package-private, and it is the default —
which surprises people who expect the default to be public.
The rule worth following: start at private and open up only when something
outside genuinely needs it. Widening later is easy; narrowing once other code
depends on it is a breaking change.
protected is narrower than it looks: it means "subclasses and the same
package", and it ties your hands, because any subclass anywhere can now depend on
that field forever. Use it deliberately, not as a softer public.
What private actually gives you
Take the class from the last lesson with its fields open:
Subscriber s = new Subscriber("Priya", "411207", 26);
s.tiffins = -5;
A negative number of tiffins. Nothing stops it, and now every method that uses
tiffins has to cope with nonsense — billPaise() returns a negative bill, and
somewhere downstream a refund appears that nobody authorised.
Closing the field lets the class refuse:
public class Subscriber {
private final String name;
private final String pincode;
private int tiffins;
public Subscriber(String name, String pincode, int tiffins) {
this.name = requireText(name, "name");
this.pincode = requirePincode(pincode);
setTiffins(tiffins);
}
public void setTiffins(int tiffins) {
if (tiffins < 0 || tiffins > 62) {
throw new IllegalArgumentException(
"tiffins must be between 0 and 62, got " + tiffins);
}
this.tiffins = tiffins;
}
}
Exception in thread "main" java.lang.IllegalArgumentException: tiffins must be between 0 and 62, got -5
That is the point of encapsulation: there is now no way to make a Subscriber
with a negative tiffin count. Not "we agreed not to" — no way. The rule is
stated once, in one place, and enforced on every path in.
Notice the constructor calls setTiffins rather than repeating the check. One
rule, one implementation. (There is a caveat about calling overridable methods
from constructors — the inheritance lesson covers it, and the fix is to make
such methods final.)
Notice also that the message says what was wrong and what it got. "invalid tiffins" in a log at 2am tells you nothing.
The getter that gives everything away
Here is the bug this lesson exists for. It survives code review constantly.
public class Route {
private final String area;
private final List<String> stops = new ArrayList<>();
public void addStop(String stop) {
stops.add(stop);
}
public List<String> getStops() {
return stops;
}
}
Every field is private. There is no setter. It looks encapsulated.
Route r = new Route("Wagholi");
r.addStop("Kesnand");
List<String> stops = r.getStops();
stops.clear();
System.out.println(r.getStops());
[]
The caller emptied the route. getStops() returned the actual internal list, not
a copy, so private protected the variable and nothing at all about the
object it points at. Every rule addStop might enforce is bypassable by
anybody who calls the getter.
A getter returning a mutable object is a hole in the class. The same applies
to arrays, Date, and any object of your own with setters.
Three fixes, in order of preference:
// 1. An unmodifiable copy — the caller can read, and cannot change anything
public List<String> getStops() {
return List.copyOf(stops);
}
// 2. An unmodifiable view — no copy, but reflects later changes
public List<String> getStops() {
return Collections.unmodifiableList(stops);
}
// 3. No getter at all — expose the operations, not the collection
public int stopCount() { return stops.size(); }
public boolean servesStop(String s) { return stops.contains(s); }
With the first fix, the caller's clear() now fails loudly:
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:159)
That is a much better outcome than silent data loss.
Option 3 is the one to reach for more often than you will want to. Ask what
callers need to do, not what they need to see. A class that exposes
servesStop instead of the list keeps the freedom to change the list into a
Set later; one that returns the list has published its internals forever.
The same trap applies going in. A constructor that stores the list it was handed keeps a reference the caller still holds:
public Route(String area, List<String> stops) {
this.stops = new ArrayList<>(stops); // copy on the way in, too
}
final fields, and why immutability is easier
private final String name;
final means the field must be assigned exactly once — in the declaration or in
every constructor — and never again. The compiler enforces it.
Make everything final that does not genuinely need to change. A field that
cannot change cannot be changed wrongly, needs no setter, needs no validation
after construction, and is safe to share between threads without thinking about
it.
One caveat you have already seen: final applies to the variable, not the
object. private final List<String> stops still allows stops.add(...). It
stops the list being replaced, not modified.
What encapsulation is not
It is not "a getter and a setter for every field". A class with
getTiffins/setTiffins, getName/setName and so on for every field has the
same surface as public fields, with more code. You have changed the syntax
callers use, not what they can do.
Before writing a setter, ask what would break if you did not. Often nothing:
namenever changes after construction. No setter.tiffinschanges through meaningful operations —recordDelivery(),pause()— not by being assigned to.
Methods named for the business, not for the field, are what encapsulation
looks like. pause() can check whether the subscription is already paused,
record a date, and log; setActive(false) can do none of that, because it is
just an assignment with a longer name.
Getters, on the other hand, are usually fine. Something has to print the name. The records lesson later in this module gives you accessors for free, which is the right default for data that is genuinely just data.
Check your work
What is the default access level in Java? Package-private — no keyword. Visible within the same package and nowhere else.
What does private on a field actually protect? The variable: nobody outside
can read or replace it. It says nothing about whether the object it refers to can
be modified.
Why did getStops() let a caller empty the route? It returned the internal
list itself. The caller then held a reference to the class's own mutable state.
Three ways to fix that. Return List.copyOf(...) for an unmodifiable copy;
return Collections.unmodifiableList(...) for an unmodifiable view; or expose
operations (stopCount(), servesStop(...)) instead of the collection.
What is the difference between a copy and a view? A copy is a snapshot — later changes to the original are not reflected. A view is live and reflects them, but still refuses modification. A view is cheaper; a copy is safer.
Why copy a collection passed into a constructor? The caller still holds a reference to the list they gave you. Storing it directly lets them modify your state afterwards.
Does final on a List field stop add? No. It stops the field being
reassigned to a different list. Use List.copyOf if the contents must not
change.
Practice 3, the leak and the fix. Before, stops.clear() from outside
empties the route silently and getStops() prints []. After returning
List.copyOf(stops), the same call throws UnsupportedOperationException from
ImmutableCollections.uoe. A loud failure at the offending line beats silent
corruption found three screens later.
Practice 5, the setters worth deleting. setName and setPincode should
go — neither changes during a subscription, so make the fields final and take
them in the constructor. setTiffins survives, but is better expressed as
recordDelivery() and pause(), because those can enforce the daily limit and
record why the number changed. The test for any setter is: what would break if it
did not exist? If the honest answer is "nothing", delete it.
Practice
-
Close the fields. Take your
Subscriberfrom the last lesson, make every fieldprivate, and makenameandpincodefinal. Fix everything the compiler then complains about. -
Make an invalid object impossible. Reject a negative tiffin count, a count above 62, a blank name, and a pincode that is not six digits — with a message naming the field and the bad value. Then try to construct four invalid
Subscribers and read each message. -
Reproduce the leaking getter. Write
Routeexactly as shown, empty it from outside through its getter, and print the result. Then switch toList.copyOfand confirm the same caller now getsUnsupportedOperationException. -
Leak it the other way. Give
Routea constructor taking aList<String>, store the parameter directly, then have the caller add a stop to their own list afterwards and print the route. Then fix it with a defensive copy. -
Delete some setters. Look at your
Subscriberand, for each setter, write down what would break without it. Delete the ones where the answer is "nothing". ReplacesetTiffinswithrecordDelivery()andpause(), and note what those two can enforce that an assignment cannot. -
Harder — a running balance. Write an
Accountholding a balance in paise, withdeposit(long),withdraw(long)andbalancePaise(). It must be impossible to end up with a negative balance, impossible to deposit a negative amount, and impossible to change the balance except through those two methods. Then try to break it from outside without editing the class. If you can, the encapsulation is not finished.
Next: inheritance — useful, overused, and the source of a constructor bug that
produces a null you cannot explain.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship