Walls with doors, not windows into the wiring
Encapsulation in object design
Encapsulation means the object controls how its state changes — callers use meaningful methods, not raw field writes. Example: a payment webhook does ticket.status = CLOSED because the field was public, or calls setEntryTime(null) because a setter existed. The ticket’s rules had a hole, and anyone could walk through it.
The wall
Fields are private. The can change. Callers never mention ArrayList or the status enum’s ordinal.
The doors
and . pay, occupy, id, status. Each door keeps the .
The hole
Public fields. Setters that skip the story. Getters that return the mutable spots list or a mutable Date.
What is the object allowed to look like?
is not “make fields private and add getters.” It is hiding the representation and exposing operations that keep the rules true. After pay(), the ticket is PAID and has a fee. After occupy(), the spot is taken by one vehicle. Those sentences are the design.
Private fields are the start. The API is commands and queries. Queries like id() and status() are fine. Setters that skip the story are not. If you need to change a value object, replace it. If you need to change status, go through the transition method. Do not leak the insides. A getter that returns the mutable spots list, or the internal Date, lets callers mutate without the owner. Return copies, unmodifiable views, or immutable values.
Invariants belong in constructors and commands. A Money with negative paise should not exist. A ticket without an id should not exist. Fail at the door. Do not wait for a service to notice.
Commands keep the rules. Fields stay private.
class ParkingSpot {
private SpotStatus status = SpotStatus.FREE;
private Vehicle occupant;
boolean tryOccupy(Vehicle vehicle) {
if (status != SpotStatus.FREE) return false;
occupant = vehicle;
status = SpotStatus.OCCUPIED;
return true;
}
void release() {
occupant = null;
status = SpotStatus.FREE;
}
boolean isFree() { return status == SpotStatus.FREE; }
SpotId id() { return id; }
// no setStatus, no public occupant field
}Public fields or leaky setters
status = CLOSED from a webhook is how illegal transitions happen. If a setter exists, someone will use it. That is the same failure as an anemic model, described from the wall instead of from the service.
Getters that return mutable guts
getSpots() returning the live list, or getEntryTime() returning a mutable Date, is encapsulation with a hole in it. See collections ownership.
Constructors are doors too
A half-built Ticket that gets its id set later is a Ticket that existed in an illegal state. Require the id, the spot, the vehicle, and the entry time at construction — or use a factory the owner calls. Optional fields that are truly optional can be empty values, not nulls you hope nobody reads. Fail at the door.
class Ticket {
private final TicketId id;
private final SpotId spotId;
private final Vehicle vehicle;
private final Instant entry;
private TicketStatus status = TicketStatus.ACTIVE;
static Ticket issue(SpotId spotId, Vehicle vehicle, Instant entry) {
return new Ticket(TicketId.random(), spotId, vehicle, entry);
}
private Ticket(TicketId id, SpotId spotId, Vehicle vehicle, Instant entry) {
this.id = Objects.requireNonNull(id);
this.spotId = Objects.requireNonNull(spotId);
this.vehicle = Objects.requireNonNull(vehicle);
this.entry = Objects.requireNonNull(entry);
}
}Tell, don’t ask — and don’t chain
lot.getFloors().get(0).getSpots().get(2).setOccupied(true) is a tour of the wiring. It is also a Law of Demeter violation. Tell the lot to park. Let the lot tell the floor. Let the floor tell the spot. Each object protects its own wall. You did not need to know there were floors.
Asking for a query is fine: isFree(), fee(), status(). Asking for the guts so you can decide outside is how anemia returns. If you find yourself writing if (spot.getStatus() == FREE) spot.setStatus(OCCUPIED), you already have the method name: tryOccupy.
What you can change behind the wall
Today spots are an ArrayList. Tomorrow they are a Map by id, or two lists — free and occupied. If the public API is findFree and occupy, the controller does not care. That is the practical payoff. Encapsulation is not morality. It is the right to change your mind about storage without rewriting the use case.
State machines make this obvious. The legal edges are the doors. Everything else is a hole. Read state machines after you can name the commands on Ticket and Spot.
A sentence that is the whole page
“What is the object allowed to look like after any public method?” If you can answer that, you know which doors to cut and which windows to brick up. Private plus getters is not an answer. pay() and occupy() are.
A Date that leaked yesterday
getEntryTime() returning a mutable Date lets a caller call setTime(0) on your field. Instant is immutable. If you must return a Date for a legacy API, return a copy. The same rule applies to arrays: clone them. The wall includes the objects you hand out, not just the fields you marked private.
class Ticket {
private final Instant entry;
Instant entry() {
return entry; // Instant is immutable — safe to share
}
// If a legacy caller needs Date:
Date entryAsDate() {
return Date.from(entry); // a new Date, not the guts
}
}toString and JSON are also doors
A toString that dumps every field is how logs leak card numbers. A JSON serializer that binds to public setters is how a client sets status. Expose a Receipt or a TicketView for the wire. Keep the entity’s commands as the only way to change life. Mapping outward is allowed. Mapping inward through setters is the hole again.
Package-private is not a door policy
A setter with no modifier, sitting in the same package as a mapper, will be called from a test in that package and then from a “temporary” admin tool. If the mapper needs to rehydrate a Ticket, give it a factory that reconstructs a valid object — including status — from stored parts. Do not leave setStatus around for the mapper and hope nobody else finds it.
Same for “protected so a subclass can fix it.” If the subclass needs to break the invariant, you do not have a subclass. You have a hole with an inheritance tax. Prefer a collaborator.
Reflection and “just for the ORM”
An ORM that writes fields directly can coexist with a rich model if reconstruction goes through a factory that ends in a valid state. If the ORM requires public setters, keep those setters off the domain type. Map to a persistence row, then Ticket.rehydrate(...). The domain type never grew a hole for Hibernate’s convenience.
Machine-coding rarely has an ORM. It has a HashMap. The same rule applies: the map stores objects that were built valid. It does not get a back door to flip status so a test can “set up” PAID without calling pay().
clone() that returns a shallow copy of a Ticket still shares the mutable status if you were sloppy. Prefer immutable values inside the entity so copies are boring. Encapsulation and immutability reinforce each other: fewer moving parts behind the wall.
Audit the public surface
Before you say you are done, list every public method on Ticket and Spot. Each one should be a command, a query, or a factory. If you find setX, delete it or replace it with the story. That list is the wall. Everything else is wiring you can change tomorrow.
If the list is longer than a handful of commands and queries, the object is doing two jobs. Split it, then audit again. A short public surface is easier to keep honest than a long one with “just one more setter.”