Objects vs storage
Keeping persistence out of the domain
The persistence boundary is the rule that domain objects talk to storage through a port — not through JDBC directly. If ParkingLot holds a java.sql.Connection and park() writes SQL, every test needs a database, and an “in-memory” follow-up rewrites the lot. Never put a JDBC Connection on ParkingLot. The lot talks to a TicketRepository. The database stays behind that port.
How to explain this in an interview
Ask yourself: “Can I explain park() without mentioning SQL?” If no, the boundary is gone. Connection, PreparedStatement, and SQLException belong on the , not on the lot. This is in one sentence: the domain depends on a it owns.
Where the boundary sits
Read the diagram definition
flowchart TB
PL[ParkingLot]
TR[TicketRepository port]
MEM[InMemoryTicketRepository]
JDBC[JdbcTicketRepository]
DB[(Database)]
PL -->|tickets.save| TR
TR --> MEM
TR --> JDBC
JDBC -->|Connection lives here| DBParkingLot
│ tickets.save(ticket)
▼
TicketRepository ← port (interface)
│
├── InMemoryTicketRepository HashMap
└── JdbcTicketRepository Connection stays hereJava: no Connection on the lot
interface TicketRepository {
void save(Ticket ticket);
Optional<Ticket> findById(TicketId id);
}
class ParkingLot {
private final TicketRepository tickets;
private final Clock clock;
ParkingLot(TicketRepository tickets, Clock clock) {
this.tickets = tickets;
this.clock = clock;
}
Ticket park(Vehicle vehicle) {
Ticket ticket = allocation.open(vehicle, clock.instant());
tickets.save(ticket);
return ticket;
}
}
class JdbcTicketRepository implements TicketRepository {
private final Connection connection; // lives here, not on ParkingLot
public void save(Ticket ticket) { /* SQL */ }
public Optional<Ticket> findById(TicketId id) { /* SQL */ }
}Domain may call save
ParkingLot, Ticket, Spot know save and findById on a port. They do not know tables, connections, or ORMs.
Adapter may know SQL
How a ticket becomes a row is the adapter’s problem: HashMap in the interview, Postgres later.
Inject both collaborators
Repository and . Do not new DriverManager inside the lot. See dependency injection.
Why JDBC on the lot fails the follow-up
“What if we persist?” should be “implement JdbcTicketRepository,” not “rewrite park().” Tests use the map. Production wires JDBC. That is also Dependency Inversion. At service scale, database per service is the HLD cousin: storage stays behind a boundary. SQL strings inside Ticket (ticket.insertSql()) make the entity a row. Mapping belongs in the repository — see domain vs persistence model.
Mistakes
- JDBC Connection on ParkingLot — the lot now knows URLs, commits, and SQLException. You cannot unit-test park(). Put Connection on the repository implementation only.
- SQL strings inside Ticket — ticket.insertSql() makes the entity a row. The entity should not know its table.
- new HashMap inside every method — that is a hidden store. Inject the repository so tests share one fake.
Java: the lot still looks like use cases
class ParkingLot {
Ticket park(Vehicle vehicle); // no SQL in the signature
Receipt exit(TicketId id);
Optional<Ticket> find(TicketId id);
}
// Illegal — do not do this
class ParkingLotBroken {
private final Connection connection;
Ticket park(Vehicle vehicle) throws SQLException { /* ... */ }
}Static now() is the same leak as JDBC
Instant.now() inside park() makes overnight-fee tests flaky. DriverManager.getConnection() inside park() makes every test need Postgres. Both are hidden collaborators. Inject Clock and TicketRepository. The public API stays park(Vehicle) and exit(TicketId). The constructors carry the world. process(Object) plus a Connection field is two leaks: untyped surface and storage on the lot.
If the interviewer says “just use a database,” you still draw the port first. Persistence is an implementation of TicketRepository, not a reason to change park’s signature to throws SQLException. The follow-up “can we keep it in memory for tests?” is free if you drew the line.
Java: test uses the map, production uses JDBC
// Test
ParkingLot lot = new ParkingLot(new InMemoryTicketRepository(), Clock.fixed(t0, ZoneOffset.UTC));
Ticket ticket = lot.park(car);
assertEquals(car.plate(), lot.find(ticket.id()).orElseThrow().vehicle().plate());
// Production wiring (not on ParkingLot)
TicketRepository tickets = new JdbcTicketRepository(dataSource.getConnection());
ParkingLot production = new ParkingLot(tickets, Clock.systemUTC());Whiteboard drawing
Box ParkingLot. Arrow down to TicketRepository. Two boxes under it: InMemory, Jdbc. Write “Connection” only in the Jdbc box. If Connection appears in the ParkingLot box, erase it. That drawing is the whole persistence-boundary lesson.
Exercises
- A candidate puts Connection on ParkingLot “because we need to persist.” Rewrite the fields and constructors you would draw instead.
- Where does SQLException appear? Where must it not appear?
- Clock and TicketRepository are both injected. What shared idea do they illustrate?
Worked scene: the follow-up that should be free
You implemented park with InMemoryTicketRepository. The interviewer asks for persistence. You add JdbcTicketRepository, keep ParkingLot untouched, keep park(Vehicle) / exit(TicketId). If instead park grew a Connection and a try/catch SQLException, you rewrote the design. The follow-up is supposed to be an adapter, not a rewrite. Clock stays injected the whole time so overnight fees still freeze in tests.
Ticket.insertSql() is the same leak from the other side: the entity learned the table. Mapping SQL in the adapter is allowed to be ugly. Ugly adapters are cheaper than a domain that knows VARCHAR lengths.
How to explain this in an interview
ParkingLot fields
TicketRepository, Clock, maybe allocation. Never Connection.
JdbcTicketRepository fields
Connection or DataSource. SQL lives here. SQLException lives here.
Tests
InMemoryTicketRepository + fixed Clock. park(Vehicle) without Postgres.
Follow-up “persist it”
New adapter. Do not rewrite park. Do not add throws SQLException to the lot.
In about a minute, say: “ParkingLot has TicketRepository and Clock. JDBC Connection is on JdbcTicketRepository. I can explain park() without SQL. HashMap in an interview, Postgres later, Ticket unchanged.”
Instant.now() inside park is the same leak as DriverManager: a hidden collaborator. Inject both. An untyped process(Object) API plus a Connection field is two leaks at once.
Ticket.insertSql() is the leak from the other side. Mapping belongs in the adapter. The lot speaks save(ticket).
If park() throws SQLException, the boundary is gone even if Connection is “only a field.” Keep SQLException off the lot’s signature.
Draw Connection only in the Jdbc box. If it appears on ParkingLot, erase it.
Java: legal fields vs illegal fields
class ParkingLot {
private final TicketRepository tickets; // legal
private final Clock clock; // legal
// private final Connection connection; // illegal
}
class JdbcTicketRepository implements TicketRepository {
private final Connection connection; // legal — adapter only
}Takeaway
Draw the line between objects and storage. ParkingLot talks to TicketRepository. JDBC Connection never sits on the lot. HashMap today, Postgres tomorrow — Ticket does not change. If you cannot explain park() without SQL, the boundary is missing.