Skip to main content

7.5 Encapsulation & Access Modifiers

In object-oriented programming, encapsulation means bundling a class's data (fields) and the methods that operate on that data into a single unit, and hiding important internal data from unauthorized external access. This is also called Information Hiding.

Think of a medicine capsule — it wraps bitter powder so the patient can take it safely and comfortably. In Java, encapsulation is achieved with Access Modifiers.


1. The Four Access Modifiers

Access modifiers are used when declaring members (fields, methods) or classes to restrict the scope of access. They are essential for security and data integrity. Java's four access modifiers, listed from widest to narrowest scope:

ModifierDescriptionVisibility Scope
publicNo restrictions. Accessible from anywhere.Everywhere
protectedAccessible within the same package AND from subclasses in other packages.Package + inheritance
(default)No keyword specified. Accessible only within the same package.Package only
privateAccessible only within the same class where it is declared. Narrowest scope.Class only

Access Modifier Scope Visualization

┌──────────────────────────────────────────┐
│ Same class (private, default, protected, public)
│ ┌────────────────────────────────────┐ │
│ │ Same package (default, protected, public) │ │
│ │ ┌──────────────────────────────┐ │ │
│ │ │ Inheritance (protected, public) │ │ │
│ │ │ ┌──────────────────────┐ │ │ │
│ │ │ │ Everywhere (public) │ │ │ │
│ │ │ └──────────────────────┘ │ │ │
│ │ └──────────────────────────────┘ │ │
│ └────────────────────────────────────┘ │
└──────────────────────────────────────────┘
Industry Convention

Always declare class state (member fields) as private to block direct external access. Only expose methods that need to be called from outside as public.

Access Modifier Example

package com.example.shop;

public class Product {
public String productId; // accessible from anywhere
protected String category; // same package + subclasses
String name; // (default) same package only
private double price; // only inside this class

public double getPrice() {
return price; // can access private field from inside
}
}
package com.example.shop;

public class ProductTest {
public static void main(String[] args) {
Product p = new Product();
p.productId = "P001"; // OK - public
p.category = "Electronics"; // OK - same package
p.name = "Laptop"; // OK - same package (default)
// p.price = 999.99; // Compile error! private
System.out.println(p.getPrice()); // OK - public method
}
}

2. Encapsulation in Practice — Getters and Setters

When a field is private, how can external classes read or modify it? Through public methods only — specifically Getters and Setters.

Why Not Expose Fields Directly?

// Bad example — field is public, anyone can set age = -500
public class BadPerson {
public int age;
}

// Good example — private field + getter/setter with validation
public class GoodPerson {
private int age;

public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age);
}
this.age = age;
}

public int getAge() {
return age;
}
}

Complete Getter/Setter Example with Validation

public class Person {
// 1. All fields are strictly private.
private String name;
private int age;
private String email;

// Default constructor
public Person() {}

// Constructor initializing all fields
public Person(String name, int age, String email) {
setName(name); // validation via setters
setAge(age);
setEmail(email);
}

// --- Setters ---

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

public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age);
}
this.age = age;
}

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

// --- Getters ---

public String getName() { return name; }
public int getAge() { return age; }
public String getEmail() { return email; }

@Override
public String toString() {
return "Person{name='" + name + "', age=" + age + ", email='" + email + "'}";
}
}
public class PersonTest {
public static void main(String[] args) {
Person p = new Person("Alice", 25, "alice@example.com");
System.out.println(p); // Person{name='Alice', age=25, email='alice@example.com'}

// Validation fails
try {
p.setAge(-5);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage()); // Error: Invalid age: -5
}

// Valid update
p.setAge(30);
System.out.println("Updated age: " + p.getAge()); // Updated age: 30
}
}

3. Immutable Objects

By providing no setters and using private final fields, you can create an immutable object that can never change after construction. Immutable objects are thread-safe and less prone to bugs.

public class ImmutablePoint {
private final double x; // final: can only be initialized once
private final double y;

// Values set only in constructor
public ImmutablePoint(double x, double y) {
this.x = x;
this.y = y;
}

// Only getters — no setters
public double getX() { return x; }
public double getY() { return y; }

// Need a new coordinate? Return a new object.
public ImmutablePoint translate(double dx, double dy) {
return new ImmutablePoint(x + dx, y + dy);
}

@Override
public String toString() {
return "(" + x + ", " + y + ")";
}
}
public class ImmutableTest {
public static void main(String[] args) {
ImmutablePoint p1 = new ImmutablePoint(3.0, 4.0);
System.out.println("Original: " + p1); // (3.0, 4.0)

// p1.x = 10; // Compile error! final field cannot be changed

// Translation returns a new object; original unchanged
ImmutablePoint p2 = p1.translate(2.0, 1.0);
System.out.println("p1 after translate: " + p1); // (3.0, 4.0) — unchanged
System.out.println("p2 after translate: " + p2); // (5.0, 5.0)
}
}
Famous Immutable Examples

Java's standard library String, Integer, and LocalDate are all immutable objects. Once created, their values never change — any transformation returns a new object.


4. Java Record (Java 16+)

Introduced in Java 16, record lets you declare an immutable data class extremely concisely. The compiler automatically generates private final fields, a constructor, getters, equals(), hashCode(), and toString().

// Record declaration — one line!
public record Point(double x, double y) {}

// The above record is equivalent to:
// - private final double x;
// - private final double y;
// - constructor accepting all fields
// - x() and y() accessor methods (instead of getX()/getY())
// - auto-implemented equals(), hashCode(), toString()
public record Person(String name, int age, String email) {
// Compact constructor: add validation here
public Person {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be empty.");
}
if (age < 0 || age > 150) {
throw new IllegalArgumentException("Invalid age: " + age);
}
}

// Additional methods can still be defined
public boolean isAdult() {
return age >= 18;
}
}
public class RecordTest {
public static void main(String[] args) {
Person p = new Person("Alice", 22, "alice@example.com");

// Record accessor methods (no "get" prefix)
System.out.println(p.name()); // Alice
System.out.println(p.age()); // 22
System.out.println(p.email()); // alice@example.com
System.out.println(p.isAdult()); // true

// Auto-generated toString()
System.out.println(p); // Person[name=Alice, age=22, email=alice@example.com]

// Auto-generated equals()
Person p2 = new Person("Alice", 22, "alice@example.com");
System.out.println(p.equals(p2)); // true

// Records are immutable — cannot modify
// p.name = "Bob"; // Compile error!
}
}
record vs Regular Class
  • For objects that simply hold data (DTOs, value objects), record is strongly recommended.
  • When you need mutable state or complex logic, use a regular class.

5. Package-Level Access Control in Practice

Packages are like folders that group related classes. Access modifiers work in close conjunction with package boundaries.

com.example
├── shop
│ ├── Product.java (public class)
│ └── ProductManager.java (same package → can access default members)
└── customer
└── Cart.java (different package → only public accessible)
package com.example.shop;

public class Product {
private double price; // no direct access from anywhere
double stockCount; // (default) only within shop package
protected String supplierId; // shop package + subclasses of Product
public String productId; // accessible from anywhere

public double getPrice() { return price; }
public void setPrice(double price) {
if (price < 0) throw new IllegalArgumentException("Price cannot be negative.");
this.price = price;
}
}
package com.example.shop;

// Same package — can access default and protected
public class ProductManager {
public void manage(Product p) {
System.out.println(p.productId); // OK - public
System.out.println(p.supplierId); // OK - protected (same package)
System.out.println(p.stockCount); // OK - default (same package)
System.out.println(p.getPrice()); // OK - public method
// p.price; // Compile error! private
}
}
package com.example.customer;

import com.example.shop.Product;

// Different package — only public accessible
public class Cart {
public void addItem(Product p) {
System.out.println(p.productId); // OK - public
System.out.println(p.getPrice()); // OK - public method
// p.supplierId; // Compile error! protected (different package, not subclass)
// p.stockCount; // Compile error! default (different package)
}
}

6. Practical Example: BankAccount Class

A bank account implemented with encapsulation — the balance can never be modified directly from outside; it must go through deposit/withdraw methods.

public class BankAccount {
private final String accountNumber; // never changeable
private final String owner; // never changeable
private double balance; // protected by private
private int transactionCount;

public BankAccount(String accountNumber, String owner, double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException("Initial balance must be >= 0.");
}
this.accountNumber = accountNumber;
this.owner = owner;
this.balance = initialBalance;
this.transactionCount = 0;
}

// Deposit
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Deposit amount must be > 0.");
}
balance += amount;
transactionCount++;
System.out.printf("[Deposit] $%,.2f → Balance: $%,.2f%n", amount, balance);
}

// Withdraw
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException("Withdrawal amount must be > 0.");
}
if (amount > balance) {
throw new IllegalStateException(
String.format("Insufficient funds. Requested: $%,.2f, Balance: $%,.2f", amount, balance)
);
}
balance -= amount;
transactionCount++;
System.out.printf("[Withdraw] $%,.2f → Balance: $%,.2f%n", amount, balance);
}

// Transfer
public void transfer(BankAccount target, double amount) {
this.withdraw(amount);
target.deposit(amount);
System.out.printf("[Transfer] %s → %s, $%,.2f%n",
this.owner, target.owner, amount);
}

// Getters — read access allowed
public String getAccountNumber() { return accountNumber; }
public String getOwner() { return owner; }
public double getBalance() { return balance; }
public int getTransactionCount() { return transactionCount; }

// No setters for accountNumber or owner — immutable
// balance is only modified through deposit/withdraw

@Override
public String toString() {
return String.format("Account[%s] Owner: %s, Balance: $%,.2f",
accountNumber, owner, balance);
}
}
public class BankAccountTest {
public static void main(String[] args) {
BankAccount alice = new BankAccount("110-1234-5678", "Alice", 1000.00);
BankAccount bob = new BankAccount("220-8765-4321", "Bob", 500.00);

System.out.println(alice);
System.out.println(bob);
System.out.println();

alice.deposit(300.00);
alice.withdraw(200.00);
alice.transfer(bob, 400.00);
System.out.println();

System.out.println(alice);
System.out.println(bob);
System.out.println("Alice's transaction count: " + alice.getTransactionCount());

// Insufficient funds attempt
System.out.println();
try {
alice.withdraw(5000.00);
} catch (IllegalStateException e) {
System.out.println("Error: " + e.getMessage());
}

// Direct balance manipulation attempt (not possible!)
// alice.balance = 999999; // Compile error! private field
}
}

[Output]

Account[110-1234-5678] Owner: Alice, Balance: $1,000.00
Account[220-8765-4321] Owner: Bob, Balance: $500.00

[Deposit] $300.00 → Balance: $1,300.00
[Withdraw] $200.00 → Balance: $1,100.00
[Withdraw] $400.00 → Balance: $700.00
[Deposit] $400.00 → Balance: $900.00
[Transfer] Alice → Bob, $400.00

Account[110-1234-5678] Owner: Alice, Balance: $700.00
Account[220-8765-4321] Owner: Bob, Balance: $900.00
Alice's transaction count: 3

Error: Insufficient funds. Requested: $5,000.00, Balance: $700.00

7. Real Benefits of Encapsulation

Encapsulation is more than just hiding fields — it is a core principle that raises software quality.

Improved Maintainability

// Internal implementation can change without breaking external code
public class Temperature {
private double celsius; // stored internally as Celsius

public void setFahrenheit(double f) {
this.celsius = (f - 32) * 5 / 9; // conversion logic is hidden
}

public double getCelsius() {
return celsius;
}

public double getFahrenheit() {
return celsius * 9 / 5 + 32; // converts when needed
}
}

Easy to Change

If you switch the internal storage from Celsius to Kelvin tomorrow, only the Temperature class needs updating. Hundreds of callers don't need to change at all.

Data Integrity

public class Percentage {
private int value; // must be 0 ~ 100

public void setValue(int value) {
if (value < 0 || value > 100) {
throw new IllegalArgumentException("Percentage must be 0-100: " + value);
}
this.value = value;
}

public int getValue() { return value; }
}

Conclusion

Encapsulation is one of the four core OOP principles (encapsulation, inheritance, polymorphism, abstraction).

  • private fields + public methods: Always start with this structure as the default.
  • Getters/Setters: Not just wrappers — they are the gateways for validation and business logic.
  • Immutable Objects: No setters and final fields increase thread safety and predictability.
  • Java Record: Java 16+ offers concise declaration of immutable data classes.

These concepts form the foundation for Spring Boot's @Service, @Repository layered architecture and design patterns like Builder and Factory that you will encounter next.