Java Encapsulation

So far, you’ve accessed object fields directly. Set a dog’s name with dog.name = "Buddy". Read a car’s mileage with car.mileage. This works but creates problems. Any code can set invalid values. A dog could end up with a negative age. A bank account could have its balance changed without a transaction record.

Encapsulation solves this by hiding an object’s internal data and controlling access through methods. The object protects its own state.

The Problem with Public Fields

Consider a BankAccount with public fields:

public class BankAccount {
    public String accountNumber;
    public double balance;
}

BankAccount account = new BankAccount();
account.balance = 1000;

// Anyone can do this:
account.balance = -500;  // Invalid! But nothing stops it
account.balance = 999999999;  // Fraud? No protection

Public fields let any code change the object however it wants. There’s no validation, no logging, no control. The object can’t enforce its own rules.

Access Modifiers

Java provides four access levels to control visibility:

public: Accessible from anywhere.

private: Accessible only within the same class.

protected: Accessible within the same class, subclasses, and same package.

Default (no modifier): Accessible within the same package only.

For encapsulation, the key ones are public (for the interface) and private (for the implementation).

Private Fields, Public Methods

The encapsulation pattern: make fields private, provide public methods to access them.

public class BankAccount {
    private String accountNumber;
    private double balance;
    
    public BankAccount(String accountNumber, double initialBalance) {
        this.accountNumber = accountNumber;
        this.balance = initialBalance;
    }
    
    public double getBalance() {
        return balance;
    }
    
    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }
    
    public void withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
        }
    }
}

Now outside code can’t touch balance directly:

BankAccount account = new BankAccount("12345", 1000);

// account.balance = -500;  // Error! balance is private

account.deposit(200);     // Works - goes through the method
account.withdraw(50);     // Works - validated
System.out.println(account.getBalance());  // 1150

The object controls its own data. Deposits must be positive. Withdrawals can’t exceed the balance. The rules are enforced.

Getters and Setters

Methods that read private fields are called getters. Methods that modify them are called setters. By convention, they’re named get[Field] and set[Field].

public class Person {
    private String name;
    private int age;
    
    // Getter for name
    public String getName() {
        return name;
    }
    
    // Setter for name
    public void setName(String name) {
        this.name = name;
    }
    
    // Getter for age
    public int getAge() {
        return age;
    }
    
    // Setter for age with validation
    public void setAge(int age) {
        if (age >= 0 && age <= 150) {
            this.age = age;
        }
    }
}

Getters typically just return the value. Setters can include validation.

Person person = new Person();
person.setName("Alice");
person.setAge(25);
person.setAge(-5);  // Ignored - validation rejects it

System.out.println(person.getName());  // Alice
System.out.println(person.getAge());   // 25

Boolean Getters

For boolean fields, the getter convention uses “is” instead of “get”:

public class Task {
    private boolean completed;
    
    public boolean isCompleted() {  // Not getCompleted()
        return completed;
    }
    
    public void setCompleted(boolean completed) {
        this.completed = completed;
    }
}

Read-Only Fields

Provide a getter without a setter to make a field read-only from outside:

public class Employee {
    private String employeeId;
    private String name;
    
    public Employee(String employeeId, String name) {
        this.employeeId = employeeId;
        this.name = name;
    }
    
    // Getter only - no setter
    public String getEmployeeId() {
        return employeeId;
    }
    
    // Both getter and setter
    public String getName() {
        return name;
    }
    
    public void setName(String name) {
        this.name = name;
    }
}

The employee ID is set once in the constructor and can never be changed. The name can be updated.

Employee emp = new Employee("E001", "Alice");
System.out.println(emp.getEmployeeId());  // E001
emp.setName("Alice Smith");  // Works
// emp.setEmployeeId("E999");  // No such method - can't change ID

Computed Properties

Getters don’t have to return stored fields. They can compute values:

public class Rectangle {
    private double width;
    private double height;
    
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    
    public double getWidth() {
        return width;
    }
    
    public double getHeight() {
        return height;
    }
    
    // Computed - no "area" field exists
    public double getArea() {
        return width * height;
    }
    
    // Computed - no "perimeter" field exists
    public double getPerimeter() {
        return 2 * (width + height);
    }
}

From outside, getArea() looks like any other getter. The caller doesn’t know or care whether area is stored or calculated. This flexibility is a key benefit of encapsulation.

Protecting Mutable Objects

Be careful with getters that return mutable objects like arrays or ArrayLists:

public class Team {
    private ArrayList<String> members;
    
    public Team() {
        members = new ArrayList<>();
    }
    
    // Dangerous! Returns the actual list
    public ArrayList<String> getMembers() {
        return members;
    }
}

Team team = new Team();
ArrayList<String> list = team.getMembers();
list.add("Intruder");  // Modifies the team's internal list!

The getter returns a reference to the internal list. Outside code can modify it directly, bypassing any controls.

Solutions:

// Option 1: Return a copy
public ArrayList<String> getMembers() {
    return new ArrayList<>(members);
}

// Option 2: Return an unmodifiable view
public List<String> getMembers() {
    return Collections.unmodifiableList(members);
}

// Option 3: Don't expose the list at all
public void addMember(String name) {
    members.add(name);
}

public boolean hasMember(String name) {
    return members.contains(name);
}

public int getMemberCount() {
    return members.size();
}

Option 3 is often best. Provide specific methods for what callers actually need instead of exposing internal structures.

Why Encapsulation Matters

Validation. Setters can reject invalid values. An age can’t be negative. A price can’t be below zero.

Flexibility. You can change internal implementation without affecting code that uses the class. Store temperature in Celsius internally but provide getFahrenheit() and getCelsius() methods.

Control. Track changes, log access, enforce business rules. Every withdrawal could log a transaction.

Consistency. The object maintains its own valid state. You can’t have a Rectangle with negative dimensions if the setter prevents it.

Practical Example: User Account

import java.time.LocalDateTime;

public class UserAccount {
    private String username;
    private String email;
    private String passwordHash;
    private boolean active;
    private LocalDateTime lastLogin;
    private int failedLoginAttempts;
    
    public UserAccount(String username, String email, String password) {
        setUsername(username);
        setEmail(email);
        setPassword(password);
        this.active = true;
        this.failedLoginAttempts = 0;
    }
    
    // Username: read-only after creation
    public String getUsername() {
        return username;
    }
    
    private void setUsername(String username) {
        if (username == null || username.length() < 3) {
            throw new IllegalArgumentException("Username must be at least 3 characters");
        }
        this.username = username.toLowerCase();
    }
    
    // Email: can be changed, with validation
    public String getEmail() {
        return email;
    }
    
    public void setEmail(String email) {
        if (email == null || !email.contains("@")) {
            throw new IllegalArgumentException("Invalid email address");
        }
        this.email = email.toLowerCase();
    }
    
    // Password: write-only (no getter for security)
    public void setPassword(String password) {
        if (password == null || password.length() < 8) {
            throw new IllegalArgumentException("Password must be at least 8 characters");
        }
        this.passwordHash = hashPassword(password);
    }
    
    private String hashPassword(String password) {
        // In real code, use proper hashing like BCrypt
        return Integer.toHexString(password.hashCode());
    }
    
    public boolean checkPassword(String password) {
        return hashPassword(password).equals(this.passwordHash);
    }
    
    // Active status
    public boolean isActive() {
        return active;
    }
    
    public void deactivate() {
        this.active = false;
    }
    
    public void activate() {
        this.active = true;
        this.failedLoginAttempts = 0;
    }
    
    // Login tracking
    public LocalDateTime getLastLogin() {
        return lastLogin;
    }
    
    public void recordSuccessfulLogin() {
        this.lastLogin = LocalDateTime.now();
        this.failedLoginAttempts = 0;
    }
    
    public void recordFailedLogin() {
        this.failedLoginAttempts++;
        if (this.failedLoginAttempts >= 5) {
            deactivate();
        }
    }
    
    public int getFailedLoginAttempts() {
        return failedLoginAttempts;
    }
}

Using the class:

UserAccount user = new UserAccount("alice", "alice@email.com", "securepass123");

System.out.println("Username: " + user.getUsername());  // alice
System.out.println("Active: " + user.isActive());       // true

// Test password
System.out.println(user.checkPassword("wrongpass"));    // false
System.out.println(user.checkPassword("securepass123")); // true

// Simulate failed logins
for (int i = 0; i < 5; i++) {
    user.recordFailedLogin();
}
System.out.println("Active after 5 failures: " + user.isActive());  // false

Notice the design choices:

  • Username is read-only after creation
  • Password has no getter (write-only for security)
  • Account auto-deactivates after 5 failed logins
  • Email is validated and normalized to lowercase

Common Mistakes

Getters and setters for everything. Don’t automatically generate both for every field. Think about what should be readable, writable, or neither.

Setters with no validation. If a setter just assigns the value without checking anything, you’re not getting the benefit of encapsulation.

Returning mutable internal objects. Return copies or unmodifiable views, or don’t expose internals at all.

Making everything public “for convenience.” Convenience now becomes maintenance headaches later. Take time to design proper access.

What’s Next

You’ve now covered the core OOP concepts: classes, objects, constructors, inheritance, polymorphism, and encapsulation. The next phase covers intermediate topics starting with interfaces, which define contracts that classes must fulfill without specifying implementation.


Related: Java Polymorphism | Java Interfaces

Sources

  • Oracle. “Controlling Access to Members of a Class.” The Java Tutorials. docs.oracle.com
  • Bloch, Joshua. “Effective Java.” 3rd Edition. Addison-Wesley, 2018.
Scroll to Top