Hide the list
Designing collections inside domain objects
Collection ownership means the aggregate that owns a list is the only code allowed to mutate it. Callers get a copy or an unmodifiable view. Example: the controller does parkingLot.getSpots().add(new Spot()). The lot’s free-count is now a lie. A test occupies a spot by mutating the list. Two gates later take the same rectangle. The list leaked. The never had a chance.
The public question
Who owns this collection’s invariant, and what is the public operation?
Leaked
lot.getSpots().add(spot);
lot.getSpots().clear();
for (s : lot.getSpots()) s.setOccupied(true);
The is the API. Counts, types, and occupancy are optional suggestions.
Owned
floor.findFree(vehicle);
spot.tryOccupy(vehicle);
floor.spots(); // unmodifiable
A is the API. Reading uses an .
A collection is private memory for a rule
A collection inside a domain object is not a public ArrayList. It is private memory for a rule. The floor’s spots are how the floor knows what it contains. Callers should not add, remove, or clear that list. Hide the list. Expose the domain operation. occupy, release, findFree, addFloor. Those methods keep the count, the occupancy, and the type rules in one place. Returning the live list is an API that says “please break me.”
If a caller must read, return an unmodifiable view or a copy. If a caller must search, give findSpot(id) or stream-like queries on the owner. Do not hand out List so every class can write a for-loop against your guts. This is the same idea as encapsulation, aimed at lists. ParkingLot, Floor, a cache’s node list, a game board’s cells — the collection is the representation. The methods are the design.
Hide the list. Expose the job.
class Floor {
private final List<ParkingSpot> spots = new ArrayList<>();
void addSpot(ParkingSpot spot) {
spots.add(Objects.requireNonNull(spot));
}
Optional<ParkingSpot> findFree(Vehicle vehicle) {
return spots.stream()
.filter(s -> s.isFree() && s.fits(vehicle))
.findFirst();
}
Optional<ParkingSpot> findById(SpotId id) {
return spots.stream().filter(s -> s.id().equals(id)).findFirst();
}
void release(SpotId id) {
findById(id).ifPresent(ParkingSpot::release);
}
List<ParkingSpot> spots() {
return Collections.unmodifiableList(spots);
}
}View, copy, or query
An unmodifiable view is enough when the caller only iterates for display. A copy is better when the caller might hold the list across a mutation — a report thread, a snapshot for pricing. A query method is best when the caller was about to write the same filter you already have. Prefer findFree over “give me the list so I can find free.”
View
Cheap. Reflects later adds. No mutate.
Copy
Snapshot. Safer across time. Costs allocation.
Query
findFree, findById. The loop stays home.
Getter that returns the live list
getSpots() returning ArrayList lets callers add spots, clear floors, and skip occupy(). The owner no longer owns the rule. This is also a Law of Demeter failure: lot.getFloors().get(0).getSpots().get(2).setOccupied(true).
Mutating while iterating from outside
A controller that loops spots and calls setOccupied(true) duplicates Floor logic. Put the loop behind findFree or occupy. Concurrent modification exceptions are a symptom. The design smell was earlier.
Maps and sets are collections too
A TicketRepository that exposes Map<TicketId, Ticket> is the same leak with a different type. save and findById are the operations. A cache that returns its LinkedHashMap lets callers break LRU. A chess board that returns Piece[][] lets callers move a king two files without going through move(). The structure is representation. The verb is the design.
Ownership of the collection follows ownership and lifecycle: the floor creates spots, holds them, lets others use ids, and never lets a ticket destroy them. The list is how holding is implemented. It is not a public field with extra steps.
A repository that does not leak the map
class InMemoryTicketRepository implements TicketRepository {
private final Map<TicketId, Ticket> byId = new HashMap<>();
@Override
public void save(Ticket ticket) {
byId.put(ticket.id(), ticket);
}
@Override
public Optional<Ticket> findById(TicketId id) {
return Optional.ofNullable(byId.get(id));
}
// no Map<TicketId, Ticket> asMap()
}What to write on the board
“Floor owns the list. Callers get findFree and an unmodifiable view. Nobody add()s from a controller.” Then implement it. If the interviewer asks for a report of all spots, add a query or a snapshot. Do not open the field “just this once.” Just this once is how the invariant dies.
A board that does not hand out the grid
Tic-tac-toe and chess die the same way parking lots do: getCells()[r][c] = piece. Then nobody checks turn, check, or occupancy. The board owns the array. move(from, to) is the operation. A snapshot for display can be a copy of marks, not the live grid.
class Board {
private final Mark[][] cells = new Mark[3][3];
void place(int row, int col, Mark mark) {
if (cells[row][col] != null) throw new IllegalStateException("occupied");
cells[row][col] = mark;
}
Optional<Mark> at(int row, int col) {
return Optional.ofNullable(cells[row][col]);
}
Mark[][] snapshot() {
Mark[][] copy = new Mark[3][3];
for (int r = 0; r < 3; r++) copy[r] = cells[r].clone();
return copy;
}
}Counts are not a second list
If Floor keeps both spots and a freeCount integer, every leak of the list makes the count a liar. Either derive the count from the list inside the owner, or update the count only inside occupy and release. Never let a controller increment a public counter. The collection and the derived number are one invariant.
Iteration is not a reason to leak
“I need to print all spots” is the usual excuse. Add forEachSpot(Consumer) or return an unmodifiable view. “I need to sort them” is a query that can return a new list. “I need to test occupancy” should call occupy and assert isFree, not poke the ArrayList. Tests that mutate internals will pass while the production controller uses the same hole.
Unmodifiable is not a deep freeze
Collections.unmodifiableList(spots) stops add and remove. It does not stop a caller from calling spots.get(0).release() if release is public — and release should be public for the owner. If that bothers you, do not return spots at all. Return ids, or a read-only SeatView. The view type has isFree and id, not release. That is how you keep queries from becoming back doors.
For machine coding, an unmodifiable list of spots is often enough if occupy lives on the spot and the lot is single-threaded. Say that assumption. If two gates appear, you need more than a wrapper — you need a single owner thread or a lock around findFree-plus-occupy.
The API you want to be able to say
“No getSpots that mutates. findFree, occupy, release, unmodifiable snapshot for display.” If the interviewer can repeat that back, the collection is owned. If they can write getSpots().add from memory of your board, it is not.