Tables are not objects
Domain model vs persistence model
The domain model is how the business thinks. The persistence model is how the database stores rows. They need not match field-for-field. Example: Ticket has a value object and a list of fee lines. The table has amount_paise and currency columns, with no child table yet. That is fine. The repository flattens on the way in and rebuilds value objects on the way out.
| Domain | Persistence | Map where |
|---|---|---|
| Money fee | amount_paise, currency | JdbcTicketRepository.save / find |
| TicketId | id VARCHAR PK | new TicketId(rs.getString) |
| TicketStatus enum | status TEXT | name() / valueOf, never ordinal |
| List<FeeLine> | JSON or child table | Adapter; Ticket still has a list |
| Instant entry | entry_ms BIGINT | Instant.ofEpochMilli |
Domain model
Behavior and invariants. Money.plus, Ticket.close. If you change a column, the domain type should not have to change. That is the test: “If I change a column, must Ticket change too?”
Persistence model
The is for rows, indexes, and columns. They will disagree. That is why the repository exists.
Java: rebuild the value object at the mapper
class JdbcTicketRepository implements TicketRepository {
private final Connection connection;
public void save(Ticket ticket) {
long paise = ticket.fee().paise();
String currency = ticket.fee().currency();
// INSERT ... id, plate, amount_paise, currency, status, entry_ms
}
public Optional<Ticket> findById(TicketId id) {
// read columns, then:
Money fee = new Money(paise, currency);
return Optional.of(Ticket.rehydrate(id, fee, status, entry));
}
}rehydrate is not park
rebuilds a Ticket that already existed. It must not occupy a new spot. Put it on Ticket as a factory used only by adapters, or a package-visible constructor. Tests of park() never call it. Value objects are covered in entity vs value object. The DTO cousin is DTO vs domain: three models if you count the wire — request, domain, row.
Java: Ticket stays honest
class Ticket {
private final TicketId id;
private final Money fee;
private TicketStatus status;
static Ticket rehydrate(TicketId id, Money fee, TicketStatus status, Instant entry) {
return new Ticket(id, fee, status, entry);
}
// no amount_paise field — that name is a column
}ORM is not an excuse to skip the line
If Hibernate’s Ticket is also your domain, lazy collections and no-arg constructors will shape the design. A small mapping layer is the persistence boundary. In a 45-minute interview you may only have the in-memory model. Still say: “If this were SQL, I would map here, not on ParkingLot.” That keeps JDBC off the lot and columns off Ticket. Schema evolution at HLD scale is data modeling and schema evolution; event-shaped history is event sourcing. Neither requires Ticket to be a row.
Mistakes
- Domain fields named after columns — int amount_paise on Ticket is the table leaking inward. Use Money. Let the repository split it.
- ORM entity as the only model — lazy collections and no-arg constructors will shape close(). Keep a mapping layer.
- ParkingLot knows column names — then you have no boundary. Mapping is the repository’s job. See repository.
Three models, three jobs
ParkRequest / TicketView wire — strings, display
│ map in controller
▼
Ticket / Money / Spot domain — invariants, close()
│ map in repository
▼
ticket row persistence — columns, indexes
ParkingLot never imports java.sql
Ticket never has amount_paiseNo-arg constructors are a persistence smell
ORM tools like a public Ticket() and setters. Domain Ticket likes a constructor that requires id, vehicle, spot, entry. If you surrender to the no-arg constructor, CLOSED tickets can be assembled with a null vehicle “until the session loads.” Keep rehydrate as an explicit factory used by the adapter. Do not make empty Ticket the public API. park(Vehicle) still constructs a real Ticket; loading reconstructs one that already lived.
An untyped process(Object) payload stored as a blob is the worst persistence model: no columns, no Money, no status enum — just bytes. Typed rows are better. Typed domain objects rebuilt from rows are the point. The lot still never sees the row.
Java: never persist ordinals blindly
class JdbcTicketRepository implements TicketRepository {
public void save(Ticket ticket) {
String status = ticket.status().name(); // ACTIVE, not 0
long entryMs = ticket.entry().toEpochMilli();
long paise = ticket.fee().paise();
// bind status, entryMs, paise — not ticket itself as Object
}
private TicketStatus statusOf(String raw) {
return TicketStatus.valueOf(raw); // fails loud on unknown column values
}
}The column-change test
Rename amount_paise to amount_minor. If Ticket compiles without edits and JdbcTicketRepository changes two lines, the boundary held. If Ticket had int amount_paise, the domain changed because of storage. That test is how you explain the page in one minute.
Exercises
- Money is one object and two columns. Write the save and find mapping lines. What field must not appear on Ticket?
- If you change amount_paise to amount_minor, who changes — Ticket or JdbcTicketRepository?
- Why is Ticket.rehydrate different from park()? Who is allowed to call it?
Worked scene: fee lines as JSON later
Today the table has one fee. Tomorrow finance wants line items. Domain Ticket already has List<FeeLine>. The adapter starts writing JSON into a column, then later a child table. Ticket.close() does not change. If Ticket had been a flat amount_paise field, adding lines would rewrite the entity and every caller. That is why you keep the domain honest even when the table is ugly.
ParkingLot still calls tickets.save(ticket). It never names the JSON column. JDBC stays off the lot. rehydrate rebuilds the list. park() is not involved in loading history.
How to explain this in an interview
Money
One object on Ticket. Two columns in the adapter. Never amount_paise on Ticket.
Status
Enum in domain. name() to TEXT. Never ordinal. valueOf on the way out.
rehydrate
Adapter only. Not park. Does not occupy a new spot.
Column rename test
JdbcTicketRepository changes. Ticket does not. If Ticket changes, the leak is in.
In about a minute, say: “Money stays a value object. The table can be two columns. I map in JdbcTicketRepository. ParkingLot never sees amount_paise. rehydrate is not park.”
If Hibernate wants a no-arg constructor, keep it off the public domain API. Ugly adapters are allowed. A Ticket that looks like a row is not.
park(Vehicle) still constructs a real Ticket. Loading reconstructs one that already lived. Those two factories must not share a Connection field on the lot.
If I change a column, Ticket should not have to change. That is the whole page.
Java: Instant is not a BIGINT on Ticket
Instant entry = Instant.ofEpochMilli(rs.getLong("entry_ms"));
Ticket ticket = Ticket.rehydrate(id, fee, status, entry);
// Ticket still has Instant, not long entry_msTakeaway
Accept that tables are not objects. Map at the repository. Keep Money as Money. Keep JDBC off ParkingLot. If changing a column forces Ticket to change, the persistence model has leaked inward.