Why this matters
- These two are routinely given as each other's definitions, and a precise answer is a quick signal of real understanding.
- Weak encapsulation is the root cause of the bug where an object is valid when you create it and invalid an hour later.
- Choosing the right abstraction determines how much of your codebase has to change when a requirement does.
Encapsulation: the object guards its own state
The mechanism is simple — private fields, public methods — but the point is the invariant, the rule that must always hold.
public class BankAccount {
private final String id;
private BigDecimal balance;
public BankAccount(String id, BigDecimal opening) {
if (opening.signum() < 0) {
throw new IllegalArgumentException("Opening balance cannot be negative");
}
this.id = id;
this.balance = opening;
}
public void deposit(BigDecimal amount) {
requirePositive(amount);
balance = balance.add(amount);
}
public void withdraw(BigDecimal amount) {
requirePositive(amount);
if (balance.compareTo(amount) < 0) {
throw new InsufficientFundsException(id, amount);
}
balance = balance.subtract(amount);
}
public BigDecimal balance() {
return balance; // safe to return: BigDecimal is immutable
}
private static void requirePositive(BigDecimal amount) {
if (amount.signum() <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}
The invariant is "balance is never negative". Because balance is private and there is no setter,
that rule holds for the entire life of the object. No caller can break it, even by mistake.
The getter-and-setter habit
Generating a getter and setter for every field is the most common misunderstanding of encapsulation. The question to ask is what operation the caller actually wants.
| Aspect | Field-oriented API | Operation-oriented API |
|---|---|---|
| Shape | getStatus() / setStatus() | approve() / reject() |
| Where rules live | Scattered across every caller | Inside the class, once |
| Invalid states | Reachable by any caller | Unreachable |
| Reading the code | What does setting status to 2 mean? | Obvious from the name |
| Changing the rule | Find every call site | Edit one method |
Shape
Field-oriented APIgetStatus() / setStatus()Operation-oriented APIapprove() / reject()Where rules live
Field-oriented APIScattered across every callerOperation-oriented APIInside the class, onceInvalid states
Field-oriented APIReachable by any callerOperation-oriented APIUnreachableReading the code
Field-oriented APIWhat does setting status to 2 mean?Operation-oriented APIObvious from the nameChanging the rule
Field-oriented APIFind every call siteOperation-oriented APIEdit one method
Expose what callers want to do, not the fields that happen to implement it.
Defensive copying
Private is not enough when the field holds a mutable object. Returning it hands out a live reference to your internals.
public class Team {
private final List<String> members;
public Team(List<String> members) {
this.members = new ArrayList<>(members); // copy in
}
public List<String> members() {
return Collections.unmodifiableList(members); // read-only view out
}
public void add(String member) {
members.add(member); // the only way to change it
}
}
Both directions need protecting
- Copy on the way in. Storing the caller's list directly means they keep a reference and can modify your state afterwards.
- Protect on the way out. Returning the field directly lets callers call
clear()on it. Return an unmodifiable view or a copy. - Immutable types need neither.
String,Integer,LocalDate,BigDecimaland records of immutable components are safe to store and return as-is.
Abstraction: hiding complexity, not data
Abstraction is about the shape of the contract. A caller should be able to use something without knowing how it works, and without changing when the how changes.
// The contract: callers know only this
public interface MessageSender {
void send(String recipient, String body);
}
// One implementation; the complexity lives here
public class EmailSender implements MessageSender {
@Override
public void send(String recipient, String body) {
// connection pooling, retries, MIME encoding, bounce handling
}
}
// Another, with completely different internals
public class SmsSender implements MessageSender {
@Override
public void send(String recipient, String body) {
// gateway auth, segment splitting, delivery receipts
}
}
// The caller: unchanged forever
public class NotificationService {
private final MessageSender sender;
public NotificationService(MessageSender sender) {
this.sender = sender;
}
public void notifyUser(String recipient) {
sender.send(recipient, "Your order has shipped.");
}
}
NotificationService has no idea whether it is sending email, SMS or writing to a log in tests.
Adding a third channel does not touch it.
The two side by side
| Aspect | Encapsulation | Abstraction |
|---|---|---|
| Hides | Data and internal state | Implementation and complexity |
| Achieved with | private fields, public methods | interfaces, abstract classes |
| Protects against | Invalid state | Coupling to one implementation |
| Level | Inside a single class | Across a design |
| Question it answers | Who may change this? | What does a caller need to know? |
Hides
EncapsulationData and internal stateAbstractionImplementation and complexityAchieved with
Encapsulationprivate fields, public methodsAbstractioninterfaces, abstract classesProtects against
EncapsulationInvalid stateAbstractionCoupling to one implementationLevel
EncapsulationInside a single classAbstractionAcross a designQuestion it answers
EncapsulationWho may change this?AbstractionWhat does a caller need to know?
They usually appear together, which is why they are confused — but they solve different problems.
Package-private as a design tool
The access modifier with no keyword is under-used. It lets several classes cooperate closely while presenting a small public surface to the rest of the codebase.
package com.prepzone.pricing;
public class PriceCalculator { // the only public entry point
public Money priceFor(Order order) {
return new DiscountEngine().apply(order, new TaxTable().lookup(order));
}
}
class DiscountEngine { } // package-private: an implementation detail
class TaxTable { } // package-private
Other packages can only see PriceCalculator. The helpers can be renamed, merged or deleted
without anyone noticing, because nothing outside could ever reference them.
Common misreadings
- "Encapsulation is getters and setters." It is the protection of invariants. A setter for every field removes that protection entirely.
- "Abstraction means using interfaces everywhere." An interface with exactly one implementation and no plausible second one is overhead. Abstract when a change is genuinely likely.
- "Private fields make a class safe." Not if a getter returns a mutable collection or a getter and setter pair exposes it anyway.
- "Abstract classes are abstraction and interfaces are not." Both provide abstraction. They differ in what they can hold, not in what they are for.
Quick recall
Everything you need if you only revisit this box.
- Encapsulation = private data plus public operations, protecting an invariant that holds for the object's whole life.
- A public setter for a guarded field cancels the guarantee. Expose operations, not fields.
- Copy mutable state in, and return an unmodifiable view or copy out. Immutable types need neither.
unmodifiableListis a live view;List.copyOfis a snapshot.- Abstraction = a contract callers depend on instead of an implementation, so new implementations cost nothing.
- Encapsulation hides data with access modifiers; abstraction hides complexity with interfaces and abstract classes.
- Package-private keeps helper classes invisible outside their package, shrinking the public surface you must maintain.
Test yourself
Answer these before moving on — recall is what makes it stick.