When we first learn Object-Oriented Programming, encapsulation is often explained like this:
Make fields
privateand access them using getters and setters.
Technically, this introduces data hiding, but it does not automatically give us good encapsulation.
There is an important difference.
class BankAccount {
private double balance;
public double getBalance() {
return balance;
}
public void setBalance(double balance) {
this.balance = balance;
}
}
At first glance, this looks perfectly encapsulated.
The field is private.
Nobody can directly write:
account.balance = 1000;
Instead, they have to call:
account.setBalance(1000);
But ask yourself one question:
What exactly did we protect?
Not much.
A caller can still do this:
account.setBalance(-50000);
or:
account.setBalance(999999999);
The field is private, but the object's internal state is still completely controlled by the outside world.
That is not strong encapsulation.
What Encapsulation Actually Means
Encapsulation is not just about restricting how a variable is accessed.
It is about restricting how an object's state can change.
A well-encapsulated object should:
- protect its internal state
- enforce business rules
- maintain valid state
- expose meaningful operations
- hide unnecessary implementation details
The object itself should decide which state transitions are allowed.
This leads to an important concept:
Invariants
An invariant is a condition that should always remain true for an object.
For example, suppose our banking system does not allow a balance to become negative.
Then:
balance >= 0
is an invariant.
If we expose a generic setter:
setBalance(double balance)
we are allowing outside code to break that invariant.
Instead of exposing the state directly, we should expose behavior.
A Better Design
Consider this version:
class BankAccount {
private double balance;
public BankAccount(double initialBalance) {
if (initialBalance < 0) {
throw new IllegalArgumentException(
"Initial balance cannot be negative"
);
}
this.balance = initialBalance;
}
public double getBalance() {
return balance;
}
public void deposit(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Deposit amount must be positive"
);
}
balance += amount;
}
public void withdraw(double amount) {
if (amount <= 0) {
throw new IllegalArgumentException(
"Withdrawal amount must be positive"
);
}
if (amount > balance) {
throw new IllegalStateException(
"Insufficient balance"
);
}
balance -= amount;
}
}
Notice something important.
There is no setBalance() method.
Instead, the object exposes operations that make sense in its domain:
deposit()
withdraw()
getBalance()
Now someone cannot arbitrarily change:
1000 -> 500000
by calling:
setBalance(500000);
They have to perform a legitimate operation.
account.deposit(500);
This is much closer to real encapsulation.
Data Hiding vs Encapsulation
These two concepts are related, but they are not identical.
Data Hiding
Data hiding restricts direct access to internal data.
For example:
private double balance;
Outside code cannot directly access the variable.
That's useful.
But encapsulation goes further.
Encapsulation
Encapsulation combines state and behavior while controlling the way the state can change.
Instead of asking:
"Can external code access this variable?"
we should ask:
"Can external code put this object into an invalid state?"
That question gives us a much better understanding of encapsulation.
Another Example: User Age
Consider:
class User {
private int age;
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
We technically have a private variable.
But this is possible:
user.setAge(-25);
Now our object contains an invalid state.
We could add validation:
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"Age cannot be negative"
);
}
this.age = age;
}
This is already better.
But sometimes even the existence of a setter should be questioned.
Imagine age is calculated from date of birth.
In that case, there should probably be no:
setAge()
at all.
Instead:
class User {
private LocalDate dateOfBirth;
public User(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}
public int getAge() {
return Period.between(
dateOfBirth,
LocalDate.now()
).getYears();
}
}
Now age cannot accidentally become inconsistent with dateOfBirth.
The object's design prevents the invalid state from existing.
That is powerful encapsulation.
Tell, Don't Ask
There is another useful object-oriented design principle related to this discussion:
Tell, Don't Ask.
Instead of extracting an object's data, making decisions outside the object, and then setting the state again, tell the object what you want it to do.
Consider:
if (account.getBalance() >= amount) {
account.setBalance(
account.getBalance() - amount
);
}
Here, the caller knows:
- how the balance is stored
- how withdrawal works
- what validation is required
- how the state should change
That logic belongs to the BankAccount.
A better approach is:
account.withdraw(amount);
Now the object owns the behavior.
The caller does not need to understand the object's internal rules.
Why Generic Setters Can Be Dangerous
Imagine a class with ten fields:
class Order {
private String status;
private double total;
private boolean paid;
private LocalDateTime shippedAt;
}
Now imagine automatically generating setters for everything:
setStatus()
setTotal()
setPaid()
setShippedAt()
External code could create combinations like:
status = SHIPPED
paid = false
shippedAt = null
The individual values may be legal.
But together they represent an invalid Order.
This is why encapsulation is not just about validating individual variables.
Sometimes we need to protect the relationship between multiple variables.
Instead, our API might expose:
order.markAsPaid();
order.ship();
Inside ship():
public void ship() {
if (!paid) {
throw new IllegalStateException(
"An unpaid order cannot be shipped"
);
}
this.status = "SHIPPED";
this.shippedAt = LocalDateTime.now();
}
Now the class protects its own rules.
The Order object becomes responsible for maintaining a valid order state.
Encapsulation Reduces Coupling
Good encapsulation has another major benefit:
implementation details can change without breaking callers.
Suppose today we store:
private double balance;
Later, because financial calculations should avoid floating-point precision issues, we replace it with:
private BigDecimal balance;
If the rest of our application directly depends on how balance works, the change could spread everywhere.
But if callers simply use:
account.deposit(amount);
account.withdraw(amount);
the internal implementation can evolve independently.
That is one of the biggest advantages of encapsulation:
Objects expose stable behavior while hiding implementation decisions.
Should We Never Use Setters?
No.
Setters themselves are not bad.
The problem is automatically generating setters for every field without thinking about the object's rules.
A setter can be perfectly valid:
public void setDisplayName(String displayName) {
if (displayName == null || displayName.isBlank()) {
throw new IllegalArgumentException();
}
this.displayName = displayName;
}
If changing a display name is a legitimate operation in the domain, this design is fine.
The important question is not:
"Should this field have a setter?"
The better question is:
"Should external code be allowed to change this value directly?"
Sometimes the answer is yes.
Sometimes the answer is no.
Encapsulation Is About Designing an API
One useful way to think about a class is:
Every public method becomes part of the API of that object.
For example:
setBalance()
essentially says:
You are allowed to replace the balance with any value.
While:
deposit()
withdraw()
say:
You can request legitimate banking operations, but the account controls how its state changes.
That difference may look small in code.
Architecturally, it is huge.
A Simple Rule I Use
Before adding a setter, ask:
"Am I exposing state, or am I exposing behavior?"
Instead of:
order.setStatus("SHIPPED");
consider:
order.ship();
Instead of:
account.setBalance(balance - 500);
consider:
account.withdraw(500);
Instead of:
user.setVerified(true);
consider:
user.verify();
The second approach usually produces objects that are easier to understand, harder to misuse, and safer to modify.
Final Thought
private fields and getters/setters are language mechanisms.
Encapsulation is a design principle.
You can have:
private fields
+ getters
+ setters
and still have a poorly encapsulated class.
Good encapsulation means that an object:
- owns its state
- protects its invariants
- exposes meaningful behavior
- prevents invalid transitions
- hides implementation details from callers
So next time your IDE offers:
Generate Getters and Setters
don't automatically click:
Select All.
First ask:
What should this object actually allow the outside world to do?
That is where encapsulation really begins.











