RizTech Academy logo
RizTech Academy
Object-Oriented JavaLesson 2 of 825 min

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:

  • name never changes after construction. No setter.
  • tiffins changes 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

  1. Close the fields. Take your Subscriber from the last lesson, make every field private, and make name and pincode final. Fix everything the compiler then complains about.

  2. 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.

  3. Reproduce the leaking getter. Write Route exactly as shown, empty it from outside through its getter, and print the result. Then switch to List.copyOf and confirm the same caller now gets UnsupportedOperationException.

  4. Leak it the other way. Give Route a constructor taking a List<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.

  5. Delete some setters. Look at your Subscriber and, for each setter, write down what would break without it. Delete the ones where the answer is "nothing". Replace setTiffins with recordDelivery() and pause(), and note what those two can enforce that an assignment cannot.

  6. Harder — a running balance. Write an Account holding a balance in paise, with deposit(long), withdraw(long) and balancePaise(). 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