Encapsulation
Encapsulation is the principle of bundling data and the methods that operate on that data into a single unit — the class — and controlling access to the internals through a well-defined interface. It is one of the four pillars of object-oriented programming and arguably the most foundational. By hiding internal state and requiring all interaction to go through defined methods, a class can enforce its own invariants, evolve its implementation freely, and present a stable contract to callers. This entry covers what encapsulation means in practice, why it matters, how to implement it properly, how it relates to immutability, getters and setters done right, the Tell Don't Ask principle, and the difference between syntactic and semantic encapsulation.
What Encapsulation Really Means
// ── Poor encapsulation — no invariant protection ─────────────────────
public class BadDateRange {
public LocalDate start; // public — anyone can set any value
public LocalDate end;
// No way to enforce start <= end
// Caller can write:
// range.start = LocalDate.of(2024, 12, 31);
// range.end = LocalDate.of(2024, 1, 1); // start AFTER end — invalid!
}
// ── Good encapsulation — invariant enforced ────────────────────────────
public final class DateRange {
private final LocalDate start;
private final LocalDate end;
public DateRange(LocalDate start, LocalDate end) {
Objects.requireNonNull(start, "start");
Objects.requireNonNull(end, "end");
if (start.isAfter(end)) {
throw new IllegalArgumentException(
"Start " + start + " must not be after end " + end);
}
this.start = start;
this.end = end;
}
public LocalDate getStart() { return start; }
public LocalDate getEnd() { return end; }
// ── Behaviour belongs here — not in the caller ────────────────────
public boolean contains(LocalDate date) {
return !date.isBefore(start) && !date.isAfter(end);
}
public long lengthInDays() {
return ChronoUnit.DAYS.between(start, end);
}
public boolean overlaps(DateRange other) {
return !this.end.isBefore(other.start)
&& !other.end.isBefore(this.start);
}
}
// ── The invariant "start <= end" can NEVER be violated ────────────────
// Every DateRange that exists is guaranteed valid.
// No defensive null checks or range validation in caller code.
// Any DateRange can be passed around and trusted.Getters and Setters Done Right
// ── Anemic setter — adds no value over public field ──────────────────
public class BadUser {
private String name;
private String email;
private int age;
// These setters add NOTHING — field might as well be public
public void setName(String name) { this.name = name; }
public void setEmail(String email) { this.email = email; }
public void setAge(int age) { this.age = age; }
}
// ── Meaningful setters — validate and enforce invariants ──────────────
public class User {
private String name;
private String email;
private int age;
private final String id;
public User(String id, String name, String email, int age) {
this.id = Objects.requireNonNull(id, "id");
setName(name); // reuse validation — not this.name = name
setEmail(email);
setAge(age);
}
public void setName(String name) {
if (name == null || name.isBlank()) {
throw new IllegalArgumentException("Name cannot be blank");
}
if (name.length() > 100) {
throw new IllegalArgumentException(
"Name cannot exceed 100 characters");
}
this.name = name.strip(); // normalise whitespace
}
public void setEmail(String email) {
if (email == null || !email.matches(
"^[\w.-]+@[\w.-]+\.[a-zA-Z]{2,}$")) {
throw new IllegalArgumentException(
"Invalid email: " + email);
}
this.email = email.toLowerCase(); // normalise to lowercase
}
public void setAge(int age) {
if (age < 0 || age > 150) {
throw new IllegalArgumentException(
"Age must be 0-150, got: " + age);
}
this.age = age;
}
// ── id has NO setter — it is immutable after construction ─────────
public String getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public int getAge() { return age; }
}
// ── Getter that returns a defensive copy ──────────────────────────────
public class Team {
private final String name;
private final List<String> memberIds;
public Team(String name, List<String> memberIds) {
this.name = name;
this.memberIds = new ArrayList<>(memberIds); // copy in
}
// Returns a copy — caller cannot modify the internal list
public List<String> getMemberIds() {
return Collections.unmodifiableList(memberIds);
}
// Returns size — does not expose the list at all
public int getMemberCount() { return memberIds.size(); }
}Tell Don't Ask — Behaviour Belongs in the Object
// ── Ask style — caller does the work (encapsulation violation) ──────
// Caller asks, decides, acts — logic scattered outside the object
OrderStatus status = order.getStatus(); // ask
List<Item> items = order.getItems(); // ask
double total = order.getTotal(); // ask
if (status == OrderStatus.PENDING && items.size() > 0 && total > 0) {
if (inventory.hasStock(items)) {
order.setStatus(OrderStatus.CONFIRMED); // tell (too late)
paymentService.charge(order.getCustomerId(), total);
}
}
// ── Tell style — ask the object to do what it knows how to do ─────────
// Object owns the decision logic — caller gives high-level instruction
order.confirm(inventory, paymentService); // one meaningful instruction
// ── The 'confirm' method — logic lives inside the Order ───────────────
public class Order {
private OrderStatus status;
private List<OrderItem> items;
private BigDecimal total;
private String customerId;
public void confirm(InventoryService inventory,
PaymentService payment) {
// Invariant check — Order knows its own rules
if (status != OrderStatus.PENDING) {
throw new IllegalStateException(
"Only PENDING orders can be confirmed");
}
if (items.isEmpty()) {
throw new IllegalStateException(
"Cannot confirm an empty order");
}
// Reserve inventory
inventory.reserve(items);
// Charge payment
payment.charge(customerId, total);
// Update own state
status = OrderStatus.CONFIRMED;
}
public void ship(String trackingNumber) {
if (status != OrderStatus.CONFIRMED) {
throw new IllegalStateException(
"Only CONFIRMED orders can be shipped");
}
this.trackingNumber = trackingNumber;
this.status = OrderStatus.SHIPPED;
this.shippedAt = Instant.now();
}
public void cancel(String reason) {
if (status == OrderStatus.SHIPPED ||
status == OrderStatus.DELIVERED) {
throw new IllegalStateException(
"Cannot cancel a " + status + " order");
}
this.status = OrderStatus.CANCELLED;
this.cancellationReason = reason;
}
// ── State is exposed for display, not for decision-making ─────────
public OrderStatus getStatus() { return status; }
public BigDecimal getTotal() { return total; }
public boolean isActive() {
return status == OrderStatus.PENDING
|| status == OrderStatus.CONFIRMED;
}
}Encapsulation and Immutability
// ── Mutable with encapsulation — thread-unsafe without synchronisation ─
public class MutableCounter {
private int count = 0;
// Without synchronisation, two threads calling increment()
// simultaneously can both read the same value, both add 1,
// and both write the same result — losing one increment
public void increment() { count++; } // read-modify-write — NOT atomic
public int getCount() { return count; }
}
// ── Immutable — no synchronisation needed ─────────────────────────────
public final class ImmutableCounter {
private final int count;
private ImmutableCounter(int count) {
if (count < 0) throw new IllegalArgumentException(
"Count cannot be negative");
this.count = count;
}
public static ImmutableCounter of(int count) {
return new ImmutableCounter(count);
}
public static ImmutableCounter zero() {
return new ImmutableCounter(0);
}
// Returns a NEW counter — this one is unchanged
public ImmutableCounter increment() {
return new ImmutableCounter(count + 1);
}
public ImmutableCounter incrementBy(int amount) {
return new ImmutableCounter(count + amount);
}
public ImmutableCounter reset() {
return new ImmutableCounter(0);
}
public int getCount() { return count; }
@Override public String toString() { return "Counter(" + count + ")"; }
@Override public boolean equals(Object o) {
return o instanceof ImmutableCounter c && this.count == c.count;
}
@Override public int hashCode() { return Integer.hashCode(count); }
}
// ── Usage — safe to share between threads without locks ───────────────
ImmutableCounter c = ImmutableCounter.zero();
ImmutableCounter c1 = c.increment(); // c unchanged
ImmutableCounter c2 = c1.increment(); // c1 unchanged
ImmutableCounter c3 = c2.incrementBy(5);
System.out.println(c); // Counter(0)
System.out.println(c1); // Counter(1)
System.out.println(c3); // Counter(7)
// ── Value objects are natural candidates for immutability ─────────────
public final class EmailAddress {
private final String value;
public EmailAddress(String value) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException("Email cannot be blank");
}
if (!value.contains("@")) {
throw new IllegalArgumentException(
"Invalid email address: " + value);
}
this.value = value.toLowerCase().strip();
}
public String getValue() { return value; }
public String getDomain() { return value.split("@")[1]; }
public String getLocalPart() { return value.split("@")[0]; }
@Override public String toString() { return value; }
@Override public boolean equals(Object o) {
return o instanceof EmailAddress e && value.equals(e.value);
}
@Override public int hashCode() { return value.hashCode(); }
}Syntactic vs Semantic Encapsulation
// ── Poor semantic encapsulation — caller must know invisible rules ────
public class ConnectionPool {
private boolean initialised = false;
private List<Connection> pool;
// Caller must call init() BEFORE any other method
// Nothing enforces this — it's just documented
public void init(int size) {
pool = new ArrayList<>(size);
for (int i = 0; i < size; i++) {
pool.add(createConnection());
}
initialised = true;
}
// If caller forgets to call init(), this throws NullPointerException
public Connection acquire() {
if (!initialised) throw new IllegalStateException(
"Must call init() first"); // too late — already broken
return pool.remove(0);
}
}
// ── Good semantic encapsulation — correct usage is the only usage ──────
public class ConnectionPool {
private final List<Connection> available;
private final List<Connection> inUse;
private final int capacity;
// Constructor IS the initialisation — object is valid the instant
// it is constructed, no follow-up calls required
public ConnectionPool(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException(
"Capacity must be positive");
this.capacity = capacity;
this.available = new ArrayList<>(capacity);
this.inUse = new ArrayList<>(capacity);
for (int i = 0; i < capacity; i++) {
available.add(createConnection());
}
}
// acquire() is always safe to call — pool is always initialised
public synchronized Connection acquire() {
if (available.isEmpty()) {
throw new PoolExhaustedException(
"All " + capacity + " connections in use");
}
Connection conn = available.remove(available.size() - 1);
inUse.add(conn);
return conn;
}
public synchronized void release(Connection conn) {
if (!inUse.remove(conn)) {
throw new IllegalArgumentException(
"Connection was not acquired from this pool");
}
available.add(conn);
}
public int available() { return available.size(); }
public int inUse() { return inUse.size(); }
}
// ── The pit of success in action ─────────────────────────────────────
// Wrong usage is structurally impossible:
ConnectionPool pool = new ConnectionPool(10);
Connection c = pool.acquire(); // always safe — no init() to forget
// pool.init(10); // method doesn't even exist
// new ConnectionPool(); // no-arg constructor doesn't existBreaking Encapsulation — Common Violations
// ── Violation 1: Returning mutable internal state ────────────────────
public class BadShoppingCart {
private final List<CartItem> items = new ArrayList<>();
// Returns the actual internal list — caller can bypass add/remove
public List<CartItem> getItems() {
return items; // HAZARD
}
}
// Outside:
cart.getItems().add(new CartItem(fraudItem, -9999)); // bypasses validation!
cart.getItems().clear(); // bypasses audit logging!
// ── Fix: return an unmodifiable view or a copy ────────────────────────
public class ShoppingCart {
private final List<CartItem> items = new ArrayList<>();
public List<CartItem> getItems() {
return Collections.unmodifiableList(items); // safe
}
// Or expose only what callers need:
public int getItemCount() { return items.size(); }
public BigDecimal getTotal() { return computeTotal(); }
public boolean isEmpty() { return items.isEmpty(); }
public boolean contains(String productId) {
return items.stream().anyMatch(
i -> i.getProductId().equals(productId));
}
}
// ── Violation 2: Leaking mutable parameter passed to constructor ───────
public class BadPriceList {
private final List<BigDecimal> prices;
public BadPriceList(List<BigDecimal> prices) {
this.prices = prices; // stores the CALLER'S list — aliasing!
}
}
List<BigDecimal> myList = new ArrayList<>(List.of(
new BigDecimal("9.99"), new BigDecimal("19.99")));
BadPriceList pricelist = new BadPriceList(myList);
myList.add(new BigDecimal("-5.00")); // modifies pricelist's internals!
// ── Fix: defensive copy ───────────────────────────────────────────────
public class PriceList {
private final List<BigDecimal> prices;
public PriceList(List<BigDecimal> prices) {
this.prices = List.copyOf(prices); // independent copy
}
}
// ── Violation 3: instanceof cascade — type-checking from outside ───────
// Ask-style: extract type, decide, act
void processBad(Shape shape) {
if (shape instanceof Circle c) {
double r = c.getRadius(); // extract data
double area = Math.PI * r * r; // do work
System.out.println("Circle area: " + area);
} else if (shape instanceof Rectangle r) {
double area = r.getWidth() * r.getHeight();
System.out.println("Rectangle area: " + area);
}
}
// ── Fix: polymorphism — behaviour inside the object ───────────────────
void processGood(Shape shape) {
System.out.println(shape.getClass().getSimpleName()
+ " area: " + shape.area()); // tell, don't ask
}