Encapsulation
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
finaland 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
| Modifier | Visible to | Typical use |
|---|---|---|
private | The same class only | Fields, internal helpers |
| (none) package-private | Classes in the same package | Collaborating classes within one feature or module |
protected | Same package, plus subclasses | Extension points for subclasses |
public | Everyone | The 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.