Command Palette

Search for a command to run...

[Java Basics] Encapsulation in Java: Access Modifiers, Getters and Setters

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.

An object with a private core: one write stopped at the wall, one let through the setter, one read out through the getter

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

A public field written directly to an invalid value, against a private field behind a validating setter

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

Four nested regions with one member at each access level and the javac error that stops each one

ModifierSame classSame packageSubclass, other packageAnywhere
privateyesnonono
(no keyword)yesyesnono
protectedyesyesonly through a reference of the subclass's own typeno
publicyesyesyesyes

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:

  • protected is wider than package-private, not narrower. A protected member is visible to the whole package and to subclasses outside it. There is no modifier meaning "subclasses only".
  • A top-level class takes only public or nothing. private and protected are 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.

ShapeConventionExample
Read a propertygetX()getBalanceCents()
Read a booleanisX()isActive()
Write a propertysetX(value), returning voidsetOwner(String owner)
Derived value, not a propertya verbavailable(), totalCents()
Record componentthe component name, no prefixcents()

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.someList or any other mutable object from a getter hands the caller a live handle on your state. The field being private and final changes 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

The same caller steps traced on a leaking getter and on one that copies

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.
  • public only 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

MistakeWhat happensMessage
Reading a private member from another classcompile errorsecret has private access in Vault
Reading a package-private member from another packagecompile errorinternal is not public in Vault; cannot be accessed from outside package
Using a package-private class from another packagecompile errorLedger is not public in com.example.model; cannot be accessed from outside package
protected member through a superclass reference, other packagecompile errorshared has protected access in Vault
private class Foo at top levelcompile errormodifier private not allowed here
Assigning a final field outside its initialisercompile errorcannot assign a value to final variable name
Calling getX() on a record componentcompile errorcannot find symbol: method getCurrency()
A getter returning a mutable fieldcompiles, invariant silently breaksnone
A constructor storing a mutable argument without copyingcompiles, caller keeps an aliasnone
A setter that skips the constructor's validationcompiles, invalid statenone
public static final array or listcompiles, shared mutable statenone
Generating a setter for every field by reflexcompiles, no invariants leftnone

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.

Related Posts

[Java Basics] Abstraction in Java: Abstract Classes and Abstract Methods

Abstract classes and abstract methods in Java: why Shape cannot be instantiated, what an abstract class still holds, the template method pattern, anonymous subclasses, constructor order, and every compile error reproduced on JDK 21.

[Java Basics] Inheritance in Java: extends and super

How extends works in Java, what a subclass inherits and what it does not, why constructors are never inherited, how super(...) chains constructors up to java.lang.Object and back down, field hiding versus overriding, protected across packages, final classes, the fragile base class problem, and when composition is the better answer.

[Java Basics] Recursion in Java: How It Works and When to Use It

How recursion works in Java: the base case and the recursive case, factorial traced frame by frame, a real StackOverflowError from a missing base case, recursion depth and -Xss, why naive Fibonacci needs 2692537 calls for fib(30) while memoisation needs 59, recursion versus iteration, and why the JVM does not optimise tail calls.

[Java Basics] Variable Scope in Java: Local, Field and Static

Variable scope in Java explained: local variables, parameters, instance fields and static fields, block scope, shadowing, definite assignment and lifetime, with every cannot find symbol error reproduced on JDK 21.