Tuesday, August 19, 2025

Encapsulation in Java: Writing secure and maintainable code

# Encapsulation in Java: Writing secure and maintainable code

Encapsulation is one of the pillars of Object-Oriented Programming. It’s the foundation that makes your code secure, maintainable, and robust. But what exactly is encapsulation, and why should you care about it as a Software Developer?

What is Encapsulation?

Encapsulation is the practice of bundling data (attributes) and the methods (behavior) that operate on that data into a single unit (in this case, an object), while restricting direct access to the internal components. It is like a protective shield around your data that controls how it can be accessed and modified.

The key is: Hide the internal state and require all interactions to happen through well-defined interface.

Without encapsulation, your program becomes vulnerable to several issues:

  • Data corruption: Any part of your code could modify critical data inappropriately, and with this I don’t only mean hackers but even our fellow team members because the poor designed interface of the component
  • Inconsistent state: Objects might end up in invalid states that could cause horrendous errors
  • Debugging nightmares: When data can be modified anywhere, tracking down bugs becomes unbearable

A Banking Example

Let’s use a very simple use case, imagine we have to write software to handle bank accounts (yes! I know, yet another banking example 😂). Requirements are:

  • Bank Accounts must have an owner and a balance
  • Bank Accounts must maintain a history of all transactions performed on them
  • Users can deposit funds into Bank Accounts
  • Users can withdraw funds from Bank Accounts

Let’s see encapsulation at work with this simple, practical example:

Poor Encapsulation (Don’t do this, please!)

public class BadBankAccount {
    public String owner;
    public double balance;
    public List<String> transactions;

    public BadBankAccount(String owner, double initialBalance) {
        this.owner = owner;
        this.balance = initialBalance;
        this.transactions = new ArrayList<>();
    }
}

// Let's create an account, no issues here
BadBankAccount account = new BadBankAccount("John Doe", 1000.0);
// Oops! Negative balance allowed
account.balance = -500.0;
// Transaction history is lost!
account.transactions.clear();
// Invalid state
account.owner = null;

As you can see, this approach is problematic, because:

  • All internals are publicly accessible, which is not good
  • No control over what values can be set
  • No protection or access control to internal data, important information can be deleted!
  • No validation or business logic is enforced
  • No clear indication of how to use this component

With Encapsulation (not perfect, but better)

public class BankAccount {
    // Private fields - hidden from outside access
    private String owner;
    private double balance;
    private List<String> transactions;
    private static final double MINIMUM_BALANCE = 0.0;

    // Constructor with validation (This can be better implemented as a factory method)
    public BankAccount(String owner, double initialBalance) {
        if (owner == null || owner.trim().isEmpty()) {
            throw new IllegalArgumentException("Owner name cannot be null or empty");
        }
        if (initialBalance < MINIMUM_BALANCE) {
            throw new IllegalArgumentException("Initial balance cannot be negative");
        }

        this.owner = owner;
        this.balance = initialBalance;
        this.transactions = new ArrayList<>();
        addTransaction("Account opened with balance: $" + initialBalance);
    }

    // Controlled access to balance (read-only)
    public double getBalance() {
        return balance;
    }

    // Controlled access to owner (read-only)
    public String getOwner() {
        return owner;
    }

    // Safe way to view transactions (returns unmodifiable copy)
    public List<String> getTransactionHistory() {
        return Collections.unmodifiableList(transactions); 
    }

    // Business logic for deposits
    public boolean deposit(double amount) {
        if (amount <= 0) {
            System.out.println("Deposit amount must be positive");
            return false;
        }

        balance += amount;
        addTransaction("Deposited: $" + amount + " | New balance: $" + balance);
        return true;
    }

    // Business logic for withdrawals
    public boolean withdraw(double amount) {
        if (amount <= 0) {
            System.out.println("Withdrawal amount must be positive");
            return false;
        }

        if (balance - amount < MINIMUM_BALANCE) {
            System.out.println("Insufficient funds. Current balance: $" + balance);
            return false;
        }

        balance -= amount;
        addTransaction("Withdrew: $" + amount + " | New balance: $" + balance);
        return true;
    }

    // Private helper method - internal implementation detail
    private void addTransaction(String transaction) {
        String timestamp = java.time.LocalDateTime.now().toString();
        transactions.add(timestamp + " - " + transaction);
    }

    // Utility method for account summary
    public void printAccountSummary() {
        String str = String.format(
            "{ owner: %s, balance: %.2f, totalTransactions: %d }",
            owner, balance, transactions.size()
        );
        System.out.println(str);
    }
}

Using the Encapsulated Class

public class BankingDemo {
    public static void main(String[] args) {
        // Create account safely
        BankAccount account = new BankAccount("Alice Johnson", 1000.0);

        // All interactions go through controlled methods
        account.deposit(250.0);    
        account.withdraw(100.0);   
        account.withdraw(2000.0);  

        // Data access is safe and controlled
        System.out.println("Current balance: $" + account.getBalance());
        System.out.println("Account owner: " + account.getOwner());

        // Transaction history is safely accessible
        List<String> history = account.getTransactionHistory();
        System.out.println("\nTransaction History:");
        for (String transaction : history) {
            System.out.println(transaction);
        }
        // This will throw an exception, you cannot modify the list
        // history.clear();

        account.printAccountSummary();
    }
}

Achieving Encapsulation with Java

1. Access Modifiers

The first step to achieve encapsulation is to use correctly the access modifiers.

  • private: Only accessible within the same class
  • protected: Accessible within the same package and subclasses
  • public: Accessible from anywhere
  • Package-private (no modifier): Accessible within the same package

2. Getter Methods

Provide controlled read access to private data:

public String getName() {
    return name;
}

// For collections, return copies (and unmodifiable) to prevent external modification
public List<String> getItems() {
    return new ArrayList<>(items);
    // or
    return Collections.unmodifiableList(items); 
}

3. Setter Methods with Validation

Provide controlled write access with business logic:

public void setAge(int age) {
    if (age < 0 || age > 150) { // business rule
        throw new IllegalArgumentException("Age must be between 0 and 150");
    }
    this.age = age;
}

4. Immutable Objects

For some cases, make objects unchangeable after creation:

public final class ImmutablePerson {
    private final String name;
    private final int age;

    public ImmutablePerson(String name, int age) {
        this.name = name;
        this.age = age;
    }

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

    // No setters - object cannot be changed after creation
}

Best Practices for Encapsulation

  1. Always start by declaring everything private: Classes, fields, methods, always start by making them private, then as needed, make them protected or even public.
  2. Provide public methods only when needed: Don’t create getters/setters automatically
  3. Validate inputs: Always check parameters in public methods
  4. Return copies of mutable internal data or make it immutable: Prevent external modification of internal data
  5. Use meaningful method names: Methods should describe business operations, not just data access
  6. Keep internal logic private: Helper methods should be private, they only have meaning inside the class.

Conclusion

If you need a frontline defender against bugs, data corruption, and maintenance headaches, encapsulation should be part of your go-to strategy. The investment in designing good encapsulation pays dividends throughout the lifetime of your application.

There is a quote I read somewhere and after some research I found who said it:

“Make it easy to do right, and hard to go wrong” - Gretchen Rubin

Design your components with that quote in mind: Design your components to do the “right thing” easy and to do the “wrong thing” hard. Encapsulation is your friend here.

No comments: