Skip to main content
Java · lesson 13 of 66
Open the Java page →
Java beginner Lesson 13 of 66

Encapsulation in Java

Learn how to protect object state with access modifiers, getters, setters, and immutable design — with production-grade Java examples.

By SkillByExample · Updated

Encapsulation means bundling data with the methods that operate on it, and controlling who can change it. Fields stay private; every change goes through methods that enforce the rules. Each example is a single file run with the JDK 21 source launcher (java File.java).

The problem: public fields accept anything

public class NoEncapsulation {
    public static void main(String[] args) {
        BankAccountBad acc = new BankAccountBad();
        acc.balance = 100;
        acc.balance -= 250;          // nothing stops an overdraft
        System.out.println("balance = " + acc.balance);
    }
}

class BankAccountBad {
    public double balance;
}
$ java NoEncapsulation.java
balance = -150.0

Any code anywhere can write any value, so when balance turns out wrong, the culprit could be any line in the program.

Access modifiers

Start with private and open up only as needed.

ModifierSame classSame packageSubclassEverywhere
privateyesnonono
(none)yesyesnono
protectedyesyesyesno
publicyesyesyesyes

Make the field private and the compiler enforces the boundary:

public class PrivateField {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount();
        acc.balance = -150;
    }
}

class BankAccount {
    private double balance;
}
$ java PrivateField.java
PrivateField.java:4: error: balance has private access in BankAccount
        acc.balance = -150;
           ^
1 error
error: compilation failed

A well-encapsulated class

Validation lives in the methods that change state, and the transaction log is handed out as a read-only view.

import java.util.*;

public class Accounts {
    public static void main(String[] args) {
        BankAccount acc = new BankAccount("ACC-001", 1000);
        acc.deposit(500);
        acc.withdraw(200);
        System.out.println(acc.balance() + " " + acc.transactions());
        try {
            acc.withdraw(5000);
        } catch (IllegalStateException e) {
            System.out.println("Rejected: " + e.getMessage());
        }
        acc.transactions().add("Deposited 1000000");
    }
}

class BankAccount {
    private final String id;
    private double balance;
    private final List<String> log = new ArrayList<>();

    BankAccount(String id, double opening) {
        if (opening < 0) throw new IllegalArgumentException("negative opening balance");
        this.id = id;
        this.balance = opening;
    }
    void deposit(double amt) {
        if (amt <= 0) throw new IllegalArgumentException("deposit must be positive");
        balance += amt; log.add("Deposited " + amt);
    }
    void withdraw(double amt) {
        if (amt > balance) throw new IllegalStateException("insufficient funds: " + balance);
        balance -= amt; log.add("Withdrew " + amt);
    }
    double balance()           { return balance; }
    List<String> transactions() { return Collections.unmodifiableList(log); }
}
$ java Accounts.java
1300.0 [Deposited 500.0, Withdrew 200.0]
Rejected: insufficient funds: 1300.0
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1092)
	at Accounts.main(Accounts.java:14)

The overdraft was refused and the balance kept its valid value. The caller can read the log but add on the returned view throws, so the audit trail cannot be forged from outside. There is no setBalance at all — the only ways to change money are deposit and withdraw.

Validated setters

When a field must be settable, put the rules in the setter and have the constructor call it, so there is one place to get validation right.

public class Employees {
    public static void main(String[] args) {
        Employee e = new Employee("  Priya ", "PRIYA@Example.com");
        System.out.println("[" + e.name() + "] " + e.email());
        try {
            new Employee("Bob", "bob.example.com");
        } catch (IllegalArgumentException ex) {
            System.out.println("Rejected: " + ex.getMessage());
        }
    }
}

class Employee {
    private String name, email;

    Employee(String name, String email) { setName(name); setEmail(email); }

    void setName(String name) {
        if (name == null || name.isBlank()) throw new IllegalArgumentException("blank name");
        this.name = name.strip();
    }
    void setEmail(String email) {
        if (!email.contains("@")) throw new IllegalArgumentException("invalid email: " + email);
        this.email = email.toLowerCase();
    }
    String name()  { return name; }
    String email() { return email; }
}
$ java Employees.java
[Priya] priya@example.com
Rejected: invalid email: bob.example.com

A setter can also normalise: the stored name is trimmed and the email lowercased, so every reader sees a consistent value.

Immutable classes

The strongest encapsulation is immutability: no setters, final fields, and operations that return new objects.

public class MoneyDemo {
    public static void main(String[] args) {
        Money price = new Money(2999, "USD");
        Money total = price.add(price.percent(10));
        System.out.println(price + " -> " + total);
        price.add(new Money(500, "EUR"));
    }
}

final class Money {
    private final long cents;
    private final String currency;

    Money(long cents, String currency) { this.cents = cents; this.currency = currency; }

    Money add(Money o) {
        if (!currency.equals(o.currency)) throw new IllegalArgumentException("currency mismatch: " + currency + " vs " + o.currency);
        return new Money(cents + o.cents, currency);
    }
    Money percent(int p) { return new Money(cents * p / 100, currency); }
    public String toString() { return String.format("%d.%02d %s", cents / 100, cents % 100, currency); }
}
$ java MoneyDemo.java
29.99 USD -> 32.98 USD
Exception in thread "main" java.lang.IllegalArgumentException: currency mismatch: USD vs EUR
	at Money.add(MoneyDemo.java:17)
	at MoneyDemo.main(MoneyDemo.java:6)

price still prints 29.99 after add and percent, because they return new objects. Storing whole cents avoids floating-point error; integer division truncates 10% of 2999 cents to 299, which is why the total is 32.98 rather than 32.99.

Defensive copies

private final stops a field being reassigned, not the object it points at being changed. A mutable argument has to be copied on the way in and on the way out.

import java.util.Arrays;

public class DefensiveCopy {
    public static void main(String[] args) {
        int[] grades = {90, 85, 88};
        Leaky leaky = new Leaky(grades);
        Safe safe = new Safe(grades);
        grades[0] = 0;                       // caller changes its own array afterwards
        leaky.grades()[1] = -1;              // caller changes the returned array
        safe.grades()[1] = -1;
        System.out.println("leaky: " + Arrays.toString(leaky.grades()));
        System.out.println("safe:  " + Arrays.toString(safe.grades()));
    }
}

class Leaky {
    private final int[] grades;
    Leaky(int[] g)  { grades = g; }
    int[] grades()  { return grades; }
}

class Safe {
    private final int[] grades;
    Safe(int[] g)   { grades = g.clone(); }
    int[] grades()  { return grades.clone(); }
}
$ java DefensiveCopy.java
leaky: [0, -1, 88]
safe:  [90, 85, 88]

Leaky shares one array with the outside world, so both outside writes landed inside it. Safe copied on the way in and on the way out, so its state can only change through its own methods. For lists, List.copyOf makes an immutable copy in one call.

Summary

PracticeWhy it matters
private fieldscallers can’t bypass the rules
validate in constructors and settersthe object is never in an invalid state
no setter unless neededfewer ways for state to go wrong
immutable valuessafe to share, no surprises
defensive copies of mutable fieldsfinal alone doesn’t protect contents

Practice

  1. Add transfer(BankAccount to, double amt) that withdraws from this account and deposits into to. Show that a failed transfer leaves both balances unchanged.
Solution
public class Transfer {
    public static void main(String[] args) {
        BankAccount a = new BankAccount(100), b = new BankAccount(50);
        a.transfer(b, 30);
        System.out.println(a.balance() + " " + b.balance());
        try { a.transfer(b, 500); } catch (IllegalStateException e) { System.out.println("Rejected: " + e.getMessage()); }
        System.out.println(a.balance() + " " + b.balance());
    }
}

class BankAccount {
    private double balance;
    BankAccount(double opening) { balance = opening; }
    void deposit(double amt)  { if (amt <= 0) throw new IllegalArgumentException("bad amount"); balance += amt; }
    void withdraw(double amt) { if (amt > balance) throw new IllegalStateException("insufficient funds"); balance -= amt; }
    void transfer(BankAccount to, double amt) { withdraw(amt); to.deposit(amt); }
    double balance() { return balance; }
}
$ java Transfer.java
70.0 80.0
Rejected: insufficient funds
70.0 80.0

withdraw runs first and throws before anything changes. If deposit could fail after a successful withdraw, you would need to put the money back — that is what transactions are for.

  1. Tighten setEmail so "bob@" and "@example.com" are rejected but "bob@example.com" passes.
Solution
public class Emails {
    public static void main(String[] args) {
        for (String e : new String[] {"bob@example.com", "bob@", "@example.com"}) {
            try { System.out.println("ok: " + check(e)); }
            catch (IllegalArgumentException ex) { System.out.println("rejected: " + ex.getMessage()); }
        }
    }

    static String check(String email) {
        int at = email.indexOf('@');
        if (at <= 0 || at == email.length() - 1) throw new IllegalArgumentException(email);
        return email.toLowerCase();
    }
}
$ java Emails.java
ok: bob@example.com
rejected: bob@
rejected: @example.com

at <= 0 catches a missing @ (-1) and an empty local part (0); the second check catches an empty domain.

  1. Write Student holding a List<String> of courses so neither the caller’s list nor the returned list can change it.
Solution
import java.util.ArrayList;
import java.util.List;

public class Courses {
    public static void main(String[] args) {
        List<String> mine = new ArrayList<>(List.of("Math", "Art"));
        Student s = new Student(mine);
        mine.add("Hacking");
        System.out.println(s.courses());
        s.courses().add("Hacking");
    }
}

class Student {
    private final List<String> courses;
    Student(List<String> courses) { this.courses = List.copyOf(courses); }
    List<String> courses()        { return courses; }
}
$ java Courses.java
[Math, Art]
Exception in thread "main" java.lang.UnsupportedOperationException
	at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
	at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
	at Courses.main(Courses.java:10)

List.copyOf both copies and makes the copy immutable, so it can be returned directly with no second copy.

  1. Rewrite Money as a record with a compact constructor that rejects negative amounts.
Solution
public class MoneyRecord {
    public static void main(String[] args) {
        Money m = new Money(1250, "USD");
        System.out.println(m + " " + m.cents());
        new Money(-1, "USD");
    }
}

record Money(long cents, String currency) {
    Money {
        if (cents < 0) throw new IllegalArgumentException("negative amount: " + cents);
    }
}
$ java MoneyRecord.java
Money[cents=1250, currency=USD] 1250
Exception in thread "main" java.lang.IllegalArgumentException: negative amount: -1
	at Money.<init>(MoneyRecord.java:11)
	at MoneyRecord.main(MoneyRecord.java:5)

A record’s fields are private final and it has no setters, so you get an immutable class in one line; the compact constructor adds validation before the fields are assigned.

Frequently Asked Questions

What are the four access modifiers in Java?
private (class only), package-private/default (same package), protected (same package + subclasses), and public (everywhere).
Should I always provide getters and setters for every field?
No. Only expose what callers actually need. Many fields should be private with no setter — mutating them freely often causes bugs. Prefer immutable objects where possible.
What is the difference between encapsulation and information hiding?
They are closely related. Encapsulation is the mechanism (bundling data + behaviour). Information hiding is the design goal (hiding internal details). Java's access modifiers implement information hiding as part of encapsulation.
Does making a field private final make an object immutable?
Not if the field refers to a mutable object such as an array, a List or a java.util.Date. final only stops the reference being reassigned; the object it points to can still change. Copy mutable values on the way in and out, or use immutable types like List.copyOf and records.