Collection APIs
Pagination, filtering, empty results
Pagination and filtering are part of the collection API: return a page of results with an explicit filter and sort, not “all rows since launch.” Example: admin wants “all tickets today.” You return an ArrayList of every Ticket. The UI freezes. Next they want only EV, only floor 2, newest first. A collection API is a type: a , a page size, and a sort — not an unbounded list.
Typed query
floor, status, type — not four nulls.
Hard limit
Max page size in the type, not a hope.
Page envelope
items + hasMore or a cursor.
Empty is a page
Zero items, never null.
The query object lives at the edge
Filters are specifications at the API edge. The repository applies them. The domain Ticket does not know about page size. If you need composable predicates, the specification pattern is the named version of TicketQuery.
Java: a page, not the whole lot
final class TicketQuery {
final Optional<Integer> floor;
final Optional<TicketStatus> status;
final int limit;
final String afterId;
TicketQuery(Optional<Integer> floor, Optional<TicketStatus> status, int limit, String afterId) {
if (limit < 1 || limit > 100) throw new IllegalArgumentException("limit");
this.floor = floor;
this.status = status;
this.limit = limit;
this.afterId = afterId;
}
}
final class Page<T> {
final List<T> items;
final boolean hasMore;
Page(List<T> items, boolean hasMore) {
this.items = List.copyOf(items);
this.hasMore = hasMore;
}
}
interface TicketRepository {
Page<Ticket> search(TicketQuery query);
}Cursor vs offset — say it in one minute
Offset
LIMIT 20 OFFSET 40
A is easy to draw. It drifts when tickets insert at the top. Acceptable for a 40-row admin screen.
Cursor
WHERE id > :after ORDER BY id LIMIT 21
A is a bookmark. Fetch limit+1 to set hasMore. Opaque string to callers. Better when the list is live.
HLD read paths and indexes live in database indexing. In LLD you still refuse to return tickets.values() and you still copy the page. See collections ownership.
Java: in-memory search that still pages
class InMemoryTicketRepository implements TicketRepository {
private final NavigableMap<TicketId, Ticket> store = new TreeMap<>();
public Page<Ticket> search(TicketQuery query) {
List<Ticket> matched = store.values().stream()
.filter(t -> query.floor.map(f -> t.floor() == f).orElse(true))
.filter(t -> query.status.map(s -> t.status() == s).orElse(true))
.filter(t -> query.afterId == null || t.id().value().compareTo(query.afterId) > 0)
.limit(query.limit + 1L)
.toList();
boolean hasMore = matched.size() > query.limit;
List<Ticket> items = hasMore ? matched.subList(0, query.limit) : matched;
return new Page<>(items, hasMore);
}
}Two collection mistakes
Returning the live list
return tickets.values() lets callers clear() your store. Return a copy or an unmodifiable page. The repository owns the collection.
Null for “no rows”
search() returning null forces every caller to branch. Empty page, empty list. An means nothing matched. Null is not a page. Optional<List> is two empties fighting — see nullability.
Sort is part of the contract
“Newest first” is a decision. Put it on TicketQuery or fix it in the repository and document it. Unspecified order is how tests flake and UIs jump. A can include order, but a comment plus a comparator in the adapter is enough for the interview.
ASCII: the caller never sees the map
Admin UI
│ search(TicketQuery{floor=2, limit=20, afterId=…})
▼
ParkingService ← translates, does not dump the store
│
▼
TicketRepository.search
│
├── InMemory TreeMap + stream filters + limit+1
└── Jdbc WHERE floor=? AND id>? ORDER BY id LIMIT 21
Connection stays in this adapterAn untyped process(Object) method cannot page
A blob API has nowhere to put limit, afterId, or floor. You end up stuffing keys into the map and hoping the callee understands pageSize. TicketQuery is the typed version of that map — and unlike Object, the compiler knows limit is an int with a max. Collection design is class API design for lists: named inputs, bounded outputs, empty meaning “nothing matched.”
Admin “export everything” is a different use case. Give it a dedicated exportTickets(TicketQuery) that streams or writes a file, still with a cap or an explicit “I accept unbounded” flag the operator must pass. Do not let search() grow a magic limit=Integer.MAX_VALUE for that one button.
Java: Page copies so callers cannot clear the store
final class Page<T> {
private final List<T> items;
private final boolean hasMore;
private final String nextAfterId;
Page(List<T> items, boolean hasMore, String nextAfterId) {
this.items = List.copyOf(items);
this.hasMore = hasMore;
this.nextAfterId = nextAfterId;
}
List<T> items() { return items; }
boolean hasMore() { return hasMore; }
Optional<String> nextAfterId() { return Optional.ofNullable(nextAfterId); }
}Interview one-liner
“I will not return the live map. TicketQuery plus Page with a max of 100. Empty page when nothing matches. Cursor if the list is live; offset if it is a tiny admin screen.” Then stop. You have shown bounds, copy, and empty-vs-null without designing Elasticsearch.
Exercises
- Admin asks for “all open EV tickets.” Write TicketQuery and Page. What is the max limit, and who enforces it?
- Why fetch limit+1 instead of running a COUNT(*) on every search in an interview?
- A caller does page.items().clear(). What did you forget in the Page constructor?
Worked scene: floor 2, newest first
Admin asks for open tickets on floor 2, twenty at a time. TicketQuery carries floor, status OPEN, limit 20, afterId. Page returns 20 items and hasMore. The live TreeMap never leaves the repository. If nothing matches, items is empty — the table shows a blank state, not a spinner crash from null.
An untyped process(Object) method would stuff pageSize into a map and hope. TicketQuery makes the bound a constructor rule. That is class API design for lists.
How to explain this in an interview
Limit on TicketQuery
Always. Cap at 100 in the constructor. Admin export is a different method.
Cursor vs offset
Offset for a tiny static admin list. Cursor when tickets keep inserting at the top.
Empty page
Nothing matched. Never null. Never Optional of a list.
Copy
List.copyOf in Page. Callers must not clear the store.
Takeaway
Collection APIs are types: a query, a bound, a page, an empty list. Unbounded ArrayList dumps are a design bug. Copy the items. Never return null for “no rows.”