Encapsulation, Records, and Immutability in Java: Modern Object Design
Ask what encapsulation means and most people recite โmake fields private, add getters and setters.โ That answer is not just shallow โ followed literally, it produces the worst of both worlds: ceremony and leaky design. A class with a setter for every field is a public struct wearing a suit.
This guide covers what encapsulation is actually for, why immutability became the modern default, the equals/hashCode contract that corrupts collections when violated, and records โ the Java 16 feature that made the right design also the shortest one.
Encapsulation Is About Invariants, Not Getters
The point of hiding fields is that the class โ and only the class โ enforces its own rules. Compare:
// "Encapsulation" as ritual: private fields, public everything anywaypublic class Order { private List<Item> items; private OrderStatus status; public List<Item> getItems() { return items; } public void setItems(List<Item> items) { this.items = items; } public void setStatus(OrderStatus status) { this.status = status; }}// any code anywhere can do: order.setStatus(SHIPPED) on an empty order// Encapsulation as protection: operations, not accessorspublic class Order { private final List<Item> items = new ArrayList<>(); private OrderStatus status = OrderStatus.DRAFT;
public void addItem(Item item) { if (status != OrderStatus.DRAFT) throw new IllegalStateException("Cannot modify a " + status + " order"); items.add(item); }
public void ship() { if (items.isEmpty()) throw new IllegalStateException("Nothing to ship"); if (status != OrderStatus.CONFIRMED) throw new IllegalStateException("Order not confirmed"); status = OrderStatus.SHIPPED; }
public List<Item> items() { return List.copyOf(items); } // read-only snapshot}The second version makes invalid states unrepresentable from outside. No caller can ship an empty order, because there is no path in the API that gets there. Bugs move from โhow did this order end up shipped with no items?โ (found in production, cause unknown) to an exception at the exact line that tried it.
Notice the last method: it returns List.copyOf(items), not the list itself. Returning the internal list would hand every caller a remote control for your private state โ order.items().clear() would empty the order, bypassing every check. This is a defensive copy, and forgetting it is one of the most common real-world encapsulation leaks. The same applies to mutable inputs: copy what you store (this.tags = List.copyOf(tags)), or callers retain a live reference into your object.
Access modifiers, quickly
| Modifier | Visible to |
|---|---|
private | the class only โ your default for fields |
| (none, โpackage-privateโ) | same package โ handy for test access and internal collaborators |
protected | package + subclasses โ use sparingly; itโs inheritance API |
public | everyone โ every public member is a promise you must maintain |
The discipline: start everything as private, widen only under demonstrated need. Narrowing later breaks callers; widening never does.
Why Immutability Won
An immutable object is set once, at construction, forever. Javaโs own String, Integer, LocalDate, and BigDecimal all work this way, and the ecosystem has steadily converged on immutable-by-default for anything that represents data. The reasons are practical, not aesthetic:
- Thread-safety with zero effort. No mutation โ no race conditions โ share freely across threads with no locks. In concurrent code (that is: all modern server code), this single property eliminates whole categories of heisenbugs.
- Safe as map keys and set members. A mutable key whose
hashCodechanges after insertion silently vanishes from aHashMapโ the entry is still there, in the wrong bucket, unfindable. Immutable keys make this impossible. - Reasoning stays local. Pass an immutable object to five methods; you know it comes back unchanged. With mutable objects, every call site is a suspect when state goes wrong.
- Failure atomicity. No half-updated objects after an exception โ the object either exists validly or was never created.
The pattern by hand looks like this:
public final class Money { private final long paise; private final String currency;
public Money(long paise, String currency) { if (paise < 0) throw new IllegalArgumentException("negative amount"); this.paise = paise; this.currency = Objects.requireNonNull(currency); }
public Money plus(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch"); return new Money(paise + other.paise, currency); // NEW object, no mutation }
public long paise() { return paise; } public String currency() { return currency; } // ... plus equals, hashCode, toString โ ~30 more lines of boilerplate}Note the shape of plus: โmodificationโ returns a new instance, exactly like String.toUpperCase(). And note whatโs missing โ those last thirty boilerplate lines are precisely what records deleted.
The equals/hashCode Contract (Read This Before Your First HashMap Bug)
Two rules, violated constantly, with silent corruption as the penalty:
- If
a.equals(b), thena.hashCode() == b.hashCode()โ always. - Override both or neither. Never just one.
Why: HashMap and HashSet first locate a bucket by hashCode, then confirm with equals. Equal objects with different hash codes land in different buckets, so contains/get miss objects that are โequalโ by your definition:
class Point { int x, y; /* equals overridden, hashCode NOT */ }
Set<Point> visited = new HashSet<>();visited.add(new Point(2, 3));visited.contains(new Point(2, 3)); // false โ looked in the wrong bucketNo exception, no warning โ just a set that lies. Writing the pair correctly by hand (null checks, type checks, field-by-field comparison, hash combination) is tedious enough that IDEs generate it. Or you use the feature built for exactly this.
Records: The Right Design Became the Short One (Java 16)
A record is a transparent, immutable data carrier. One line replaces the entire Money boilerplate:
public record Money(long paise, String currency) { }That declaration generates, correctly and automatically:
private finalfields for each component- a canonical constructor
Money(long, String) - accessors
paise()andcurrency()(note: nogetprefix) - value-based
equalsandhashCodehonoring the contract above - a readable
toString:Money[paise=4999, currency=INR]
Records are implicitly final, and their fields cannot be reassigned. What they donโt remove is your ability to add behavior and validation:
public record Money(long paise, String currency) { public Money { // compact constructor: validation if (paise < 0) throw new IllegalArgumentException("negative amount"); Objects.requireNonNull(currency); }
public Money plus(Money other) { if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency mismatch"); return new Money(paise + other.paise, currency); }
public static Money inr(long paise) { return new Money(paise, "INR"); }}The compact constructor (public Money { โ no parameter list) runs before field assignment; it validates or normalizes (currency = currency.toUpperCase(); works โ youโre adjusting the parameter before itโs stored).
One trap: records are shallowly immutable
public record Team(String name, List<String> members) { }
var list = new ArrayList<String>();var team = new Team("Core", list);list.add("intruder"); // team.members() now contains "intruder"!The reference is final; the list it points to is as mutable as ever. Fix it in the compact constructor:
public record Team(String name, List<String> members) { public Team { members = List.copyOf(members); // defensive copy โ truly immutable }}List.copyOf is doubly nice: it rejects nulls and returns an unmodifiable list, and itโs cheap when the input is already an immutable copy.
Where records fit โ and donโt
Reach for a record when the thing is its data: API request/response DTOs, coordinates, money, date ranges, configuration, map keys, multi-value returns from a method, and the cases of a sealed hierarchy. Combined with pattern matching, they deconstruct cleanly:
static String describe(Object obj) { return switch (obj) { case Money(long p, String c) when p == 0 -> "zero " + c; case Money(long p, String c) -> p + " paise " + c; default -> "not money"; };}Donโt use a record when the object has identity and lifecycle โ an Order that transitions DRAFT โ CONFIRMED โ SHIPPED is defined by continuity, not by field values; two orders with identical fields are still different orders. Entities like that stay regular classes with controlled mutation, like the Order at the top of this guide. The modern Java codebase settles into a two-kingdom shape: records for values, classes for entities โ and far fewer of the in-between โJavaBeanโ mutable data bags that used to dominate.
Putting It Together: A Small Domain, Designed Modern
public record CustomerId(String value) { public CustomerId { if (!value.matches("C-\\d{6}")) throw new IllegalArgumentException(value); }}
public record LineItem(String sku, int qty, Money unitPrice) { public LineItem { if (qty <= 0) throw new IllegalArgumentException("qty"); } public Money total() { return new Money(unitPrice.paise() * qty, unitPrice.currency()); }}
public class ShoppingCart { // entity: identity + lifecycle private final CustomerId customer; private final List<LineItem> lines = new ArrayList<>();
public ShoppingCart(CustomerId customer) { this.customer = customer; }
public void add(LineItem line) { lines.add(line); }
public Money total() { return lines.stream().map(LineItem::total) .reduce(Money.inr(0), Money::plus); }
public List<LineItem> lines() { return List.copyOf(lines); }}Even CustomerId as a record instead of a raw String earns its keep: the compiler now prevents passing an order ID where a customer ID belongs, and the format rule lives in exactly one place. Tiny wrapper records like this (โmicro-typesโ) are cheap in Java now โ one line โ and they catch the argument-swapping bugs that stringly-typed code invites.
Frequently Asked Questions
Do records replace JavaBeans and Lombok? Largely, for new value types. The JavaBean convention (no-arg constructor + getters/setters) survives where frameworks require mutability โ some ORM entities, some serialization paths โ though modern versions of those frameworks increasingly accept records directly for DTOs and projections. Lombokโs @Value is essentially a pre-records record; codebases on Java 16+ typically migrate those. Lombokโs other annotations (@Builder, logging) remain a separate question.
Can a record implement interfaces or have static members? Yes to both โ records can implement any interface (thatโs the sealed-hierarchy pattern), define static factories and constants, and add instance methods. The only prohibitions: no extending classes (a recordโs superclass is fixed), no additional instance fields beyond the components, and no mutability.
Isnโt creating a new object on every change wasteful? Far less than intuition suggests: small short-lived objects are what the JVMโs generational GC is optimized for, and immutability saves elsewhere โ no defensive copies on every read, no locks for shared access. Real hot-path exceptions exist (tight numeric loops), and theyโre the place for primitives and arrays, not for abandoning immutable design generally.
How do I โupdateโ one field of a record? A wither method returning a modified copy: public Money withCurrency(String c) { return new Money(paise, c); }. For records with many components this gets verbose โ the pragmatic options are a builder, or restructuring so frequently-updated state lives in an entity that holds immutable values rather than in one giant record.
Should getters be named getX() or x()? Records chose bare x(), and new non-record value classes increasingly follow. getX() remains the JavaBean convention that some tooling expects. Within one codebase, consistency beats either theory โ but donโt hand-write getPaise() on a record; the accessor is already there.
A Refactoring Exercise
Take this all-too-realistic class and modernize it mentally (or actually):
public class UserDTO { public String name; public String email; public List<String> roles; public void setName(String n) { name = n; } // ... five more getters/setters}The steps you should find yourself making: (1) itโs a value carrier โ record; (2) record User(String name, String email, List<String> roles); (3) compact constructor: null-check name/email, roles = List.copyOf(roles); (4) hunt down every mutation site that did dto.setName(...) and convert it to constructing the correct value up front โ which usually simplifies the calling code, because half of setter-era mutation was compensating for constructing objects before their data was known. That last discovery โ immutability pressure improving the design around the class โ is the deeper lesson of this whole guide.
Self-Check
getItems()returns the internalArrayListdirectly. Describe an outside line of code that corrupts the order without touching any setter.- A class overrides
equalsbut nothashCode, and instances go into aHashSet. What misbehavior appears, and why is there no exception? - Why does
record Team(String, List<String>)need a compact constructor to be truly immutable? - Your
Invoicehas states DRAFT โ SENT โ PAID. Record or class? Why?
(Answers: order.getItems().clear(). Equal objects hash to different buckets, so contains/deduplication silently fail. The list reference is final but the referenced list is mutable โ copy it. Class โ it has identity and a lifecycle; equal fields donโt mean the same invoice.)
Next, the series shifts from designing types to using Javaโs built-in ones at scale: the Collections Framework โ List, Set, Map, and how to choose the right one without memorizing forty classes.