Skip to content
elephantoo

Encapsulation & access modifiers

Lesson 13 of 43 15 min read

private, package-private, protected and public; getters, setters, validation and immutable classes.


In the classes lesson, anyone could write account.balance = -1_000_000; and break our bank. Encapsulation means hiding an object's internal data and allowing access only through carefully designed methods. The class stays in control of its own state, so it can guarantee the data is always valid.

Access modifiers#

Java has four access levels, from most to least restrictive:

ModifierSame classSame packageSubclass (other package)Everywhere
private✅❌❌❌
(none), package-private✅✅❌❌
protected✅✅✅❌
public✅✅✅✅

They can be applied to fields, methods, constructors and nested classes. Top-level classes can only be public or package-private.

A good default:

  • Fields: private, almost always.
  • Methods: public if they are part of the class's API, private if they are internal helpers.
  • Constructors: usually public; private for utility classes, singletons and static factories.
  • protected: for members that subclasses need. Use with care, as it widens access to the whole package too.

Encapsulating a bank account#

BankApp.java
public class BankApp {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount("ACC-101", "Meera");
        acc.deposit(5000);
        acc.withdraw(1500);
        System.out.println(acc.getOwner() + ": " + acc.getBalance());

        try {
            acc.withdraw(10_000);
        } catch (IllegalStateException e) {
            System.out.println("Error: " + e.getMessage());
        }
        try {
            acc.deposit(-50);
        } catch (IllegalArgumentException e) {
            System.out.println("Error: " + e.getMessage());
        }
        // acc.balance = 1_000_000;   // compile error: balance has private access
        System.out.println("Final: " + acc.getBalance());
    }
}

class BankAccount {
    private final String number;
    private final String owner;
    private double balance;

    public BankAccount(String number, String owner) {
        this.number = number;
        this.owner = owner;
    }

    public void deposit(double amount) {
        requirePositive(amount);
        balance += amount;
    }

    public void withdraw(double amount) {
        requirePositive(amount);
        if (amount > balance) {
            throw new IllegalStateException("Insufficient funds");
        }
        balance -= amount;
    }

    public double getBalance() { return balance; }
    public String getOwner() { return owner; }
    public String getNumber() { return number; }

    private void requirePositive(double amount) {   // internal helper
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive: " + amount);
        }
    }
}
Output
Meera: 3500.0
Error: Insufficient funds
Error: Amount must be positive: -50.0
Final: 3500.0

What we gained:

  • Valid state is guaranteed. The balance can only change through deposit and withdraw, which enforce the rules.
  • Read-only where it should be. number and owner have getters but no setters.
  • Freedom to change internals. We could store the balance as long paise tomorrow; as long as getBalance() behaves the same, no caller breaks.

Getters and setters#

The conventional names are getX() / setX(value), and isX() for booleans:

Java
public class Product {
    private String name;
    private double price;
    private boolean active;

    public String getName() { return name; }

    public void setName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("Name required");
        }
        this.name = name.strip();
    }

    public double getPrice() { return price; }

    public void setPrice(double price) {
        if (price < 0) throw new IllegalArgumentException("Negative price");
        this.price = price;
    }

    public boolean isActive() { return active; }
    public void setActive(boolean active) { this.active = active; }
}

Do not generate a getter and setter for every field by reflex. That is just a public field with extra steps. Ask: should outsiders really change this? Often a meaningful method (deactivate(), applyDiscount(10)) is a better API than a raw setter.

Many frameworks (Spring, Hibernate, Jackson) recognise the getX/setX/isX naming convention, so following it matters in practice.

Tell, don't ask#

Encapsulation is not just about private. It is about putting behaviour next to the data it uses. Compare:

Java
// Asking for data and deciding outside the object (fragile):
if (account.getBalance() >= price) {
    account.setBalance(account.getBalance() - price);
}

// Telling the object what to do (encapsulated):
account.withdraw(price);

In the second version, the rule "you cannot overdraw" lives in exactly one place.

Immutable classes#

The strongest form of encapsulation is an immutable object whose state can never change after construction. String, Integer and LocalDate are immutable. To make your own:

  1. Make all fields private final.
  2. Provide no setters.
  3. Make the class final (so subclasses cannot add mutable behaviour).
  4. Return new objects from "modifying" methods.
  5. Make defensive copies of mutable inputs and outputs (arrays, lists, dates from old APIs).
ImmutableDemo.java
import java.util.ArrayList;
import java.util.List;

public class ImmutableDemo {
    public static void main(String[] args) {
        List<String> tags = new ArrayList<>(List.of("java", "oop"));
        Article a = new Article("Encapsulation", tags);

        tags.add("hacked");                  // changing the caller's list...
        System.out.println(a.getTags());     // ...does not affect the article

        try {
            a.getTags().add("hacked again");
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify tags from outside");
        }

        Article b = a.withTitle("Encapsulation in Java");
        System.out.println(a.getTitle() + " / " + b.getTitle());
    }
}

final class Article {
    private final String title;
    private final List<String> tags;

    Article(String title, List<String> tags) {
        this.title = title;
        this.tags = List.copyOf(tags);       // defensive, unmodifiable copy
    }

    public String getTitle() { return title; }
    public List<String> getTags() { return tags; }   // already unmodifiable

    public Article withTitle(String newTitle) {      // "wither" returns a new object
        return new Article(newTitle, tags);
    }
}
Output
[java, oop]
Cannot modify tags from outside
Encapsulation / Encapsulation in Java

Immutable objects are simple to reason about, safe to share between threads, and make excellent HashMap keys. For pure data classes, Java's records (a later lesson) give you this almost for free.

Package-private in practice#

Leaving off a modifier means "visible to my package". It is useful for helpers that several classes in the same package collaborate on but that should not be part of the public API:

Java
package com.elephantoo.billing;

public class InvoiceService {          // public API of the package
    public Invoice create(Order o) {
        return new Invoice(TaxCalculator.taxFor(o));
    }
}

class TaxCalculator {                  // package-private: an implementation detail
    static double taxFor(Order o) { /* ... */ return 0; }
}

Code outside com.elephantoo.billing cannot even see TaxCalculator, so you can rewrite it freely. You will learn how packages work in the packages lesson.

Why protected should be rare#

Java
public class Shape {
    protected String name;   // visible to subclasses AND the whole package
}

protected fields couple subclasses to the parent's internals. Prefer private fields with protected (or public) methods. You will see this in the inheritance lesson.

A checklist for well-encapsulated classes#

  • All fields private (and final where possible).
  • Constructors establish a valid state and reject invalid input.
  • Every public method keeps the object valid.
  • No mutable internals leak out (return copies or unmodifiable views).
  • Internal helpers are private.
  • The public API describes behaviour, not just raw data access.

Common mistakes#

  • Public fields "for convenience", which lose all control over the data.
  • Setters with no validation, which put the same problem behind a method.
  • Returning internal mutable collections or arrays directly.
  • Exposing implementation details as public, because once others depend on them, you can never change them.

What's next#

With encapsulated building blocks in hand, we can start building class hierarchies. Next up: inheritance with extends and super.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Which access modifier allows access only from inside the same class?

  2. 2.A field has no access modifier at all. Who can access it?

  3. 3.A class has private final List<String> tags; and public List<String> getTags() { return tags; }. What is the problem?

Finished reading?

Mark this lesson complete to track your progress.