Encapsulation is the practice of keeping an object's state under the object's own control, so that a rule the class promises cannot be broken by code outside it. The rule is the point. private and the setter are only the machinery that enforces it.
That framing matters because encapsulation is usually taught as a ritual — make the field private, generate a getter, generate a setter, done — which produces classes that are exactly as breakable as the public fields they replaced. This article works from the other end: pick a rule the object must never violate, then show what it takes to actually hold it. Every output line and every error message below came from compiling and running the code on OpenJDK 21.0.6.
![]()
The examples assume you already have classes, fields, constructors, this, static and final from the previous articles.
What encapsulation actually protects
A class usually promises something about its own state that is not expressible in the type system. A balance is never negative. A percentage is between 0 and 100. An array of samples is always sorted. A list of order lines is never empty. Those promises are called invariants, and an invariant is only real if there is no way to break it from outside.
That gives a concrete test for any class: pick the rule, then ask which code can violate it. If the answer is "any code that can reach the object", the class does not have an invariant — it has a comment.
Encapsulation is what shrinks that answer down to "only the class itself". Access modifiers do the shrinking; getters, setters, constructors and defensive copies decide what the class then chooses to expose. Everything below follows from that one idea.
A public field cannot defend anything
Start with a bank account whose rule is balanceCents >= 0 and whose owner is never blank.
public class OpenAccount {
public String owner;
public long balanceCents;
public static void main(String[] args) {
OpenAccount a = new OpenAccount();
a.owner = "Mai";
a.balanceCents = 50_00;
// nothing stops this
a.balanceCents = -999_99;
a.owner = null;
System.out.println("owner = " + a.owner);
System.out.println("balance = " + a.balanceCents);
}
}
owner = null
balance = -99999
The object is now in a state the class says is impossible, and no code ever got the chance to object. There is nowhere to put a check: assignment to a field is not a method call, so no code of yours runs on the way in.
Now the same class with the field private and the write routed through a method that can refuse.
public class SafeAccount {
private String owner;
private long balanceCents;
public SafeAccount(String owner, long balanceCents) {
setOwner(owner);
setBalanceCents(balanceCents);
}
public String getOwner() {
return owner;
}
public void setOwner(String owner) {
if (owner == null || owner.isBlank()) {
throw new IllegalArgumentException("owner must not be blank");
}
this.owner = owner;
}
public long getBalanceCents() {
return balanceCents;
}
public void setBalanceCents(long balanceCents) {
if (balanceCents < 0) {
throw new IllegalArgumentException("balance must not be negative: " + balanceCents);
}
this.balanceCents = balanceCents;
}
public static void main(String[] args) {
SafeAccount a = new SafeAccount("Mai", 50_00);
try {
a.setBalanceCents(-999_99);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
try {
a.setOwner(null);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("owner = " + a.getOwner());
System.out.println("balance = " + a.getBalanceCents());
}
}
rejected: balance must not be negative: -99999
rejected: owner must not be blank
owner = Mai
balance = 5000

The bad write was refused and the object never left a valid state. Trying to go around the setter does not compile:
public class Breaker {
public static void main(String[] args) {
SafeAccount a = new SafeAccount("Mai", 5000);
a.balanceCents = -99999;
}
}
Breaker.java:4: error: balanceCents has private access in SafeAccount
a.balanceCents = -99999;
^
1 error
private is what makes the setter the only door. Without it the setter is a suggestion.
The four access levels
Java has four levels of member access, and only three of them have a keyword. The fourth — package-private — is what you get when you write no modifier at all, which is why it is the one most people never learn.
Demonstrating them honestly requires more than one package, because two of the four boundaries are package boundaries. A single-file example can only show private.
Building a two-package tree
src/
com/example/model/Vault.java
com/example/model/Ledger.java
com/example/model/Neighbour.java
com/example/client/Outsider.java
com/example/client/SubVault.java
Vault declares one member at each level, plus a private helper method:
package com.example.model;
public class Vault {
private int secret = 1; // this class only
int internal = 2; // no keyword: this package only
protected int shared = 3; // this package, plus subclasses anywhere
public int open = 4; // anywhere
private String stamp() { // a private helper
return "v1";
}
public void report() {
System.out.println("inside Vault " + secret + " " + internal + " "
+ shared + " " + open + " " + stamp());
}
}
Ledger is a top-level class with no modifier, which makes the class itself package-private:
package com.example.model;
class Ledger { // no keyword: package-private top-level class
static String name() {
return "Ledger";
}
}
Neighbour sits in the same package and reaches everything except secret:
package com.example.model;
public class Neighbour {
public static void main(String[] args) {
Vault v = new Vault();
System.out.println("same package " + v.internal + " " + v.shared + " "
+ v.open + " " + Ledger.name());
v.report();
}
}
Compile the whole tree into an output directory and run it:
javac -d out $(find src -name '*.java')
java -cp out com.example.model.Neighbour
same package 2 3 4 Ledger
inside Vault 1 2 3 4 v1
Same package, so internal, shared, open and the package-private class Ledger are all reachable. secret is not, even here:
package com.example.model;
public class NeighbourFail {
void tryIt() {
Vault v = new Vault();
System.out.println(v.secret); // private
System.out.println(v.stamp()); // private method
}
}
NeighbourFail.java:6: error: secret has private access in Vault
System.out.println(v.secret); // private
^
NeighbourFail.java:7: error: stamp() has private access in Vault
System.out.println(v.stamp()); // private method
^
2 errors
private means this top-level class, not this object and not this package. Another instance of the same class can read your private fields, which is how equals is written. A separate top-level class cannot, even in the same file. Classes nested inside the same top-level class are the one exception: they form a nest and see each other's private members.
Now cross the package boundary. com.example.client.Outsider is not a subclass:
package com.example.client;
import com.example.model.Vault;
import com.example.model.Ledger;
public class OutsiderFail {
void tryIt() {
Vault v = new Vault();
System.out.println(v.secret);
System.out.println(v.internal);
System.out.println(v.shared);
System.out.println(Ledger.name());
}
}
OutsiderFail.java:4: error: Ledger is not public in com.example.model; cannot be accessed from outside package
import com.example.model.Ledger;
^
OutsiderFail.java:9: error: secret has private access in Vault
System.out.println(v.secret);
^
OutsiderFail.java:10: error: internal is not public in Vault; cannot be accessed from outside package
System.out.println(v.internal);
^
OutsiderFail.java:11: error: shared has protected access in Vault
System.out.println(v.shared);
^
OutsiderFail.java:12: error: Ledger is not public in com.example.model; cannot be accessed from outside package
System.out.println(Ledger.name());
^
5 errors
Three different messages for three different levels, and the package-private class fails at the import line as well as at the use. Only open survives the crossing:
package com.example.client;
import com.example.model.Vault;
public class Outsider {
public static void main(String[] args) {
Vault v = new Vault();
System.out.println("other package " + v.open);
v.report();
}
}
other package 4
inside Vault 1 2 3 4 v1
Note the second line: report() is a public method of Vault, and inside Vault all four fields are visible. Access is checked at the call site, not at the field, so a public method is free to publish private state — which is the whole reason a getter works.
What each level allows

| Modifier | Same class | Same package | Subclass, other package | Anywhere |
|---|---|---|---|---|
private | yes | no | no | no |
| (no keyword) | yes | yes | no | no |
protected | yes | yes | only through a reference of the subclass's own type | no |
public | yes | yes | yes | yes |
The reach grows monotonically, so each level is a superset of the one above it. Two details are worth pinning down because they trip people up:
protectedis wider than package-private, not narrower. Aprotectedmember is visible to the whole package and to subclasses outside it. There is no modifier meaning "subclasses only".- A top-level class takes only
publicor nothing.privateandprotectedare member modifiers; on a top-level class they do not compile at all.
private class Helper {
}
protected class Other {
}
TopLevelPrivate.java:1: error: modifier private not allowed here
private class Helper {
^
TopLevelPrivate.java:4: error: modifier protected not allowed here
protected class Other {
^
2 errors
A nested class is a member, so private is fine there:
public class Outer {
private static class Helper {
static String tag() {
return "helper";
}
}
public static void main(String[] args) {
System.out.println(Helper.tag());
}
}
helper
The protected rule most tutorials state wrongly
The usual summary — "a subclass can access protected members of its superclass" — is not what the compiler implements. From another package, a subclass may touch a protected member only through a reference whose static type is the subclass itself (or a further subclass). A reference typed as the superclass is refused, even when it is this.
Here is a subclass in com.example.client, using only what is allowed:
package com.example.client;
import com.example.model.Vault;
public class SubVault extends Vault {
public static void main(String[] args) {
SubVault s = new SubVault();
System.out.println("own inherited copy " + s.shared);
s.show();
}
void show() {
System.out.println("through this " + this.shared);
System.out.println("through SubVault ref " + new SubVault().shared);
System.out.println("public is always ok " + new Vault().open);
}
}
own inherited copy 3
through this 3
through SubVault ref 3
public is always ok 4
Now the same class, reaching for the same field through a Vault-typed reference:
package com.example.client;
import com.example.model.Vault;
public class SubVaultFail extends Vault {
void tryIt() {
Vault other = new Vault();
System.out.println(other.shared); // superclass reference
Vault asSuper = this;
System.out.println(asSuper.shared); // this, seen as Vault
System.out.println(((Vault) this).shared); // cast to Vault
System.out.println(this.shared); // fine
}
}
SubVaultFail.java:8: error: shared has protected access in Vault
System.out.println(other.shared); // superclass reference
^
SubVaultFail.java:10: error: shared has protected access in Vault
System.out.println(asSuper.shared); // this, seen as Vault
^
SubVaultFail.java:11: error: shared has protected access in Vault
System.out.println(((Vault) this).shared); // cast to Vault
^
3 errors
The last one is the striking case: this and ((Vault) this) are the same object at runtime, and only the second is rejected. The check is purely static, on the declared type of the expression.
The reason is that protected is meant to let a subclass work on its own inherited state, not to give it a window into every other instance of the superclass. Without this rule, putting one class in a package and subclassing it from anywhere would hand out package-private-level access to arbitrary objects.
A subclass in the same package is unaffected — package access already covers it. And a package-private member is invisible to a subclass in another package, protected-style inheritance notwithstanding:
package com.example.client;
import com.example.model.Vault;
public class SubInternal extends Vault {
void tryIt() {
System.out.println(this.internal); // package-private, other package
System.out.println(this.secret); // private
}
}
SubInternal.java:7: error: internal is not public in Vault; cannot be accessed from outside package
System.out.println(this.internal); // package-private, other package
^
SubInternal.java:8: error: secret has private access in Vault
System.out.println(this.secret); // private
^
2 errors
Getters and setters
A getter reads state; a setter writes it after checking. Neither is required, and a class made of getX/setX pairs with empty bodies has encapsulated nothing — it has renamed field access.
The naming convention
The convention comes from JavaBeans and is worth following because tooling depends on it: JSON libraries, template engines, ORMs and IDEs all discover properties by method name.
| Shape | Convention | Example |
|---|---|---|
| Read a property | getX() | getBalanceCents() |
Read a boolean | isX() | isActive() |
| Write a property | setX(value), returning void | setOwner(String owner) |
| Derived value, not a property | a verb | available(), totalCents() |
| Record component | the component name, no prefix | cents() |
A method that computes something is not a getter and should not be named like one. getTotal() implies a stored field; total() or computeTotal() says what it is.
When a setter should not exist
Write a setter only when the value is genuinely meant to change after construction. Skipping it is the strongest form of protection available and costs nothing:
- Identity fields — an id, an account number, a currency. Changing one turns the object into a different object.
- Fields that only make sense together — a start and an end date need one
setPeriod(start, end)that can check the ordering, not two independent setters that pass through an invalid state in between. - Anything derived — expose the calculation, not a field to set.
The IDE shortcut that generates a getter and a setter for every field is the single most common cause of classes with no invariants at all. Generate the getters; add setters one at a time, when something actually needs them.
A getter that returns a mutable field leaks the invariant
This is the failure that survives a code review, because the class looks encapsulated. Every field is private, every access goes through a method, and the object is still wide open.
LeakyLog keeps its samples sorted so that max() can just read the last slot:
import java.util.Arrays;
public class LeakyLog {
private final int[] samples; // invariant: always sorted ascending
public LeakyLog(int[] samples) {
this.samples = samples; // stores the caller's array
Arrays.sort(this.samples);
}
public int[] getSamples() {
return samples; // hands out the internal array
}
public int max() {
return samples[samples.length - 1]; // correct only while sorted
}
public static void main(String[] args) {
int[] input = {21, 19, 23};
LeakyLog log = new LeakyLog(input);
System.out.println("1 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
int[] view = log.getSamples();
view[2] = -40; // the caller writes straight into the object
System.out.println("2 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
input[0] = 999; // the constructor argument still aliases too
System.out.println("3 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
}
}
1 samples [19, 21, 23] max 23
2 samples [19, 21, -40] max -40
3 samples [999, 21, -40] max -40
max() returns -40, which is the smallest value in the array. Nothing threw, nothing warned, and the object cannot detect what happened — no method of LeakyLog ran between the two prints. Line 3 shows the second leak: the constructor kept the caller's array rather than copying it, so the argument variable is still an alias into the object.
⚠️ Returning
this.someArray,this.someListor any other mutable object from a getter hands the caller a live handle on your state. The field beingprivateandfinalchanges nothing.
The defensive copy
Copy on the way in and on the way out. Two calls to clone() close both holes:
import java.util.Arrays;
public class SafeLog {
private final int[] samples; // invariant: always sorted ascending
public SafeLog(int[] samples) {
this.samples = samples.clone(); // copy in
Arrays.sort(this.samples);
}
public int[] getSamples() {
return samples.clone(); // copy out
}
public int max() {
return samples[samples.length - 1];
}
public static void main(String[] args) {
int[] input = {21, 19, 23};
SafeLog log = new SafeLog(input);
System.out.println("1 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
int[] view = log.getSamples();
view[2] = -40; // mutates a copy, not the object
System.out.println("2 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
input[0] = 999;
System.out.println("3 samples " + Arrays.toString(log.getSamples()) + " max " + log.max());
}
}
1 samples [19, 21, 23] max 23
2 samples [19, 21, 23] max 23
3 samples [19, 21, 23] max 23

Identical caller code, identical mutation attempt, and the object is untouched. The cost is one array copy per call, which is worth measuring only if a profiler points at it; the cost of the alternative is a bug with no stack trace.
final does not make an array immutable
final on a field freezes the reference, not the object it points at:
import java.util.Arrays;
public class FinalIsNotDeep {
private final int[] data = {1, 2, 3};
void demo() {
data[0] = 99; // legal: the array object is not final
System.out.println(Arrays.toString(data));
}
public static void main(String[] args) {
new FinalIsNotDeep().demo();
}
}
[99, 2, 3]
There is no such thing as a final array in Java. final int[] data only means data will never point at a different array.
Collections leak the same way
A List field returned directly is the same bug with a different type:
import java.util.ArrayList;
import java.util.List;
public class LeakyCart {
private final List<String> items = new ArrayList<>();
public void add(String item) {
if (item == null || item.isBlank()) {
throw new IllegalArgumentException("item must not be blank");
}
items.add(item);
}
public List<String> getItems() {
return items; // the real list
}
public static void main(String[] args) {
LeakyCart c = new LeakyCart();
c.add("book");
System.out.println("items: " + c.getItems());
c.getItems().add(""); // the validation never runs
System.out.println("items: " + c.getItems());
c.getItems().clear();
System.out.println("items: " + c.getItems());
}
}
items: [book]
items: [book, ]
items: []
The blank string that add rejects went in through the getter, and then the whole cart was emptied by a caller. Collections.unmodifiableList wraps the field in a read-only view, so mutation attempts fail instead:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class Cart {
private final List<String> items = new ArrayList<>();
public void add(String item) { // the only way in
if (item == null || item.isBlank()) {
throw new IllegalArgumentException("item must not be blank");
}
items.add(item);
}
public List<String> getItems() {
return Collections.unmodifiableList(items); // a read-only view
}
public static void main(String[] args) {
Cart c = new Cart();
c.add("book");
c.add("pen");
List<String> view = c.getItems();
System.out.println("items: " + view);
try {
view.add("");
} catch (UnsupportedOperationException e) {
System.out.println("blocked: " + e.getClass().getSimpleName());
}
try {
c.add(" ");
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("items: " + c.getItems());
}
}
items: [book, pen]
blocked: UnsupportedOperationException
rejected: item must not be blank
items: [book, pen]
The wrapper is a view, not a copy: the caller cannot write through it, but it does keep showing later changes made by the object itself. List.copyOf(items) gives an independent snapshot instead. Pick the view when the caller should see updates, the copy when it should not.
Validate in the constructor, not only in the setter
A setter that checks and a constructor that does not — or the reverse — leaves an open door. HalfChecked validates on construction and then forgets:
public class HalfChecked {
private int percent;
public HalfChecked(int percent) {
if (percent < 0 || percent > 100) {
throw new IllegalArgumentException("percent out of range: " + percent);
}
this.percent = percent;
}
public int getPercent() {
return percent;
}
public void setPercent(int percent) {
this.percent = percent; // no check
}
public static void main(String[] args) {
try {
new HalfChecked(500);
} catch (IllegalArgumentException e) {
System.out.println("constructor rejected: " + e.getMessage());
}
HalfChecked h = new HalfChecked(50);
h.setPercent(500);
System.out.println("after setPercent(500): " + h.getPercent());
}
}
constructor rejected: percent out of range: 500
after setPercent(500): 500
The invalid value the constructor blocked walked in through the setter one line later. Write the check once, in a private method, and call it from both:
private static void requirePercent(int value) {
if (value < 0 || value > 100) {
throw new IllegalArgumentException("percent out of range: " + value);
}
}
IllegalArgumentException is the right type for a caller passing a value the method will not accept, and IllegalStateException for a call that is legal in general but wrong for the object's current state. Both are unchecked, so they need no throws clause. Exceptions in full — checked versus unchecked, try/catch, custom types — are article 31; here they are just the mechanism that makes a setter able to say no.
Two smaller rules go with it. Put the message in the exception, including the offending value, because a stack trace that says only "invalid" costs an hour later. And validate before assigning, so a rejected call leaves the object exactly as it was.
Immutable objects: the strongest form
If the state never changes after construction, there is nothing left to protect. An immutable class needs four things: final fields, no setters, a constructor that validates and copies mutable arguments in, and getters that copy mutable state out.
import java.util.Arrays;
public final class Route {
private final String name;
private final int[] waypoints;
public Route(String name, int[] waypoints) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("name must not be blank");
}
if (waypoints == null || waypoints.length < 2) {
throw new IllegalArgumentException("a route needs at least 2 waypoints");
}
this.name = name;
this.waypoints = waypoints.clone(); // copy in
}
public String getName() {
return name;
}
public int[] getWaypoints() {
return waypoints.clone(); // copy out
}
public int stops() {
return waypoints.length;
}
public Route withName(String newName) {
return new Route(newName, waypoints); // a new object, never a mutation
}
@Override
public String toString() {
return "Route[" + name + ", " + Arrays.toString(waypoints) + "]";
}
public static void main(String[] args) {
int[] pts = {1, 4, 9};
Route r = new Route("north", pts);
pts[0] = -1; // the caller's array
r.getWaypoints()[1] = -1; // the array the getter returned
System.out.println(r);
System.out.println(r.withName("north express"));
System.out.println("original still " + r);
try {
new Route(" ", pts);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
try {
new Route("solo", new int[] {5});
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
Route[north, [1, 4, 9]]
Route[north express, [1, 4, 9]]
original still Route[north, [1, 4, 9]]
rejected: name must not be blank
rejected: a route needs at least 2 waypoints
Two mutation attempts and both missed. Attacking it from another class does not compile either:
public class RouteBreak {
void tryIt(Route r) {
r.name = "hijacked";
r.setName("hijacked");
}
}
RouteBreak.java:3: error: name has private access in Route
r.name = "hijacked";
^
RouteBreak.java:4: error: cannot find symbol
r.setName("hijacked");
^
symbol: method setName(String)
location: variable r of type Route
2 errors
Even the class itself cannot change its mind afterwards:
public class FinalAssign {
private final String name = "north";
void rename() {
name = "south";
}
}
FinalAssign.java:5: error: cannot assign a value to final variable name
name = "south";
^
1 error
withName is the pattern that replaces a setter: return a new instance rather than modifying this one. The class is final so that no subclass can add mutable state or override a getter to return something else. An immutable object is also safe to share between threads and safe to use as a map key, which is why the JDK builds String, Integer and LocalDate this way.
record: the compact immutable carrier
Since Java 16, a record gives you that shape in one line. The compiler generates the private final fields, the canonical constructor, an accessor per component, plus equals, hashCode and toString.
public record Money(String currency, long cents) {
public Money { // compact constructor: validation still yours
if (cents < 0) {
throw new IllegalArgumentException("cents must not be negative: " + cents);
}
if (currency == null || currency.length() != 3) {
throw new IllegalArgumentException("currency must be a 3-letter code");
}
}
}
public class RecordDemo {
public static void main(String[] args) {
Money a = new Money("VND", 250_000);
Money b = new Money("VND", 250_000);
System.out.println(a.currency() + " " + a.cents()); // no get prefix
System.out.println(a);
System.out.println("a.equals(b) = " + a.equals(b));
System.out.println("same hash = " + (a.hashCode() == b.hashCode()));
try {
new Money("VND", -1);
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
}
}
VND 250000
Money[currency=VND, cents=250000]
a.equals(b) = true
same hash = true
rejected: cents must not be negative: -1
javap shows exactly what was generated:
Compiled from "Money.java"
public final class Money extends java.lang.Record {
public Money(java.lang.String, long);
public final java.lang.String toString();
public final int hashCode();
public final boolean equals(java.lang.Object);
public java.lang.String currency();
public long cents();
}
The accessors are currency() and cents() — records deliberately drop the get prefix, so code written against the JavaBeans convention will not find them:
RecordFail.java:3: error: cannot find symbol
System.out.println(m.getCurrency());
^
symbol: method getCurrency()
location: variable m of type Money
1 error
A record is the right default for a value carrier — a DTO, a coordinate, a query result, a key. It is not a replacement for thinking about encapsulation, for two reasons. The first is that its components are public by definition: a record announces that its state is its API, which is wrong for anything with a hidden representation. The second is that its immutability is shallow, exactly like final:
import java.util.Arrays;
public record Trip(String name, int[] legs) {
public static void main(String[] args) {
int[] legs = {10, 20};
Trip t = new Trip("north", legs);
legs[0] = -1; // the caller's array is the record's array
t.legs()[1] = -2; // the accessor hands the same array back
System.out.println(Arrays.toString(t.legs()));
Trip u = new Trip("north", new int[] {10, 20});
System.out.println("equals = " + new Trip("north", legs).equals(u));
}
}
[-1, -2]
equals = false
The generated constructor and accessor do no copying, so a record with an array component leaks exactly like LeakyLog. The generated equals compares array components by reference, so two records with equal contents are not equal. Both are fixable — copy in the compact constructor, override the accessor and equals — but at that point you are writing the class by hand anyway. Records earn their keep when every component is itself immutable.
Encapsulation is not only about fields
A method that exists only to help another method is part of the implementation, and marking it private is what lets you change it later. Vault.stamp() above is one; calling it from outside failed with stamp() has private access in Vault.
The reasoning is about what you have promised. Anything public is a promise: someone may call it, and changing its name, its parameters or its behaviour can break them. Anything private is free — rename it, split it, delete it, inline it, and nothing outside the class notices. So the public surface should be the smallest set of methods that lets callers do their job, and everything else should start private and be widened only when a real caller needs it.
Practical form of the same rule:
- Helper methods, parsing, formatting, index arithmetic and validation:
private. - Fields:
private, always, apart from the constants covered in the next section. - Classes that exist only to serve one package: no modifier, so they stay inside it.
publiconly where a caller outside genuinely needs it.
Widening access later is easy. Narrowing it is a breaking change for everyone who compiled against it, which is why so many APIs are stuck with methods their authors regret.
When is a public field acceptable?
Rarely, but not never, and the honest cases are worth knowing so the rule does not sound like superstition.
A public static final constant of an immutable type. There is no state to protect: nobody can reassign it and nobody can mutate it. The JDK is full of them — Integer.MAX_VALUE, Math.PI, System.out.
public static final int MAX_RETRIES = 3;
ConfigFail.java:3: error: cannot assign a value to static final variable MAX_RETRIES
Config.MAX_RETRIES = 9;
^
1 error
The trap is that final protects the reference only, so a public constant of a mutable type is a public field with extra steps:
import java.util.Arrays;
public class Config {
public static final int MAX_RETRIES = 3; // safe: primitive
public static final String[] HOSTS = {"a.example", "b.example"}; // not safe
public static void main(String[] args) {
System.out.println(Arrays.toString(Config.HOSTS));
Config.HOSTS[0] = "evil.example"; // legal
System.out.println(Arrays.toString(Config.HOSTS));
}
}
[a.example, b.example]
[evil.example, b.example]
Any code anywhere rewrote a shared constant. Use List.of(...) for a constant sequence; it is genuinely unmodifiable.
A small, purely local aggregate with no invariant. A private nested class or a package-private helper holding two loose values that any combination of is valid — a record is now the better answer for exactly this case, and it gives you equals and toString for free.
Everything else gets a private field. The cost of a getter is one method; the cost of a public field is that you can never add a check, a computation, a log line or a lazy initialisation without breaking every caller.
Common mistakes and the errors they produce
| Mistake | What happens | Message |
|---|---|---|
Reading a private member from another class | compile error | secret has private access in Vault |
| Reading a package-private member from another package | compile error | internal is not public in Vault; cannot be accessed from outside package |
| Using a package-private class from another package | compile error | Ledger is not public in com.example.model; cannot be accessed from outside package |
protected member through a superclass reference, other package | compile error | shared has protected access in Vault |
private class Foo at top level | compile error | modifier private not allowed here |
Assigning a final field outside its initialiser | compile error | cannot assign a value to final variable name |
Calling getX() on a record component | compile error | cannot find symbol: method getCurrency() |
| A getter returning a mutable field | compiles, invariant silently breaks | none |
| A constructor storing a mutable argument without copying | compiles, caller keeps an alias | none |
| A setter that skips the constructor's validation | compiles, invalid state | none |
public static final array or list | compiles, shared mutable state | none |
| Generating a setter for every field by reflex | compiles, no invariants left | none |
The bottom half of the table is the dangerous half: nothing diagnoses it. All five come from the same question, so ask it deliberately for each class — which code outside this class can put it in a state it says is impossible? If any answer exists, that is the leak.
FAQ
What is encapsulation in Java in one sentence?
Keeping an object's state private and exposing only the operations the object is willing to guarantee, so the rules it promises about itself cannot be broken from outside. The private field is the mechanism; the guaranteed rule is the goal.
What is the difference between default (package-private) and protected in Java?
Package-private, which you get by writing no modifier, allows access from the same package only. protected allows the same package plus subclasses in other packages, so it is strictly wider. The common belief that protected means "subclasses only" is wrong in both directions: it also grants the whole package, and from another package a subclass can only reach the member through a reference of its own type.
Why can my subclass not access a protected field of its superclass?
Almost always because the reference is typed as the superclass. From another package, this.shared compiles and ((Vault) this).shared does not, with error: shared has protected access in Vault. The rule limits a subclass to its own inherited state rather than to every instance of the superclass. If the subclass is in the same package, package access covers it and the restriction never applies.
Do I need a getter and a setter for every field?
No, and generating them by reflex is the standard way to end up with a class that encapsulates nothing. Add a getter where a caller genuinely needs to read the value, and a setter only where the value is meant to change after construction. Many good classes have several getters and no setters at all.
Does making a field private make my object immutable?
No. private controls who can name the field; it says nothing about whether the value changes. Immutability needs final fields, no setters, and defensive copies of any mutable component both in the constructor and in the getters. And final itself is shallow: private final int[] data still permits data[0] = 99 from inside the class.
Do records replace encapsulation?
For a value carrier whose components are all immutable, a record gives you the whole pattern — final fields, no setters, accessors, equals, hashCode, toString — with validation available in the compact constructor. It replaces the boilerplate, not the reasoning. A record's components are public by design, its copying is not automatic, and its generated equals compares array components by reference, so anything with a hidden representation or a mutable component still needs a hand-written class.
Conclusion
Encapsulation is not the getter. It is the invariant, and the getter is one of several tools for keeping it: private to make the setter the only door, validation in both the constructor and the setter so neither is a bypass, defensive copies so a returned array or list is not a handle on your state, final fields and no setters when the value should never move at all, and a record when the whole object is just a bundle of immutable values.
The four access levels are worth memorising as reach rather than as words: private stops at the class, no keyword stops at the package, protected adds subclasses elsewhere — reachable only through a reference of the subclass's own type — and public stops nowhere. Start every member at the narrowest level that compiles and widen only under pressure, because widening is a one-line change and narrowing is a broken build for everyone downstream.
Next in this series: inheritance with extends and super — what a subclass really inherits, how constructors chain through super(...), and where protected fits once the subclass is doing real work.