Unit of work
Where transactions begin and end
A is the set of changes that must all succeed or all roll back. Example: park() marks a spot occupied, then saves a ticket. If the save fails, the spot is taken and nobody has a ticket. That pair is one unit. The transaction starts at the use-case edge — park() — not inside ParkingSpot.setOccupied.
Swimlane: the use case owns commit
Read the diagram definition
sequenceDiagram
participant S as ParkingService
participant L as ParkingLot
participant R as TicketRepository
participant U as UnitOfWork
S->>U: begin
S->>L: park(vehicle)
L->>L: occupy spot
S->>R: save(ticket)
S->>U: commit
Note over L: Spot.occupy does not commitParkingService.park() ← transaction / unit of work starts
allocate + occupy
tickets.save
commit
ParkingSpot.occupy() ← no commit hereBegin at the use case
Controller or ParkingService. That is the story: park, exit, pay. there.
Entities change memory
Spot.tryOccupy() is not allowed to call connection.commit(). You could not compose occupy with save.
Fail together
if save fails. synchronized alone is mutual exclusion — compensate occupy, or use JDBC rollback.
Java: use case owns the unit of work
class ParkingService {
private final ParkingLot lot;
private final TicketRepository tickets;
private final UnitOfWork uow;
Ticket park(Vehicle vehicle) {
return uow.inTransaction(() -> {
Ticket ticket = lot.park(vehicle);
tickets.save(ticket);
return ticket;
});
}
}
interface UnitOfWork {
<T> T inTransaction(Supplier<T> work);
}In-memory rounds still have a unit of work
A synchronized park() is mutual exclusion: two gates cannot interleave occupy and save. It is not a database transaction — if tickets.save throws after spot.occupy(), you still need an explicit undo (or occupy only after save can succeed). Call that out: the lock stops races; rollback is a separate story. When you add JDBC, the same use-case method opens the real transaction. HLD depth is ACID and isolation levels. You do not need SERIALIZABLE on the whiteboard. You need to name what is .
Java: in-memory atomic park
class ParkingLot {
private final Object gate = new Object();
private final TicketRepository tickets;
Ticket park(Vehicle vehicle) {
synchronized (gate) {
// Mutual exclusion only — not JDBC rollback. Undo occupy if save fails.
ParkingSpot spot = allocation.choose(freeSpots(), vehicle)
.orElseThrow(SpotUnavailable::new);
spot.occupy();
try {
Ticket ticket = Ticket.open(ids.next(), vehicle, spot.id(), clock.instant());
tickets.save(ticket);
return ticket;
} catch (RuntimeException e) {
spot.free(); // explicit compensate; synchronized alone does not roll back
throw e;
}
}
}
}Mistakes
- Commit inside a setter — spot.setOccupied() that calls connection.commit() cannot share a transaction with tickets.save(). Commit at the use case.
- Two writes with no story — occupy, then save, with no lock and no transaction, is how you get a taken spot and no ticket. Say which two steps are atomic before you code them.
- JDBC commit on ParkingLot — that drags Connection onto the lot. Keep the unit of work next to the service, the Connection on the adapter. See persistence boundary.
Outbox is a later story
If park must also notify a display board, you will want the write and the event in one unit — the outbox pattern. In LLD, say “I would commit ticket plus outbox row together.” Do not start a second Connection in Ticket.close(). The GRASP controller is the object that runs the use case and therefore the natural commit point. Two gates racing for spot 101 is thread safety: the lock and the transaction are cousins.
Nested transactions are a smell here
If Spot.occupy opens a transaction and ParkingService.park opens another, you now have two commits or a savepoint maze. Keep one unit per use case. Nested “just in case” commits are how you persist occupy without the ticket. The GRASP controller / application service is the one object allowed to say commit. Repositories execute statements inside that unit; they do not start their own unless you are in a standalone script.
An untyped process(Object) API has no named unit of work: every map key might write. park(Vehicle) is a named unit. That is why the class API and the transaction boundary are the same conversation: name the use case, then say what must land together.
Java: adapter commits only when the service says so
class JdbcUnitOfWork implements UnitOfWork {
private final DataSource dataSource;
public <T> T inTransaction(Supplier<T> work) {
Connection connection = null;
boolean previousAutoCommit = true;
try {
connection = dataSource.getConnection();
previousAutoCommit = connection.getAutoCommit();
connection.setAutoCommit(false);
T result = work.get();
connection.commit();
return result;
} catch (RuntimeException | SQLException e) {
if (connection != null) {
try { connection.rollback(); } catch (SQLException ignored) {}
}
throw (e instanceof RuntimeException re) ? re : new PersistenceFailed(e);
} finally {
if (connection != null) {
try {
connection.setAutoCommit(previousAutoCommit);
connection.close();
} catch (SQLException ignored) {}
}
}
}
}Two gates, one spot
Concurrent park on spot 101 is a race plus a transaction. The lock (or DB unique constraint on occupied spot) makes the check-and-occupy atomic. The transaction makes occupy-and-save atomic. You need both stories. Saying “I will synchronized park” covers the in-memory interview. Saying “unique(spot_id) where open” covers JDBC. Neither lives inside ParkingSpot.setOccupied as a commit call.
Exercises
- Name the unit of work for exit. What rolls back if payment recording fails after the spot is freed?
- Why must ParkingSpot.occupy() not call commit, even if it has a Connection in a bad design?
- In a single-threaded toy lot, what still counts as a “transaction” between occupy and save?
Worked scene: occupy without a ticket
Gate A occupies spot 101 in memory. tickets.save throws. Without a unit of work, 101 stays occupied and the driver has no TicketId. The next park thinks the lot is tighter than it is. With a transaction or a synchronized park that only occupies after save can be attempted as one block, both map puts land or neither does. In JDBC, rollback restores the spot row. In memory, you undo occupy on failure or never expose occupy until save returns.
Explain that recovery plan out loud. Interviewers grade the story more than the UnitOfWork interface. An untyped process(Object) API has no story: you cannot name which writes share a commit. park(Vehicle) is the name of the unit.
Ticket park(Vehicle vehicle) {
return uow.inTransaction(() -> {
ParkingSpot spot = allocation.choose(freeSpots(), vehicle)
.orElseThrow(SpotUnavailable::new);
Ticket ticket = Ticket.open(ids.next(), vehicle, spot.id(), clock.instant());
spot.occupy();
tickets.save(ticket);
spots.save(spot);
return ticket;
});
}How to explain this in an interview
park unit
Occupy + ticket save. One lock in memory. One JDBC transaction in production.
exit unit
Free spot + close ticket + payment record. Name it before coding.
Who commits
ParkingService / use-case edge. Not Spot.occupy. Not Ticket.close.
Connection.commit
UnitOfWork adapter. Never a field on ParkingLot.
In about a minute, say: “park is occupy plus save. That pair is one unit. Spot.occupy does not commit. In memory I synchronized park; with JDBC the service starts the transaction. Two gates on spot 101 need a lock and a unit of work.”
If save fails after occupy with no rollback, the lot is inconsistent: a taken spot and no ticket. Name the unit before you code the two writes. An untyped process(Object) API has no named unit.
Java: exit as one unit
Receipt exit(TicketId id) {
return uow.inTransaction(() -> {
Ticket ticket = tickets.findById(id).orElseThrow(() -> new UnknownTicket(id));
Receipt receipt = ticket.close(clock.instant(), pricing);
ParkingSpot spot = spots.findById(ticket.spotId())
.orElseThrow(() -> new InconsistentLot("ticket " + id + " has no spot"));
spot.free();
tickets.save(ticket);
return receipt;
});
}Takeaway
Name the unit of work that must succeed or roll back. park commits allocation plus ticket write together. Entities do not commit. JDBC transactions start at the use-case edge, not inside a spot setter. Never put Connection.commit on ParkingLot.