Encapsulation

Su Suriya Ravichandran Updated 14 Nov 2026
8 min read ·Lesson 50 of 133

Encapsulation means keeping an object's internal data hidden and allowing it to be accessed or changed only through the object's own methods. The class bundles its data together with the rules for using that data, and it alone is responsible for keeping that data valid. Encapsulation is what lets a class make promises ("the balance is never negative," "the email is always lowercase") that the rest of the program can rely on.

The Problem Without Encapsulation

public class Account {
    public String owner;
    public double balance;
}
Account acc = new Account();
acc.owner = "Ada";
acc.balance = 500;

acc.balance = -10_000;     // nothing prevents an impossible state
acc.owner = null;          // or a missing owner

With public fields, every piece of code in the program can put the object into any state. If the balance ever becomes negative, the bug could be anywhere. Rules such as "no overdrafts" must be re-checked by every caller, and one forgotten check corrupts the data.

There is a second, quieter problem: other classes now depend on the field's exact name and type. Changing balance from double to long paise, or renaming it, breaks every class that touches it.

Encapsulating the Data

public class Account {
    private final String owner;
    private double balance;

    public Account(String owner, double openingBalance) {
        if (owner == null || owner.isBlank()) {
            throw new IllegalArgumentException("owner is required");
        }
        if (openingBalance < 0) {
            throw new IllegalArgumentException("opening balance cannot be negative");
        }
        this.owner = owner;
        this.balance = openingBalance;
    }

    public void deposit(double amount) {
        if (amount <= 0) {
            throw new IllegalArgumentException("deposit must be positive");
        }
        balance += 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;
    }

    public double getBalance() { return balance; }
    public String getOwner()   { return owner; }
}
Account acc = new Account("Ada", 500);
acc.deposit(200);
acc.withdraw(100);
System.out.println(acc.getBalance());   // 600.0

acc.balance = -10_000;                  // compile error: balance has private access
acc.withdraw(5000);                     // IllegalStateException: insufficient funds

The fields are private, so only code inside Account can touch them. The constructor and methods are the only entry points, and each one enforces the rules. It is now impossible, anywhere in the program, to create an Account with a negative balance or a missing owner.

The Two Parts of Encapsulation

Data hiding. Fields are private. Outside code cannot read or modify them directly.

Controlled access. Public methods expose exactly the operations that make sense, each validating its inputs and keeping the object consistent.

The set of public methods is the class's interface (in the general sense): what other code can rely on. Everything else, including the fields, helper methods, and the algorithm used, is implementation, free to change.

Getters and Setters

Methods that read a field are called getters (accessors); methods that set one are setters (mutators). By convention they are named getX / isX (for booleans) and setX:

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

    public String getName() { return name; }

    public double getPrice() { return price; }

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

    public boolean isActive() { return active; }
}

A setter is better than a public field because it can validate. But a setter for every field is not encapsulation; it is a public field with extra steps. Ask for each field:

  • Should outside code see it? If not, no getter.
  • Should it ever change after construction? If not, make it final and provide no setter.
  • Is "set" the right operation? Often a domain-specific method is more accurate.
// Weak: any balance can be set
public void setBalance(double balance) { this.balance = balance; }

// Strong: only meaningful operations are possible
public void deposit(double amount) { ... }
public void withdraw(double amount) { ... }

setBalance lets a caller skip every rule deposit and withdraw enforce. Methods named after real operations (deposit, activate, markShipped, applyCoupon) express intent and keep the rules in one place.

Changing the Implementation Safely

Because callers depend only on methods, the internal representation can change without affecting them. Suppose rounding errors in double become a problem and the account switches to storing paise as a long:

public class Account {
    private final String owner;
    private long balancePaise;              // changed representation

    public void deposit(double amount) {
        if (amount <= 0) throw new IllegalArgumentException("deposit must be positive");
        balancePaise += Math.round(amount * 100);
    }

    public double getBalance() {
        return balancePaise / 100.0;        // same method, same return type
    }

    // ...
}

Every class that calls deposit and getBalance continues to work unchanged. With a public balance field, every one of them would have needed rewriting. This freedom to change internals is one of the most practical benefits of encapsulation in long-lived codebases.

Protecting Mutable Fields

Making a field private is not enough if a getter hands out a reference to a mutable object:

public class Classroom {
    private final List<String> students = new ArrayList<>();

    public void enroll(String name) {
        if (students.size() >= 30) throw new IllegalStateException("class is full");
        students.add(name);
    }

    public List<String> getStudents() {
        return students;            // leaks the internal list
    }
}
Classroom c = new Classroom();
c.getStudents().add("Unchecked student");   // bypasses the capacity rule
c.getStudents().clear();                    // erases the class

The caller received a reference to the actual internal list and can change it freely. Two common fixes:

public List<String> getStudents() {
    return List.copyOf(students);                    // unmodifiable snapshot
}

public List<String> getStudents() {
    return Collections.unmodifiableList(students);   // read-only live view
}

The same issue arises in the other direction. If a constructor stores a list it was given, the caller still holds a reference to it:

public Classroom(List<String> initial) {
    this.students = new ArrayList<>(initial);        // copy instead of storing the caller's list
}

This is called defensive copying: copy mutable objects when they enter or leave the class. Immutable types (String, LocalDate, records with immutable components) do not need it.

Hiding Behavior, Not Just Data

Encapsulation applies to methods too. Helper methods that exist only to support the public operations should be private:

public class PasswordPolicy {
    public boolean isAcceptable(String password) {
        return password != null
                && password.length() >= 10
                && hasDigit(password)
                && hasUpperCase(password);
    }

    private boolean hasDigit(String s) {
        return s.chars().anyMatch(Character::isDigit);
    }

    private boolean hasUpperCase(String s) {
        return s.chars().anyMatch(Character::isUpperCase);
    }
}

Other classes can ask only one question: isAcceptable. The checks behind it can be added, removed, or reimplemented without affecting any caller.

Access Modifiers as Encapsulation Tools

ModifierVisible toTypical use
privateThe same class onlyFields, internal helpers
(none) package-privateClasses in the same packageCollaborating classes within one feature or module
protectedSame package, plus subclassesExtension points for subclasses
publicEveryoneThe class's published API

Start with the most restrictive level and widen only when needed. A private member can be changed freely; a public one is a commitment to every caller. Access modifiers have their own lesson.

Encapsulation and Records

Records are encapsulated by default: their fields are private final, accessible only through generated accessor methods. Validation goes in a compact constructor:

public record Email(String value) {
    public Email {
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("invalid email: " + value);
        }
        value = value.strip().toLowerCase();
    }
}

Email e = new Email("  Ada@Example.COM ");
System.out.println(e.value());    // ada@example.com

Every Email object in the program is guaranteed to be valid and normalized. Wrapping a primitive or a String in a small type like this, rather than passing raw strings around, moves validation to one place and makes method signatures more precise (send(Email to) instead of send(String to)).

Practical Example: Inventory Item

import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class InventoryItem {
    private static final int REORDER_LEVEL = 10;

    private final String sku;
    private int quantity;
    private final List<String> movements = new ArrayList<>();

    public InventoryItem(String sku, int initialQuantity) {
        if (!sku.matches("[A-Z]{3}-\\d{4}")) {
            throw new IllegalArgumentException("SKU must look like ABC-1234");
        }
        if (initialQuantity < 0) {
            throw new IllegalArgumentException("quantity cannot be negative");
        }
        this.sku = sku;
        this.quantity = initialQuantity;
        log("OPEN", initialQuantity);
    }

    public void receive(int units) {
        requirePositive(units);
        quantity += units;
        log("IN", units);
    }

    public boolean ship(int units) {
        requirePositive(units);
        if (units > quantity) {
            return false;
        }
        quantity -= units;
        log("OUT", units);
        return true;
    }

    public int getQuantity() { return quantity; }
    public String getSku() { return sku; }
    public boolean needsReorder() { return quantity < REORDER_LEVEL; }
    public List<String> getMovements() { return Collections.unmodifiableList(movements); }

    private void log(String type, int units) {
        movements.add(LocalDate.now() + " " + type + " " + units + " -> " + quantity);
    }

    private static void requirePositive(int units) {
        if (units <= 0) throw new IllegalArgumentException("units must be positive");
    }

    public static void main(String[] args) {
        InventoryItem item = new InventoryItem("KBD-1001", 25);
        item.ship(12);
        item.receive(5);
        System.out.println(item.ship(50));          // false: not enough stock
        item.ship(10);

        System.out.println(item.getSku() + ": " + item.getQuantity());
        System.out.println("Reorder? " + item.needsReorder());
        item.getMovements().forEach(System.out::println);
    }
}

Example output (dates depend on when it runs):

false
KBD-1001: 8
Reorder? true
2026-11-10 OPEN 25 -> 25
2026-11-10 OUT 12 -> 13
2026-11-10 IN 5 -> 18
2026-11-10 OUT 10 -> 8

quantity is private and changes only through receive and ship, so stock can never go negative and every change is logged. There is no setQuantity, because "set the quantity to an arbitrary value" is not a legitimate warehouse operation. The movement log is exposed as an unmodifiable view, so callers can read the history but cannot falsify it. log and requirePositive are private helpers that callers never need to know about. The class enforces its rules completely on its own, which is exactly what encapsulation is for.

0 Comments

Reviewed before they appear

No comments yet.

Java
Java Introduction Java Features Java Syntax Java Comments Java Statements Java Output Java Variables Java Data Types Java Type Casting Java Operators Java Strings Java Numbers Java Math Java Booleans Java User Input Java Conditions Java If Else Java Switch Java While Loop Java For Loop Java Break Java Continue Java Arrays Java Methods Java Parameters Java Return Values Java Scope Java Recursion Install Java Install JDK Java IDEs Java JDK Java JRE Java JVM Java Compiler Java File Compile Java Run Java JShell Basics Java Packages Java OOP Java Classes Java Objects Class Methods Constructors Constructor Overload this Keyword static Keyword final Keyword Encapsulation Inheritance Polymorphism Abstraction Access Modifiers super Keyword Method Overload Method Override Instanceof Operator Nested Classes Inner Classes Anonymous Classes Enums Records Array Basics Array Length Loop Arrays Multidimensional Arrays Array Copy Array Sorting Array Search ArrayList LinkedList HashSet TreeSet HashMap TreeMap Queue Interface Deque Interface Stack Class Iterator List Interface Set Interface Map Interface Collections Class Comparable Comparator Generics Basics Generic Classes Generic Methods Wildcards String Basics String Length String Methods String Concatenation String Compare String Search String Replace String Split String Format StringBuilder StringBuffer Text Blocks Regular Expressions Number Types Integer Class Float Class Double Class BigInteger BigDecimal Math Class Random Numbers Number Format Date Basics LocalDate LocalTime LocalDateTime ZonedDateTime Date Formatting Date Parsing Period Class Duration Class Java Instant Class Java Exception Basics Java try...catch Java Multiple Catch Blocks Java finally Block Java throw Keyword Java throws Keyword Java Checked Exceptions Java Unchecked Exceptions Java Custom Exceptions Java try-with-resources Java Exception Chaining
Ask about this post
AI Ask about this post

Ask questions about Encapsulation and get answers drawn from it.

Signed-in readers only.