Skip to the content
Software Made Clear Diagrams that show the mechanism About

Encapsulation in Java: keep object state valid

See how a private list can still break a class's rules. Try direct access, a read-only view and a snapshot, then run a complete Java example.

3 min read Last verified October 2, 2026
ANSWER

Encapsulation keeps an object's state behind operations that preserve its rules. In Java, private fields restrict direct access, but a getter that returns a mutable internal object can still let callers bypass those rules. Choose carefully what can be changed through every reference you expose.

IN PLAIN TERMS

A class manages two seats. Its enrol method refuses a third name. If a getter hands out the actual list, another caller can add a third name anyway. Returning a read-only snapshot closes that route; the class still controls who can enter.

The capacity check can be correct and still never run. In this illustrative example, a roster holds the names of people occupying two seats. It must contain at most two different, non-blank names. Those conditions are its invariant: the rule each completed public operation must preserve.

A private field can still escape#

Suppose enrol("Cy") checks the number of occupied seats before adding a name. Declaring the list private prevents ordinary callers from writing roster.names. But this getter still gives them the same list object:

// Unsafe getter fragment: returns the roster's mutable backing list.
public List<String> names() {
    return names;
}

Now roster.names().add("Cy") changes the roster without calling enrol. The field remains private; the object it points to has escaped. A reference is another route to the same state, not a copy of that state.

Try to bypass a two-seat roster

The roster starts with Ada and Bo. Predict whether a third name can enter through the returned list without calling enrol().

Roster's internal names
Ada, Bo (2 / 2)
Previously returned list
Ada, Bo

Two seats, two names. Returning the internal list lets a caller bypass the capacity check. Both read-only alternatives block that write.

Illustrative Java list behaviour with immutable String elements and one thread. Read-only containers do not make mutable elements immutable or make multi-step operations thread-safe.

Reset after trying the third name. Switch to a read-only view or snapshot and try the same write. Both reject it. Then remove Bo through the roster’s method: an earlier view reflects the removal, while an earlier snapshot keeps the old two-name result. Blocking writes and preserving an earlier observation are different choices.

Keep the rules inside the class#

This complete example owns its mutable list. The constructor copies names through the same checks as enrolment. names() returns an unmodifiable snapshot, and cancellation changes state through a named operation. Save it as EncapsulationExample.java; run javac EncapsulationExample.java and java EncapsulationExample with Java 17 or later.

Java 17+ complete example
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;

public class EncapsulationExample {
    static final class Roster {
        private final int capacity;
        private final List<String> names = new ArrayList<>();

        Roster(int capacity, List<String> initialNames) {
            if (capacity < 1) throw new IllegalArgumentException("Positive capacity required");
            this.capacity = capacity;
            for (String name : Objects.requireNonNull(initialNames)) enrol(name);
        }

        public void enrol(String name) {
            Objects.requireNonNull(name);
            if (name.isBlank()) throw new IllegalArgumentException("Blank name");
            if (names.contains(name)) throw new IllegalArgumentException("Already enrolled");
            if (names.size() >= capacity) throw new IllegalStateException("No seats left");
            names.add(name);
        }

        public void cancel(String name) {
            names.remove(Objects.requireNonNull(name));
        }

        public List<String> names() {
            return List.copyOf(names);
        }
    }

    public static void main(String[] args) {
        List<String> supplied = new ArrayList<>(List.of("Ada", "Bo"));
        Roster roster = new Roster(2, supplied);
        supplied.clear(); // Does not clear the roster's own list.
        List<String> earlier = roster.names();
        System.out.println("After caller clears input: " + roster.names());

        try {
            earlier.add("Cy");
            throw new AssertionError("Snapshot allowed a write");
        } catch (UnsupportedOperationException expected) {
            System.out.println("Snapshot write rejected");
        }
        try {
            roster.enrol("Cy");
            throw new AssertionError("Capacity check allowed a third name");
        } catch (IllegalStateException expected) {
            System.out.println("Third enrolment rejected");
        }

        roster.cancel("Bo");
        System.out.println("Current roster: " + roster.names());
        System.out.println("Earlier snapshot: " + earlier);
    }
}

Expected output:

After caller clears input: [Ada, Bo]
Snapshot write rejected
Third enrolment rejected
Current roster: [Ada]
Earlier snapshot: [Ada, Bo]

There are three boundaries to inspect. The constructor retains no caller-owned list. Enrolment checks the rules before mutating the internal list. The accessor exposes names without exposing a write path. final prevents replacing the field’s reference; it does not prevent names.add(...) inside the class.

This example treats exact strings as identity: Ada and ada are different names. It has no persistent storage, concurrent enrolment or real booking guarantees. A real reservation system needs a rule across transactions as well as objects; transaction boundaries explain where that wider check belongs.

Choose a view or a snapshot#

Use the same two-name roster to compare the three accessor designs:

Return value Can the caller add Cy through it? After the roster cancels Bo, does an earlier result change?
names Yes; it bypasses enrolment. Yes; it is the same list.
Collections.unmodifiableList(names) No; list mutation is rejected. Yes; the view reads the backing list.
List.copyOf(names) No; list mutation is rejected. No; later structural changes to the original list are not reflected.

The Java SE 25 Collections.unmodifiableList contract describes a read-through view. The List.copyOf contract specifies an unmodifiable list whose contents do not track later changes to the source collection.

A defensive copy separates the returned container from the owner’s mutable container. It is not automatically a deep copy. Both alternatives above still hold references to their elements. Strings are immutable, which is sufficient for this example. If the elements were mutable Person objects, a caller could potentially change a person through either list; decide whether to copy those objects or expose immutable values instead.

Copies also have a cost. Choose a snapshot when callers need a stable observation; choose a live read-only view when seeing later owner-controlled changes is part of the API. Neither choice makes the check-then-add operation safe across multiple threads. That requires a separate concurrency design.

Encapsulation, data hiding and abstraction#

The terms describe related jobs. Data hiding restricts access to representation, such as the private list. Encapsulation puts the state and the operations responsible for it behind a boundary, so callers use a controlled API. Abstraction presents the useful operation without requiring callers to understand its implementation: enrol a person, rather than manipulate a list index.

Java’s access-control rules provide one enforcement mechanism. private does not decide which operations preserve your application rules; you must design those operations and account for exposed references.

An interface can describe the operations a caller may request. It does not, by itself, prove that an implementation preserves a capacity rule. The abstract class versus interface comparison shows how an operation contract differs from shared fields and construction logic.

When reviewing a class, name the rule first. Then trace every constructor argument, returned object and public operation that can change the state behind it. That finds the bypass a getter-and-setter checklist can miss.

Questions people also ask

3 QUESTIONS
Does encapsulation mean adding getters and setters?

No. A setter for every field may let callers create invalid combinations. Offer operations that describe valid changes, such as enrol or cancel, and expose only the state callers need.

Does final make a Java list immutable?

No. A final field cannot be reassigned after its permitted initialisation, but the referenced list may still allow add, remove or clear. A read-only list also does not make mutable elements immutable.

Is encapsulation a security boundary?

Java access rules restrict ordinary source-level access. They do not replace authentication, authorisation, process isolation or a policy for reflective access. This guide concerns preserving a class's rules through its ordinary API.

The library Browse all articles