Mastering Object Oriented Architecture
Nuances of Encapsulation
Protecting Your Objects
You already know how to use private to hide data and public to expose it, often through simple getters and setters. But true encapsulation is about more than just hiding variables. It’s about protecting an object's internal consistency and ensuring it’s always in a valid state.
At the heart of this is the concept of a class invariant. This is a rule or condition about an object’s internal state that must hold true for the entire lifetime of the object, from the moment it’s created until it’s destroyed. If this rule is ever broken, the object is considered corrupt or invalid.
For example, in a
DateRangeobject, an essential invariant is that thestartDatemust always be before or on the same day as theendDate. It should be impossible to create aDateRangewhere the end comes before the start.
Simple public setters can easily violate this. If a client can call setStartDate() and setEndDate() independently, what stops them from setting an end date of January 1 and a start date of December 31? Nothing. This breaks the object's integrity. To properly protect our invariants, we need to think less about exposing data and more about exposing behavior.
Tell, Don't Ask
The "Tell, Don't Ask" principle is a guideline that helps create more robust and maintainable code. It suggests that instead of asking an object for its data and then acting on that data, you should tell the object to perform an action.
The object itself should contain all the logic necessary to carry out that action, including any checks needed to maintain its invariants. This keeps the decision-making logic bundled with the data it operates on.
Encapsulation is the principle of bundling data and methods together within a class and controlling access to them.
Let's consider a bank account. The "Ask" approach involves a lot of back-and-forth:
// The "Ask" approach (less ideal)
// Logic is spread out in the client code
if (account.getBalance() >= amountToWithdraw) {
double newBalance = account.getBalance() - amountToWithdraw;
account.setBalance(newBalance);
// ... proceed with dispensing cash
} else {
// ... show an error
}
Notice how the business logic for a withdrawal lives outside the Account class. The client code needs to know the rules. If the rule changes (e.g., adding an overdraft fee), you have to find and update every place that performs a withdrawal.
Now, let's look at the "Tell" approach:
// The "Tell" approach (preferred)
// The Account object handles the logic
try {
account.withdraw(amountToWithdraw);
// ... proceed with dispensing cash
} catch (InsufficientFundsException e) {
// ... show an error
}
// Inside the Account class:
public void withdraw(double amount) throws InsufficientFundsException {
if (this.balance >= amount) {
this.balance -= amount;
} else {
throw new InsufficientFundsException();
}
}
Here, we simply tell the account to withdraw money. The Account object is responsible for protecting its own state and enforcing its own rules. The client doesn't need to know anything about the internal logic, only that it can tell the account to perform a withdrawal.
Exposing Behavior, Not Data
This leads to a crucial design trade-off. When you expose data (with getters), you create a dependency on that data's specific representation. This is called tight coupling.
Imagine our Account class initially stored the balance as a double. Every client that calls getBalance() now depends on the balance being a double. What if you later decide to change the internal representation to a specialized Money object to handle different currencies and avoid floating-point errors? Every single piece of client code that used getBalance() would break.
When you expose behavior (with methods like withdraw() or deposit()), you create a stable public interface. You are free to change the internal implementation details as much as you want. As long as the method signatures don't change, the client code remains unaffected. This is loose coupling.
Good encapsulation allows you to reason about a class in isolation. By hiding internal details, you ensure that changes inside one class don't cause unexpected failures in another.
The goal isn't to never write a getter. Sometimes, client code genuinely needs to read a value without changing it. But always pause before adding one. Ask yourself: does the client need this data to make a decision? If so, could that decision-making logic be moved inside your class as a new behavior?
Thinking in terms of behaviors and invariants leads to systems that are more secure, easier to understand, and far simpler to change over time.
What is a class invariant?
Which of the following code snippets best demonstrates the "Tell, Don't Ask" principle for handling a bank account withdrawal?