☕ Java

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

Encapsulation is frequently reduced to "make fields private and add getters and setters." This reduction misses the deeper point almost entirely. True encapsulation is about protecting invariants and hiding implementation decisions. An invariant is a condition that must always be true about an object's state — an account balance cannot be negative, a date range's start cannot be after its end, a stack's size cannot exceed its capacity. Encapsulation exists to ensure these invariants can never be violated from outside the class. When a field is public, any code anywhere in the program can assign any value to it. There is no place to put the invariant check. When the field is private and modification goes through a method, the method is the single point of control — add the check once and it applies everywhere. This is not defensive programming, it is offensive design: the class asserts its own correctness and refuses to be placed in an invalid state. Hiding implementation decisions is the second dimension. A class that exposes its internal representation commits to that representation forever. If a class stores a phone number as a String and exposes it as a public field, changing to store it as three separate parts (country code, area code, number) is a breaking change for every caller. If the class kept the field private and exposed a getPhoneNumber() method, the internal representation is free to change as long as the method still returns the right value — callers see no change.
Java
// ── 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

The JavaBeans convention of generating a getter and setter for every field is deeply ingrained but fundamentally misguided as a default. A getter/setter pair on a private field provides exactly the same access as a public field with extra steps — the field might as well be public. The class gains nothing from the private declaration if every field is immediately exposed through unconditional setters. Getters and setters earn their place when they add value beyond simple delegation. A setter adds value when it validates, converts, normalises, or triggers side effects. A getter adds value when it returns a defensive copy, computes a derived value, or formats data. Setters that simply assign (this.x = x) and getters that simply return (return x) are often a sign that the class is a passive data holder rather than a real object with behaviour. The acid test for a setter is: can the caller set this field to any value and leave the object in a valid state? If yes, a simple setter is fine. If no, the setter must enforce the constraint. The acid test for a getter is: does exposing this value commit the class to keeping this representation forever? If yes, consider whether to expose it at all, or to expose a computed abstraction instead.
Java
// ── 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

The Tell Don't Ask principle is the behavioural dimension of encapsulation. It says: tell an object what to do rather than asking it for its data, making a decision, and then telling it to do something. When you ask an object for its internal data to make a decision, you are taking responsibility for logic that belongs inside the object. The object knows its own state — it should also know how to act on it. Violations of Tell Don't Ask always look the same: the caller reaches into an object, extracts data, makes a decision, and then calls back on the object. The decision should have been inside the object all along. Moving that decision inside the object is not just tidier — it is encapsulation in its true form. The invariants are maintained, the logic is co-located with the data it operates on, and callers are simpler. Tell Don't Ask does not mean objects can never expose their state. It means that logic operating on that state should be pushed toward the object that owns the state. Sometimes the caller genuinely owns the decision. Most of the time, though, if you find yourself extracting values from an object to make a decision, the decision probably belongs in that object.
Java
// ── 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

Immutability is encapsulation taken to its logical extreme. A mutable object with private fields and careful setters maintains its invariants against concurrent modification only if access is synchronised — two threads can still interleave their calls to getBalance() and withdraw() and produce incorrect results even though both methods are individually correct. An immutable object sidesteps this entire problem: because its state never changes after construction, it is inherently thread-safe. The relationship between encapsulation and immutability runs deep. Encapsulation protects invariants by controlling writes. Immutability eliminates writes entirely. Every invariant is established at construction and thereafter maintained trivially because there is nothing that can break it. This makes immutable objects dramatically simpler to reason about, to test, to cache, and to share across threads. Not every class can be immutable — some objects represent entities whose state genuinely changes over time. But many more classes are naturally immutable than developers realise: value objects (Money, Coordinate, EmailAddress, PhoneNumber), identifiers, DTOs, configuration objects, and domain events are all good candidates for immutability.
Java
// ── 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

Syntactic encapsulation means the language enforces the access boundaries — private fields cannot be accessed from outside the class. Semantic encapsulation means the design enforces correct usage — the API of the class makes it easy to use correctly and difficult to use incorrectly. A class can have perfect syntactic encapsulation (all fields private) but terrible semantic encapsulation (callers must invoke six methods in a specific order for the object to work correctly). Semantic encapsulation is about making the design communicate its constraints. A class with complex usage requirements should make those requirements impossible to violate — not just documented. If a method must be called before another, consider merging them or using a state machine. If a set of fields must always be updated together, provide a single method that updates all of them atomically. If an object can only be in a valid state when certain combinations of fields are set, use the builder pattern to enforce correct construction. The measure of good semantic encapsulation is how often callers can use a class incorrectly. If misuse is possible but documented, that is poor semantic encapsulation — documentation is not enforcement. If misuse is structurally impossible because the API does not allow it, that is good semantic encapsulation. Design the API so that the pit of success is deeper than the pit of failure.
Java
// ── 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 exist

Breaking Encapsulation — Common Violations

Encapsulation is broken in more ways than just making fields public. Returning mutable internal objects from getters is one of the subtlest violations — the caller receives a reference to an internal collection or array and can modify it directly, bypassing all invariant checks. This is known as an aliasing hazard. Returning mutable state that was passed in at construction is the same problem in reverse. Exposing internal structure through identifiers is another violation. A method that returns the internal ID of a related object gives callers the ability to obtain and manipulate that object directly, coupling the caller to the class's internal representation. A method that operates on the related object directly keeps the coupling inside the class. The instanceof cascade — checking what type an object is and casting to access type-specific behaviour — is a sign that the type hierarchy is not encapsulating its behaviour. The behaviour should be polymorphic, not detected and acted upon from outside.
Java
// ── 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
}

Related Topics in Object-Oriented Programming

OOP Concepts
Object-Oriented Programming (OOP) is a programming paradigm that organises software around objects — self-contained units that combine data (fields) and behaviour (methods). Java is a class-based, object-oriented language where almost everything is an object. OOP provides four foundational principles: encapsulation, inheritance, polymorphism, and abstraction. Together they produce software that is modular, reusable, maintainable, and easier to reason about as systems grow in complexity.
Classes
A class is the fundamental building block of Java. It is a blueprint that defines the structure and behaviour of objects — what data each object holds (fields) and what operations it can perform (methods). Every Java program is composed of classes. Understanding how to design a class well — choosing the right access modifiers, separating state from behaviour, and writing cohesive single-responsibility classes — is the foundation of object-oriented programming in Java. This entry covers class anatomy, fields, methods, access modifiers, static vs instance members, the this keyword, and class design principles.
Objects
An object is a runtime instance of a class. Where a class is a blueprint that exists in source code, an object is a living entity that exists in memory during program execution — it has its own identity, its own state stored in its fields, and the ability to respond to method calls. Every object has three fundamental properties: identity (a unique memory address), state (the current values of its fields), and behaviour (the methods it responds to). This entry covers object identity vs equality, the Object class hierarchy, object state and mutation, method calls, toString, equals and hashCode, and the object lifecycle.
Object Creation
Object creation in Java is the process of allocating memory, initialising fields, and running constructor logic to bring an object into existence. The new keyword is the primary mechanism, but Java also provides factory methods, builder patterns, copy constructors, and object cloning. Constructors are special methods that set up the initial state — their design determines how easy or difficult the class is to use correctly. This entry covers constructors in depth, constructor overloading and chaining, copy constructors, factory methods, the builder pattern, and the difference between shallow and deep copy.