Java Encapsulation

INTERMEDIATE ~8 min read Tutorial

Encapsulation is the practice of bundling state and behaviour together inside a class while hiding the implementation from the outside world. It is the principle that lets you change the inside of a class without breaking the rest of the codebase. Java supports encapsulation through access modifiers (private, protected, public) and through the convention of getter and setter methods.

This tutorial covers why encapsulation matters, the standard pattern, validation in setters, immutability as the strongest form of encapsulation, and modern Java features that reduce boilerplate while keeping state safe.

1. Why Encapsulation Matters

Without encapsulation, any code anywhere in the system can read or write your object's fields. The class has no way to enforce its invariants. Consider a BankAccount with a public balance field:

java
public class BankAccount {
    public double balance;   class=class="tok-str">"tok-cmt">// public - anyone can write any value!

    public BankAccount(double opening) {
        this.balance = opening;
    }
}

class=class="tok-str">"tok-cmt">// in another file:
BankAccount acct = new BankAccount(class="tok-num">100.0);
acct.balance = -class="tok-num">50.0;          class=class="tok-str">"tok-cmt">// illegal but allowed
acct.balance = Double.NaN;     class=class="tok-str">"tok-cmt">// nonsense but allowed
acct.balance = Double.MAX_VALUE; class=class="tok-str">"tok-cmt">// absurd but allowed

Nothing stops a caller from setting the balance to a negative number, or to Double.NaN, or to 1.0E15. The class cannot defend itself. Encapsulation hands control back to the class.

2. The Standard Pattern: Private Fields, Public Methods

The classic Java pattern: declare fields private, expose access through methods:

java
public class BankAccount {
    private double balance;   class=class="tok-str">"tok-cmt">// private - only this class can read/write

    public BankAccount(double opening) {
        if (opening < class="tok-num">0)
            throw new IllegalArgumentException("opening must be >= class="tok-num">0");
        this.balance = opening;
    }

    public double getBalance() { return balance; }

    public void deposit(double amount) {
        if (amount <= class="tok-num">0)
            throw new IllegalArgumentException("amount must be positive");
        balance += amount;
    }

    public void withdraw(double amount) {
        if (amount <= class="tok-num">0)
            throw new IllegalArgumentException("amount must be positive");
        if (amount > balance)
            throw new IllegalStateException("insufficient funds");
        balance -= amount;
    }
}

Now the class can validate every write and refuse bad values. The internal representation (a double balance) can later change (e.g. to long cents to avoid floating-point issues) without changing the public API.

3. Validation in Setters

Setters are the natural place to enforce invariants:

java
public class User {
    private String email;
    private int age;

    private static final java.util.regex.Pattern EMAIL =
        java.util.regex.Pattern.compile("^[^@]+@[^@]+\\.[^@]+$");

    public void setEmail(String email) {
        if (email == null || !EMAIL.matcher(email).matches())
            throw new IllegalArgumentException("invalid email");
        this.email = email;
    }

    public void setAge(int age) {
        if (age < class="tok-num">0 || age > class="tok-num">150)
            throw new IllegalArgumentException("age out of range");
        this.age = age;
    }
}

Throw an exception for invalid values rather than silently ignoring them. The fail-fast principle is a long-term productivity win: bugs surface at the moment they happen, not three layers later.

4. Immutability: The Strongest Encapsulation

If a class has no setters at all, no code outside can mutate its state. The state is fixed at construction and never changes. This is the strongest form of encapsulation:

java
public final class Money {
    private final long cents;   class=class="tok-str">"tok-cmt">// final = assigned once, in constructor

    public Money(long cents) {
        if (cents < class="tok-num">0) throw new IllegalArgumentException();
        this.cents = cents;
    }

    public long cents() { return cents; }

    class=class="tok-str">"tok-cmt">// operations return a new Money, never mutate this
    public Money plus(Money other) {
        return new Money(this.cents + other.cents);
    }

    public Money minus(Money other) {
        return new Money(this.cents - other.cents);
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m && m.cents == this.cents;
    }

    @Override
    public int hashCode() { return Long.hashCode(cents); }
}

Immutable classes are thread-safe by default, can be used as HashMap keys without fear, and have no inconsistency windows during which a half-updated object is visible. Effective Java recommends “favor immutability” wherever feasible.

5. Records: Immutability Without Boilerplate

Java 16 added record, which gives you an immutable data carrier in one line:

java
public record User(String email, int age) {
    public User {   class=class="tok-str">"tok-cmt">// compact constructor - validation
        if (email == null || !email.contains("@"))
            throw new IllegalArgumentException("invalid email");
        if (age < class="tok-num">0 || age > class="tok-num">150)
            throw new IllegalArgumentException("invalid age");
    }
}

User u = new User("alice@example.com", class="tok-num">30);
class=class="tok-str">"tok-cmt">// u.email() returns "alice@example.com"
class=class="tok-str">"tok-cmt">// u.age()   returns class="tok-num">30
class=class="tok-str">"tok-cmt">// u.setEmail(...) does not exist - immutable

Use records for value objects, DTOs, command objects, and any case where you would otherwise write private final fields, a constructor, and getters by hand.

6. The Builder Pattern for Complex Construction

When a class has many optional fields, a single constructor with a long parameter list becomes unreadable. The builder pattern keeps immutability while making construction fluent:

java
public final class Pizza {
    private final int size;
    private final boolean extraCheese;
    private final boolean pepperoni;
    private final boolean mushrooms;

    private Pizza(Builder b) {
        this.size = b.size;
        this.extraCheese = b.extraCheese;
        this.pepperoni = b.pepperoni;
        this.mushrooms = b.mushrooms;
    }

    public static class Builder {
        private final int size;   class=class="tok-str">"tok-cmt">// required
        private boolean extraCheese = false;
        private boolean pepperoni = false;
        private boolean mushrooms = false;

        public Builder(int size) { this.size = size; }
        public Builder extraCheese()  { this.extraCheese = true;  return this; }
        public Builder pepperoni()     { this.pepperoni = true;    return this; }
        public Builder mushrooms()     { this.mushrooms = true;   return this; }
        public Pizza build() { return new Pizza(this); }
    }
}

Pizza p = new Pizza.Builder(class="tok-num">12)
    .extraCheese()
    .mushrooms()
    .build();

Builders are particularly useful when you have required fields plus many optional ones, or when the order of construction matters. They also let you compute derived fields once at build time.

7. Package-Private Access for Internal Helpers

Not everything should be private or public. The default (no modifier) is package-private: visible only inside the same package. Use it for classes, methods, and fields that are implementation details shared across classes in the same package:

java
class=class="tok-str">"tok-cmt">// in package com.dashboardesk.tutorials
class InternalCache { class=class="tok-str">"tok-cmt">/* package-private - not visible outside */ }

public class PublicService {
    private InternalCache cache;   class=class="tok-str">"tok-cmt">// can use the package-private class

    void helperMethod() { class=class="tok-str">"tok-cmt">/* package-private - only this package */ }

    public void publicMethod() {
        class=class="tok-str">"tok-cmt">// external contract - callable from anywhere
        helperMethod();
    }
}

This is the principle of “least visibility”: start with private, widen to package-private when truly needed inside the package, widen to protected only for genuinely subclass-oriented APIs, and reserve public for the external contract.

Exercises

  1. Convert a Person class with public fields into one with private fields, getters, and setters. Add validation in the age setter.
  2. Make Person immutable: remove the setters, mark fields final, provide a constructor.
  3. Convert Person to a record with a compact constructor that rejects empty names.
  4. Write a Pizza builder that takes a required size and optional toppings, then build an immutable Pizza.