Skip to main content
Java beginner Lesson 13 of 58

Encapsulation in Java

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

Encapsulation means bundling data and the methods that operate on that data into a single unit (the class), while controlling who can access or modify that data. It is the first and most fundamental OOP pillar — without it, the other three pillars cannot be applied effectively.

The Problem Encapsulation Solves

When fields are public, any code anywhere in the codebase can write any value directly — there is no validation, no audit trail, and no way to enforce rules about what values are legal. Bugs become nearly impossible to trace because the corruption can come from anywhere. Encapsulation solves this by routing all changes through controlled methods.

// BAD — no encapsulation
class BankAccountBad {
    public double balance; // anyone can set this to anything
}

BankAccountBad acc = new BankAccountBad();
acc.balance = -999999; // no validation, no audit trail, no way to detect this

Encapsulation prevents this by making the field private and routing all changes through controlled methods.

Access Modifiers

Access modifiers are the enforcement mechanism for encapsulation. They define the boundary around each piece of data or behaviour. The rule of thumb: start with private, and only open up as needed.

ModifierClassPackageSubclassWorld
private
(none)
protected
public

A Well-Encapsulated Class

This example shows all the key practices together: private fields, validation in the constructor and mutating methods, read-only getters for truly immutable fields, and a defensive copy on the collection getter so callers cannot bypass the encapsulation by mutating the returned list.

public class BankAccount {

    private final String accountNumber; // immutable — never changes after construction
    private final String owner;
    private double balance;
    private final List<String> transactions = new ArrayList<>();

    public BankAccount(String accountNumber, String owner, double initialBalance) {
        if (initialBalance < 0)
            throw new IllegalArgumentException("Initial balance cannot be negative.");
        this.accountNumber = accountNumber;
        this.owner = owner;
        this.balance = initialBalance;
        log("Account opened with balance " + initialBalance);
    }

    public void deposit(double amount) {
        if (amount <= 0)
            throw new IllegalArgumentException("Deposit must be positive.");
        balance += amount;
        log("Deposited " + amount);
    }

    public void withdraw(double amount) {
        if (amount <= 0)
            throw new IllegalArgumentException("Withdrawal must be positive.");
        if (amount > balance)
            throw new IllegalStateException("Insufficient funds.");
        balance -= amount;
        log("Withdrew " + amount);
    }

    // Read-only getters — no setters for accountNumber or owner (they never change)
    public String getAccountNumber() { return accountNumber; }
    public String getOwner()         { return owner; }
    public double getBalance()       { return balance; }

    // Defensive copy — callers get a view of the list but cannot modify our internal list
    public List<String> getTransactions() {
        return Collections.unmodifiableList(transactions);
    }

    private void log(String message) {
        transactions.add(message);
    }

    @Override
    public String toString() {
        return String.format("BankAccount[%s, owner=%s, balance=%.2f]",
                accountNumber, owner, balance);
    }
}

Using it:

BankAccount acc = new BankAccount("ACC-001", "Alice", 1000.0);
acc.deposit(500.0);
acc.withdraw(200.0);

System.out.println(acc.getBalance());          // 1300.0
System.out.println(acc.getTransactions());
// [Account opened with balance 1000.0, Deposited 500.0, Withdrew 200.0]

// acc.balance = 999999; // COMPILE ERROR — private field

Validated Setters

When a field must be mutable, a setter is the right place to enforce all the rules about what values are acceptable. By calling setters from the constructor, you avoid duplicating validation logic — the constructor and any future update path both go through the same checks.

public class Employee {

    private String name;
    private double salary;
    private String email;

    public Employee(String name, double salary, String email) {
        // Delegate to setters so validation logic lives in one place
        setName(name);
        setSalary(salary);
        setEmail(email);
    }

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

    public void setSalary(double salary) {
        if (salary < 0)
            throw new IllegalArgumentException("Salary cannot be negative.");
        this.salary = salary;
    }

    public void setEmail(String email) {
        if (!email.contains("@"))
            throw new IllegalArgumentException("Invalid email: " + email);
        this.email = email.toLowerCase();
    }

    public String getName()   { return name; }
    public double getSalary() { return salary; }
    public String getEmail()  { return email; }
}

Immutable Classes

The strongest form of encapsulation is making objects immutable — their state can never change after construction. Immutable objects eliminate an entire category of bugs: there is no mutation to track, no thread synchronisation needed, and no risk of one part of the system surprising another by changing a shared value.

public final class Money {          // final prevents subclassing

    private final double amount;    // final fields — assigned once in constructor
    private final String currency;

    public Money(double amount, String currency) {
        if (amount < 0)
            throw new IllegalArgumentException("Amount cannot be negative.");
        this.amount = amount;
        this.currency = currency.toUpperCase();
    }

    // No setters — return new objects instead of mutating the current one
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch.");
        return new Money(this.amount + other.amount, this.currency);
    }

    public Money multiply(double factor) {
        return new Money(this.amount * factor, this.currency);
    }

    public double getAmount()   { return amount; }
    public String getCurrency() { return currency; }

    @Override
    public String toString() {
        return String.format("%.2f %s", amount, currency);
    }
}

Money price    = new Money(29.99, "USD");
Money tax      = price.multiply(0.10);
Money total    = price.add(tax);

System.out.println(price);  // 29.99 USD — original is unchanged
System.out.println(total);  // 32.99 USD

Defensive Copies

When your class holds a mutable object (like an array or Date), you must store a defensive copy — not the original. Otherwise a caller can keep a reference to the object they passed in and mutate your internals after construction, silently bypassing all your encapsulation.

import java.util.Arrays;
import java.util.Date;

public class ImmutableStudent {

    private final String name;
    private final int[] grades;    // arrays are mutable — must copy
    private final Date enrolled;   // Date is mutable — must copy

    public ImmutableStudent(String name, int[] grades, Date enrolled) {
        this.name = name;
        // Defensive copy on the way IN — caller can't mutate our internals
        this.grades = Arrays.copyOf(grades, grades.length);
        this.enrolled = new Date(enrolled.getTime());
    }

    public int[] getGrades() {
        // Defensive copy on the way OUT — caller can't mutate via the returned array
        return Arrays.copyOf(grades, grades.length);
    }

    public Date getEnrolled() {
        return new Date(enrolled.getTime());
    }

    public String getName() { return name; }
}

Summary

PracticeWhy it matters
Make fields privatePrevents accidental external mutation
Validate in setters/constructorsEnsures object is always in a valid state
Return defensive copiesPrevents external code from corrupting internals
Prefer final fieldsMakes intent clear; enables immutability
Prefer immutable objectsThread-safe, simpler to test, no unexpected side effects

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.