Lesson 15 of 25

Encapsulation

Hiding Data So It Cannot Go Wrong

Encapsulation means keeping an object's data private and letting the outside world reach it only through methods the class controls. It is the least glamorous of the four object-oriented principles and, in day-to-day work, the one that prevents the most bugs.

The problem is concrete. Leave balance as a public field and any line anywhere in the program can write account.balance = -50000;. Nothing stops it. When a negative balance turns up in production, the bug could have been written by any of the two hundred lines that touch that field, and you have no way to narrow it down.

Make the field private and the only way to change it is through deposit() and withdraw(). Those methods enforce the rules — no negative amounts, no withdrawing more than the balance — and they are the only two places you have to look when something goes wrong. The class has become responsible for keeping itself valid, rather than trusting every caller to be careful.

There is a second benefit that matters as programs grow. Because nobody outside can see how the balance is stored, you are free to change that. Store it as a long number of paise instead of a double, or fetch it from a database on demand, and as long as getBalance() still returns the right number, no other file needs to change. Public fields are a promise you can never take back; public methods are a promise about behaviour, which is much easier to keep.

Example
public class BankAccount {
    private final String owner;         // private: nobody outside can touch it
    private double balance;

    public BankAccount(String owner, double initialBalance) {
        if (initialBalance < 0) {
            throw new IllegalArgumentException("Opening balance cannot be negative");
        }
        this.owner = owner;
        this.balance = initialBalance;
    }

    public double getBalance() {        // read access, no write access
        return balance;
    }

    public String getOwner() {
        return owner;
    }

    // Changes are only possible through rules the class enforces
    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("Deposit must be positive");
        }
        balance += amount;
    }

    public boolean withdraw(double amount) {
        if (amount <= 0 || amount > balance) {
            return false;               // refuse, do not corrupt the balance
        }
        balance -= amount;
        return true;
    }
}

BankAccount acc = new BankAccount("Ananya", 1000);
acc.deposit(500);
System.out.println(acc.getBalance());     // 1500.0

System.out.println(acc.withdraw(5000));   // false — refused
System.out.println(acc.getBalance());     // 1500.0 — still correct

// acc.balance = -50000;
// error: balance has private access in BankAccount
Notes
  • Notice that withdraw returns a boolean rather than silently doing nothing. A method that quietly ignores an invalid request is almost as bad as one that accepts it — the caller has no way to know it failed. Either return a result or throw an exception.

The Four Access Levels

Java gives you four levels of visibility, and they form a ladder from most closed to most open. Three have keywords; the fourth is what you get when you write no keyword at all, and it is called package-private or simply default.

The guiding rule is the principle of least privilege: give every member the narrowest access that still lets your code work. Start everything at private and widen only when you have an actual reason. Widening later is easy and harmless. Narrowing later breaks everyone who was relying on the old access, which is why an over-generous public tends to become permanent.

In practice the distribution in well-written code is lopsided. Nearly all fields are private. Methods intended for outside use are public; helper methods are private. protected appears only where a subclass genuinely needs it. Package-private is useful for classes and methods that several classes in the same package share but that are not part of your public interface.

Two constraints on classes themselves are worth knowing. A top-level class may be public or package-private only — private and protected are not allowed there, since there would be nothing outside the class for them to mean. And a public class must live in a file with a matching name.

  • private — visible only inside the same class. The default choice for every field.
  • (no modifier) — package-private: visible to any class in the same package, and nowhere else.
  • protected — visible within the package, plus to subclasses in any package. Exists for inheritance.
  • public — visible everywhere. A permanent commitment, so choose it deliberately.
  • Ordering from tightest to loosest: private < package-private < protected < public
  • An overriding method may widen access but never narrow it
Example
package com.school.core;

public class Marksheet {

    private   double[] marks;      // only this class
              String  batchCode;   // any class in com.school.core
    protected String  schoolId;    // this package + any subclass anywhere
    public    String  studentName; // everywhere — think twice before doing this

    private double sum() {         // internal helper, not part of the API
        double t = 0;
        for (double m : marks) t += m;
        return t;
    }

    public double percentage() {   // the API other code is meant to use
        return marks.length == 0 ? 0 : sum() / marks.length;
    }
}

// A top-level class may only be public or package-private
// private class Helper { }     // error: modifier private not allowed here

class Helper { }                // package-private: fine, and often right
Notes
  • Do not confuse private with security. It is a compile-time rule that keeps honest code honest and prevents accidents; it is not protection against a determined attacker, who can bypass it with reflection. Real secrets belong outside your source code entirely.

Getters and Setters, Written Thoughtfully

The standard pattern is a getX() to read a field and a setX(value) to change it. Most tutorials stop there, and most students end up generating a getter and a setter for every single field out of habit. That is a mistake worth correcting early, because a class with a setter for every field has exactly the same weakness as a class with public fields — anyone can put it into an invalid state — with more code to read.

Ask two questions per field. Does the outside world need to read this? If not, no getter. Does the outside world need to change this, at any time, to any value? Usually not, and then no setter either.

Often the honest answer is that the value should change only as a side effect of a real operation. A bank balance is the clearest case: nobody should ever "set the balance". They should deposit or withdraw, and the balance follows. Naming methods after the operation rather than after the field makes the class describe the domain instead of describing its own storage.

When you do need a setter, put the validation in it. A setter that assigns without checking is a public field with three extra lines. And keep the naming conventions — getName(), setName(), and isActive() for booleans — because many libraries and frameworks detect properties by exactly those names.

Example
public class Student {
    private final int rollNo;        // set once, never changes: no setter
    private String name;
    private double marks;
    private boolean active = true;

    public Student(int rollNo, String name) {
        this.rollNo = rollNo;
        setName(name);               // reuse the validation
    }

    public int getRollNo() { return rollNo; }          // read-only

    public String getName() { return name; }

    public void setName(String name) {                 // validated setter
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name cannot be blank");
        }
        this.name = name.strip();
    }

    public double getMarks() { return marks; }

    // No setMarks(). Marks change only through a real operation.
    public void recordExam(double scored, double outOf) {
        if (scored < 0 || scored > outOf) {
            throw new IllegalArgumentException("Invalid exam result");
        }
        this.marks = (scored / outOf) * 100;
    }

    public boolean isActive() { return active; }       // 'is' for booleans

    public void deactivate() { this.active = false; }  // named after the action
}

Student s = new Student(101, "  Ananya  ");
System.out.println("[" + s.getName() + "]");   // [Ananya] — trimmed on the way in
s.recordExam(430, 500);
System.out.printf("%.1f%n", s.getMarks());      // 86.0
Notes
  • If a class ends up as nothing but private fields with a full set of getters and setters, it is not encapsulating anything — it is a data structure wearing a class costume. Either move the behaviour that operates on that data into the class, or make it a record and be honest about it.

The Leaky Getter: Returning Your Internals

Here is the encapsulation bug that gets past almost everyone the first time. You store a list of subjects in a private field and provide getSubjects() that returns it. Every field is private, every access goes through a method, and the class looks properly encapsulated.

It is not. The getter handed the caller a reference to your actual internal list. They can now call clear() on it, or add duplicates, or remove everything — and your object's state changes without a single one of your methods running. All your validation has been bypassed, and worse, the code that caused the damage is somewhere else entirely, so debugging is miserable.

The same hole exists in the other direction. A constructor that stores the exact list it was given leaves the caller holding a reference to your internals from the moment the object is created. They can keep modifying that list afterwards and your object will change underneath you.

There are two fixes and you pick by intent. Return an unmodifiable view with List.copyOf() or Collections.unmodifiableList() when the caller should only read — any attempt to modify then throws UnsupportedOperationException, loudly and immediately. Or return a defensive copy when the caller may reasonably want their own list to play with. Do the same on the way in: copy what you are given before storing it.

This applies to arrays and to any mutable object, not just collections. Primitives, String and other immutable types are safe to hand out directly, because the caller cannot change them.

Example
import java.util.*;

// ---- BROKEN, despite every field being private ----
public class LeakyStudent {
    private final List<String> subjects = new ArrayList<>();

    public void addSubject(String s) {
        if (subjects.size() >= 6) throw new IllegalStateException("Max 6 subjects");
        subjects.add(s);
    }

    public List<String> getSubjects() {
        return subjects;                   // hands out the real list!
    }
}

LeakyStudent bad = new LeakyStudent();
bad.addSubject("Maths");
bad.getSubjects().clear();                 // validation completely bypassed
bad.getSubjects().add("Anything");         // and the 6-subject rule too

// ---- FIXED ----
public class SafeStudent {
    private final List<String> subjects;

    public SafeStudent(List<String> initial) {
        this.subjects = new ArrayList<>(initial);   // defensive copy IN
    }

    public void addSubject(String s) {
        if (subjects.size() >= 6) throw new IllegalStateException("Max 6 subjects");
        subjects.add(s);
    }

    public void removeSubject(String s) {
        subjects.remove(s);
    }

    public List<String> getSubjects() {
        return List.copyOf(subjects);      // unmodifiable snapshot OUT
    }
}

SafeStudent good = new SafeStudent(List.of("Maths"));
// good.getSubjects().add("Hacked");
// UnsupportedOperationException — the leak is closed
good.addSubject("Physics");                 // the supported route still works
System.out.println(good.getSubjects());     // [Maths, Physics]
Notes
  • List.copyOf() rejects null elements and returns a genuinely independent unmodifiable list. Collections.unmodifiableList(x) returns a read-only view of x — the caller cannot modify it, but changes you make to the original are still visible through it. Both are useful; know which one you chose.

Immutability: Encapsulation Taken to Its Conclusion

The strongest form of encapsulation is an object that cannot change at all after it is created. If there is no way to modify it, there is no way to modify it incorrectly, and every question about who changed what and when simply disappears.

You have already relied on this. String is immutable, which is why passing one to a method is completely safe. The modern date and time classes in java.time — LocalDate, LocalDateTime — are immutable too, and their methods return new instances rather than modifying in place. The old java.util.Date was mutable, which caused years of bugs and is a large part of why it was replaced.

Building an immutable class takes five deliberate steps, listed below. Miss any of them and the guarantee is gone. In particular, a final field holding a mutable object does not make the object immutable — the reference cannot be redirected, but the thing it points at can still be changed. That is exactly why defensive copies matter.

When to choose it: value-like things — money amounts, dates, coordinates, configuration, anything you use as a HashMap key. When not to: objects that model something genuinely changing over time, such as a bank account or a shopping cart. Records give you shallow immutability for free, which covers a large share of the cases.

Example
import java.util.*;

public final class ExamResult {                  // 1. final class: no subclass
    private final String studentName;            // 2. private final fields
    private final List<Integer> marks;

    public ExamResult(String studentName, List<Integer> marks) {
        this.studentName = studentName;
        this.marks = List.copyOf(marks);         // 3. defensive copy IN
    }

    public String getStudentName() { return studentName; }

    public List<Integer> getMarks() {
        return marks;                            // 4. already unmodifiable
    }
                                                 // 5. no setters at all

    // "Modification" returns a NEW object, like String does
    public ExamResult withExtraMark(int mark) {
        List<Integer> updated = new ArrayList<>(marks);
        updated.add(mark);
        return new ExamResult(studentName, updated);
    }
}

ExamResult r1 = new ExamResult("Ananya", List.of(90, 85));
ExamResult r2 = r1.withExtraMark(78);

System.out.println(r1.getMarks());   // [90, 85]      — untouched
System.out.println(r2.getMarks());   // [90, 85, 78]  — a new object

// A record gives you most of this in one line
public record Money(String currency, long paise) { }
  • Make the class final so a subclass cannot add mutable state
  • Make every field private final
  • Set every field in the constructor, and provide no setters
  • Take a defensive copy of any mutable object passed in
  • Return a copy or an unmodifiable view of any mutable object passed out
  • Offer withX()-style methods that return a new instance instead of changing this one
Notes
  • Immutable objects are automatically safe to share between threads, because there is nothing to go out of sync. That single property removes an entire category of the hardest bugs in programming, and it is the main reason experienced developers reach for immutability by default.
Ask AI