Port plus adapter
Repository abstraction
A is a typed port for loading and saving one aggregate — usually save, findById, and maybe search. Example: tickets must come back at exit. A HashMap on ParkingLot works until someone asks “what if we persist?” With a repository, the lot calls the port and does not care whether the implementation is a map or a database.
One per aggregate
TicketRepository, not AppRepository.save(Object).
Swap the impl
HashMap today. JDBC tomorrow. Ticket unchanged.
Domain in, domain out
No Map<String,Object> rows leaking into park().
Collection-shaped, not table-shaped
Think of a repository as for one : you save a Ticket and look it up by id. Do not add table-shaped methods like updateFeeColumn. Add a only when a use case needs it. Pages and filters belong on search — see collection APIs. The boundary rule is persistence out of the domain: JDBC stays in the adapter.
Java: port plus in-memory adapter
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(TicketId id);
}
class InMemoryTicketRepository implements TicketRepository {
private final Map<TicketId, Ticket> store = new HashMap<>();
public void save(Ticket ticket) {
store.put(ticket.id(), ticket);
}
public Optional<Ticket> findById(TicketId id) {
return Optional.ofNullable(store.get(id));
}
}How to explain JDBC in an interview
Explain this clearly: “JdbcTicketRepository would implement the same interface. Connection lives there.” That sentence is the design. Inject the repository. Tests use the in-memory map. Wiring is DI and DIP. Choosing SQL vs a document store is SQL vs NoSQL— pick later; keep the port now.
Java: a query the use case needs
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(TicketId id);
Optional<Ticket> findOpenBySpot(SpotId spotId);
Page<Ticket> search(TicketQuery query);
}
class ParkingLot {
private final TicketRepository tickets;
Receipt exit(TicketId id) {
Ticket ticket = tickets.findById(id).orElseThrow(() -> new UnknownTicket(id));
Receipt receipt = ticket.close(clock.instant(), pricing);
tickets.save(ticket);
return receipt;
}
}One God repository for the whole app
Repository that returns HashMap rows
DAO vs repository in an interview
Identity and save
save is upsert in most LLD rounds: put by TicketId. Do not invent a separate insert and update unless the interviewer asks about unique constraints. The in-memory map is honest: one id, one ticket. If two threads save, that is a concurrency story — thread safety— not a reason to put synchronized on JDBC inside ParkingLot.
What the lot is allowed to know
ParkingLot may know tickets.save and tickets.findById. It may not know SELECT, ResultSet, or table names. If you need “open tickets on floor 2,” add findOpenByFloor to the port and implement it twice: stream-filter in memory, WHERE in JDBC. Do not pass a SQL string from the lot into the repository — that is the lot writing SQL with extra steps.
An untyped AppRepository.save(Object) is the storage version of process(Object): one bag method that hides every aggregate. You lose the aggregate, the type, and the ability to swap impls without rewriting callers. TicketRepository is the persistence equivalent of park(Vehicle): named, typed, and small.
Java: JDBC adapter still returns Ticket
class JdbcTicketRepository implements TicketRepository {
private final Connection connection;
public Optional<Ticket> findById(TicketId id) {
try (PreparedStatement ps = connection.prepareStatement(
"SELECT id, plate, status, entry_ms FROM ticket WHERE id = ?")) {
ps.setString(1, id.value());
ResultSet rs = ps.executeQuery();
if (!rs.next()) return Optional.empty();
return Optional.of(mapRow(rs));
} catch (SQLException e) {
throw new PersistenceFailed(e);
}
}
private Ticket mapRow(ResultSet rs) throws SQLException {
return Ticket.rehydrate(/* columns → value objects */);
}
}How to explain this in an interview
“I will inject TicketRepository. InMemory for the interview. JDBC would implement the same interface; Connection stays in that class. ParkingLot.park still takes Vehicle.” That explanation plus a HashMap implementation is a complete persistence design for machine coding.
Exercises
- Write TicketRepository with save and findById. Implement InMemory. Where would Connection appear if you added JDBC?
- A teammate adds updateFeePaise(long) to the interface. Why is that table-shaped, and what domain method replaces it?
- Can you swap the HashMap for a database without rewriting Ticket? What must stay true of findById’s return type?
Worked scene: exit without SQL in the lot
The driver presents TicketId T-9. ParkingLot.exit asks tickets.findById. The in-memory map returns the Ticket. Ticket.close prices the stay. tickets.save writes it back. Nowhere in that exit story is a JDBC Connection. If tomorrow findById hits Postgres, that same exit story still works — that is the swap test. If park() had to declare throws SQLException, the design failed: the lot would depend on SQL.
Keep query methods honest: findOpenBySpot returns Optional<Ticket>, not a row of column names. Search returns Page<Ticket>. The adapter may run JOIN; the lot still sees Tickets. If you feel the urge to add execute(String sql) to the interface, you are writing process(Object) for storage. Stop and name the use case instead.
interface SpotRepository {
Optional<ParkingSpot> findById(SpotId id);
void save(ParkingSpot spot);
}
class ParkingLot {
private final TicketRepository tickets;
private final SpotRepository spots;
// two ports, two aggregates — still no JDBC here
}How to explain this in an interview
Interface size
save, findById, then the one query the use case needs. Not every WHERE.
Return type
Ticket or Page<Ticket>. Never Map<String,Object> rows.
How many repositories
One per aggregate. Tickets, maybe spots. Never AppRepository.save(Object).
JDBC
Same interface. Connection in the adapter. mapRow rebuilds Ticket.
In about a minute, say: “TicketRepository.save and findById. InMemory is a HashMap. JDBC implements the same port. I return Ticket, not rows. One repository per aggregate — not AppRepository.save(Object).”
updateFeePaise on the interface is table-shaped. ticket.close plus save is domain-shaped. Search lives as search(TicketQuery), not execute(sql) from the lot.
The swap test is the design: change InMemory for Jdbc, keep park(Vehicle) and Ticket. If findById returns a row map, you failed the test.
Two aggregates means two ports — tickets and spots — not one God repository that takes Object.
That explanation plus a HashMap implementation is enough for the interview.
Java: findOpen stays a query on the port
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(TicketId id);
List<Ticket> findOpen();
}
class InMemoryTicketRepository implements TicketRepository {
public List<Ticket> findOpen() {
return store.values().stream()
.filter(Ticket::isOpen)
.toList(); // copy via stream; still not the live map
}
}Takeaway
Give aggregates a repository: save, findById, the queries you need. Hide SQL, files, or maps. Return Ticket, not rows. One repository per aggregate — never an untyped save(Object) for all aggregates. Swap HashMap for a database without rewriting Ticket.