Guide contents

Lesson 6 of 31

Encapsulation and Invariants in Java

An invariant is a fact that must always be true of a value: a percentage is between 0 and 100, a booking's end is after its start, an account balance never goes below its limit. Encapsulation is the technique of guaranteeing invariants by controlling how values are created and changed. In Java the tools are private fields, constructors that throw on bad input, records, defensive copies, builders, and sealed types shaped so that bad combinations cannot be written. This article covers each and ends with a rule for choosing.

The payoff in a design interview is concrete: every check that moves from "every caller must remember" to "the constructor guarantees" is one less bug class and one less line in every method. See the interview framework for where this fits in a full design.

Checks in the examples use plain assert statements. The example checker runs with assertions enabled.

Private fields make the constructor the only door

A private field is visible only inside the top-level class that declares it. Other classes cannot read or write it, so every change goes through a method the class chose to offer. In the examples below, types that must be protected are top-level classes in the same file as Main, because private also lets a class and its nested classes see each other's private members.

Java
final class Account {
    private final String owner;
    private long balanceCents;

    Account(String owner) {
        this.owner = owner;
    }

    void deposit(long cents) {
        if (cents > 0) {
            balanceCents += cents;
        }
    }

    long balanceCents() {
        return balanceCents;
    }

    String owner() {
        return owner;
    }
}

public class Main {
    public static void main(String[] args) {
        var a = new Account("Asha");
        a.deposit(500);
        a.deposit(-200); // ignored: the invariant "deposits are positive" holds
        System.out.println(a.owner() + " has " + a.balanceCents());
    }
}
Output
Asha has 500

The compiler rejects code outside Account that tries to bypass the methods:

Java✗ does not compile
final class Account {
    private long balanceCents;
}

public class Main {
    public static void main(String[] args) {
        var a = new Account();
        a.balanceCents = 1_000_000;
    }
}
Output
error: balanceCents has private access in Account

The accessor balanceCents() returns a long, which is a copy, so the caller cannot change the balance through it. Methods are named for the business operation (deposit), not for the field (setBalance). A setter that accepts any value would remove the protection the private field gave. The next section covers the case where an accessor returns an object instead of a number.

Defensive copies: do not hand out internal state

An accessor that returns a mutable object (a List, an array) lets a caller change internal state, and any rule about that state with it. The fix is to return an immutable copy or an unmodifiable view, and to copy mutable arguments on the way in.

Java
import java.util.ArrayList;
import java.util.List;

final class LeakyTeam {
    private final List<String> members = new ArrayList<>();

    void add(String name) {
        members.add(name);
    }

    List<String> members() {
        return members; // leaks the internal list
    }
}

final class Team {
    private final List<String> members = new ArrayList<>();

    void add(String name) {
        members.add(name);
    }

    List<String> members() {
        return List.copyOf(members); // immutable snapshot
    }
}

public class Main {
    public static void main(String[] args) {
        var leaky = new LeakyTeam();
        leaky.add("Ravi");
        leaky.members().clear();
        System.out.println("leaky team size: " + leaky.members().size());

        var team = new Team();
        team.add("Ravi");
        try {
            team.members().clear();
        } catch (UnsupportedOperationException e) {
            System.out.println("team list is read-only");
        }
        System.out.println("team size: " + team.members().size());
    }
}
Output
leaky team size: 0
team list is read-only
team size: 1

List.copyOf costs one copy per call. Collections.unmodifiableList(members) is a view with no copy, and it reflects later changes made by the owner, so choose a copy when the caller keeps the result and a view when the caller reads it at once. Records holding a collection copy it in the compact constructor for the same reason.

Records that validate at construction

A record is a class for immutable data. Its compact constructor runs before the fields are assigned, so it is the place to check an invariant. Because the canonical constructor is the only way to create a record, every existing value is valid, and downstream code never re-checks.

Java
public class Main {
    record Percent(int value) {
        Percent {
            if (value < 0 || value > 100) {
                throw new IllegalArgumentException("percent must be 0 to 100, got " + value);
            }
        }

        long of(long cents) {
            return cents * value / 100;
        }
    }

    public static void main(String[] args) {
        var tax = new Percent(18);
        System.out.println("tax on 1000 = " + tax.of(1000));
        try {
            new Percent(150);
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}
Output
tax on 1000 = 180
percent must be 0 to 100, got 150

Percent.of needs no bounds check, because a Percent cannot hold 150. IllegalArgumentException is an unchecked exception. It fits here because an out-of-range argument is a bug in the caller or bad input that the boundary of the program should have parsed. When a failure is an expected business outcome that callers handle routinely, see errors in domain design.

Records give primitives a meaning

Two long values, an account ID and an amount of cents, are interchangeable to the compiler, so passing them in the wrong order compiles. A record with one component makes each its own type and turns that mistake into a compile error.

Java
public class Main {
    record AccountId(long value) {}

    record Cents(long value) {
        Cents plus(Cents other) {
            return new Cents(Math.addExact(value, other.value));
        }
    }

    static String transfer(AccountId from, AccountId to, Cents amount) {
        return "move " + amount.value() + " cents from #" + from.value() + " to #" + to.value();
    }

    public static void main(String[] args) {
        var a = new AccountId(1);
        var b = new AccountId(2);
        System.out.println(transfer(a, b, new Cents(250).plus(new Cents(50))));
        // transfer(a, new Cents(250), b);  // does not compile: incompatible types
    }
}
Output
move 300 cents from #1 to #2

The record decides which operations exist. Cents.plus is defined, a times between two Cents is not (it is meaningless), and AccountId has no arithmetic at all. Math.addExact throws ArithmeticException on overflow, where plain + on long wraps silently. Combine a one-component record with a validating compact constructor and the result is a validated value type, such as Email, Percent or Quantity, that parses once at the boundary and is trusted afterwards. This is the "parse, don't validate" idea.

Encapsulation for mutable state: guard every transition

A constructor protects creation, but methods that mutate must preserve the invariant too. The pattern is the same: check first, change state only when every check has passed, so a failed call leaves the state unchanged.

Java
public class Main {
    static final class InsufficientFundsException extends RuntimeException {
        private final long available;
        private final long requested;

        InsufficientFundsException(long available, long requested) {
            super("available " + available + ", requested " + requested);
            this.available = available;
            this.requested = requested;
        }

        long available() { return available; }
        long requested() { return requested; }
    }

    static final class Wallet {
        private long cents; // invariant: cents is the exact balance, never negative

        Wallet(long cents) {
            if (cents < 0) {
                throw new IllegalArgumentException("opening balance must not be negative");
            }
            this.cents = cents;
        }

        long withdraw(long amount) {
            if (amount <= 0) {
                throw new IllegalArgumentException("amount must be positive, got " + amount);
            }
            if (amount > cents) {
                throw new InsufficientFundsException(cents, amount);
            }
            cents -= amount;
            return cents;
        }
    }

    static String attempt(Wallet wallet, long amount) {
        try {
            return "Ok(" + wallet.withdraw(amount) + ")";
        } catch (InsufficientFundsException e) {
            return "InsufficientFunds(available=" + e.available() + ", requested=" + e.requested() + ")";
        } catch (IllegalArgumentException e) {
            return "Rejected(" + e.getMessage() + ")";
        }
    }

    public static void main(String[] args) {
        var w = new Wallet(1000);
        System.out.println(attempt(w, 300));
        System.out.println(attempt(w, 900));
        System.out.println(attempt(w, 0));
        System.out.println(attempt(w, 700));
    }
}
Output
Ok(700)
InsufficientFunds(available=700, requested=900)
Rejected(amount must be positive, got 0)
Ok(0)

Two details matter. A long can hold negative values, so "never negative" is enforced by the checks: the comparison amount > cents runs before the subtraction. And a failed call changes nothing, which is the property that lets a caller retry safely. InsufficientFundsException is a domain failure with data, so it is a named exception class. The zero amount is a caller bug, so it uses IllegalArgumentException.

Builders that validate

When a type has many fields, some optional and some related, a constructor with eight parameters is unreadable and easy to call with arguments in the wrong order. A builder collects fields one at a time and validates the whole set in build(). The product type is a record with a validating constructor, so the builder is a convenience and the invariants hold even for code that skips it.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    record Meeting(String title, int start, int end, List<String> attendees) { // start, end: minutes since midnight
        Meeting {
            if (title == null || title.isBlank()) {
                throw new IllegalStateException("MissingTitle");
            }
            if (end <= start) {
                throw new IllegalStateException("EndBeforeStart");
            }
            if (attendees.isEmpty()) {
                throw new IllegalStateException("NoAttendees");
            }
            attendees = List.copyOf(attendees);
        }
    }

    static final class MeetingBuilder {
        private String title;
        private int start;
        private int end;
        private final List<String> attendees = new ArrayList<>();

        MeetingBuilder title(String title) {
            this.title = title;
            return this;
        }

        MeetingBuilder between(int start, int end) {
            this.start = start;
            this.end = end;
            return this;
        }

        MeetingBuilder attendee(String name) {
            attendees.add(name);
            return this;
        }

        Meeting build() {
            return new Meeting(title, start, end, attendees);
        }
    }

    static String attempt(MeetingBuilder builder) {
        try {
            return builder.build().toString();
        } catch (IllegalStateException e) {
            return "Err " + e.getMessage();
        }
    }

    public static void main(String[] args) {
        System.out.println(attempt(new MeetingBuilder().title("Design review").between(600, 660).attendee("Ravi")));
        System.out.println(attempt(new MeetingBuilder().title("Standup").between(700, 650).attendee("Mei")));
        System.out.println(attempt(new MeetingBuilder().between(1, 2)));
    }
}
Output
Meeting[title=Design review, start=600, end=660, attendees=[Ravi]]
Err EndBeforeStart
Err MissingTitle

The builder methods return this, so calls chain. The checks run in a fixed order, which is why the third call reports the missing title first. Use a builder when there are more than about three optional inputs or cross-field rules. For two or three required fields, a plain constructor or record is clearer. IllegalStateException is used here because the builder is in a state in which it cannot produce a value yet.

Making invalid states unrepresentable

The strongest form of encapsulation is a type in which the bad state has no encoding at all. The usual culprit is a class of independent fields that are only valid in certain combinations.

Java
public class Main {
    // Invalid states are possible: connected with no session id,
    // or disconnected with a stale session id.
    static final class ConnectionLoose {
        boolean connected;
        Long sessionId;
    }

    // Each state carries exactly the data valid for it.
    sealed interface Connection {
        record Disconnected() implements Connection {}
        record Connecting(int attempt) implements Connection {}
        record Connected(long sessionId) implements Connection {}
    }

    static String describe(Connection connection) {
        return switch (connection) {
            case Connection.Disconnected d -> "offline";
            case Connection.Connecting c -> "connecting (attempt " + c.attempt() + ")";
            case Connection.Connected c -> "session " + c.sessionId();
        };
    }

    public static void main(String[] args) {
        var broken = new ConnectionLoose();
        broken.connected = true; // compiles, but means nothing: connected with no session
        System.out.println("loose: connected=" + broken.connected + " sessionId=" + broken.sessionId);
        for (Connection c : new Connection[] {
                new Connection.Disconnected(),
                new Connection.Connecting(2),
                new Connection.Connected(77)}) {
            System.out.println(describe(c));
        }
    }
}
Output
loose: connected=true sessionId=null
offline
connecting (attempt 2)
session 77

ConnectionLoose has four combinations of its flag and its optional ID, and only three are meaningful. The sealed interface has exactly the meaningful ones, and sessionId exists only where it makes sense. The switch has no default, so adding a state makes every switch that misses it fail to compile. Other cases follow the same shape: a sealed Shipping type with Pickup and Delivery(Address) records instead of a nullable Address with a boolean beside it, or a record Quantity(int value) that rejects zero. Transitions between states are covered in state machines with sealed types.

A worked example: booking with several invariants

The pieces combine. This Booking requires a non-empty guest name, a stay of at least one night, and a room count between 1 and 5. It uses a validated record for the room count, a record with a compact constructor that normalizes the guest name, and a domain exception, so no code can construct an invalid booking.

Java
public class Main {
    static final class BookingException extends RuntimeException {
        BookingException(String message) {
            super(message);
        }
    }

    record Rooms(int count) {
        Rooms {
            if (count < 1 || count > 5) {
                throw new BookingException("rooms must be 1 to 5, got " + count);
            }
        }
    }

    record Booking(String guest, int nights, Rooms rooms) {
        Booking {
            if (guest == null || guest.isBlank()) {
                throw new BookingException("guest name is required");
            }
            if (nights < 1) {
                throw new BookingException("stay must be at least one night");
            }
            guest = guest.strip();
        }

        int roomNights() {
            return nights * rooms.count();
        }
    }

    static void tryBooking(String guest, int nights, int rooms) {
        try {
            var booking = new Booking(guest, nights, new Rooms(rooms));
            System.out.println(booking.guest() + " booked " + booking.roomNights() + " room-nights");
        } catch (BookingException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }

    public static void main(String[] args) {
        tryBooking("  Lena ", 3, 2);
        tryBooking("", 3, 2);
        tryBooking("Lena", 3, 9);
    }
}
Output
Lena booked 6 room-nights
rejected: guest name is required
rejected: rooms must be 1 to 5, got 9

Choosing the technique

Situation Technique
A primitive with a range or format rule (percent, email, quantity) Record with a validating compact constructor
Two values of the same primitive type that must not be mixed (IDs, units) Plain one-component record
A class whose fields must obey a cross-field rule Private final fields plus a constructor or record that checks
Many optional inputs Builder whose build() calls a validating constructor
Data valid only in certain states Sealed interface with per-variant records
Mutation that could break a rule Method that checks first and changes state last
An accessor that returns a collection List.copyOf or an unmodifiable view
Plain data with no rules (a point, a color) A record with no checks

Encapsulation has a cost: more code, and less flexibility for callers. Skip it for pure data carriers, and reserve it for types that have a rule worth protecting. Also keep the surface small: every non-private method is a promise. Package-private (no modifier) is the default for helpers shared inside a package. The relationship to SOLID is direct: a type that guards its own invariants has one responsibility, and clients depend on its methods, not its representation.

Interview points

  • An invariant is a fact that must always hold. Encapsulation guarantees it by letting only the type's own code create and change values.
  • private applies per top-level class. Name methods for business operations instead of exposing setters.
  • A constructor or record compact constructor that throws gives validity by construction: every existing value is valid.
  • Return List.copyOf or an unmodifiable view, and copy mutable arguments, so callers cannot change internal state.
  • A mutating method checks first and changes state last, so a failed call leaves the state unchanged.
  • A sealed interface with a record per state removes combinations of fields that have no meaning.
  • A builder suits many optional inputs. It does not replace validation in the product type.

Summary

  • An invariant is a fact that must always hold. Encapsulation is how the type guarantees it.
  • Private final fields force all construction and mutation through the type's own methods.
  • A validating constructor makes every existing value valid, so callers never re-check.
  • One-component records are cheap to write and turn mixed-up arguments into compile errors.
  • Accessors return immutable copies or views of collections. Mutable arguments are copied on the way in.
  • Mutating methods leave the state unchanged when they throw.
  • Use a builder when there are many optional fields or cross-field rules, and validate everything in the constructor it calls.
  • Prefer sealed types whose records carry only the data valid for that state over classes of loosely related fields.
  • Do not encapsulate plain data that has no rules.