Tutorial 06: Tables API
Session lifecycle: open a table, track it, and close it with a checkout summary.
← Back to TutorialsEvery order in the system belongs to a table session. A session opens when a waiter seats guests, and closes when the bill is paid. This tutorial covers how that lifecycle is implemented: starting a session safely, querying active tables, and generating a full checkout summary.
1. Session lifecycle at a glance
A TableSession has two timestamps: sessionStart (set when opened) and sessionEnd (null while active, set when closed). The repository uses the null check to find active sessions.
// Key JPQL queries in TableSessionRepository
@Query("select t from TableSession t where t.tableNumber = :tableNumber and t.sessionEnd is null")
Optional<TableSession> findActiveTableSessionByTableNumber(@Param("tableNumber") String tableNumber);
@Query("select tb from TableSession tb where tb.sessionEnd is null")
List<TableSession> findActiveTableSession();
- sessionEnd is null: the idiomatic way to represent "currently active" without a separate boolean column.
- Returns
Optionalfor the single-session lookup: forces the caller to handle the "no active session" case explicitly.
2. Controller endpoints
@RestController
@RequestMapping("/sessions")
public class TableSessionController {
@PostMapping("/start")
public ResponseEntity<StartSessionResponse> startSession(@RequestBody @Valid StartSessionDTO dto) {
return ResponseEntity.status(HttpStatus.CREATED).body(tableSessionService.startSession(dto));
}
@PutMapping("/{tableNumber}/end")
public ResponseEntity<EndSessionResponse> endSession(@PathVariable String tableNumber) {
return ResponseEntity.status(HttpStatus.CREATED).body(tableSessionService.endSession(tableNumber));
}
@GetMapping("/active")
public ResponseEntity<List<TableSessionResponse>> getActiveSessions() {
return ResponseEntity.ok(tableSessionService.getActiveTableSessions());
}
@GetMapping("/active/{tableNumber}")
public ResponseEntity<TableSessionResponse> getActiveSessionByTable(@PathVariable String tableNumber) {
return ResponseEntity.ok(tableSessionService.findActiveSessionByTableNumber(tableNumber));
}
@GetMapping("/{id}/checkout-summary")
public ResponseEntity<SessionSummary> getCheckoutSummary(@PathVariable Long id) {
return ResponseEntity.ok(tableSessionService.getSessionSummaryForCheckout(id));
}
@GetMapping("/{date}/sessions-by-date")
public ResponseEntity<List<SessionSummary>> getSessionsByDate(@PathVariable String date) {
return ResponseEntity.ok(tableSessionService.getAllSessionByDate(date));
}
}
- PUT /{tableNumber}/end: uses the table number as the path variable, not a session ID. A waiter knows their table number, not the internal DB ID.
- /active vs /active/{tableNumber}: one returns all active sessions (for the host dashboard), the other returns the session for a specific table (for the waiter's view).
3. Starting a session safely
public StartSessionResponse startSession(StartSessionDTO dto) {
// Reject if there's already an open session for this table
Optional<TableSession> existing =
tableSessionRepository.findActiveTableSessionByTableNumber(dto.getTableNumber());
if (existing.isPresent()) {
throw new ActiveSessionExistsException(
"There is already an active session for table: " + dto.getTableNumber()
);
}
TableSession session = new TableSession();
session.setSessionStart(LocalDateTime.now());
session.setTableNumber(dto.getTableNumber());
tableSessionRepository.save(session);
StartSessionResponse resp = new StartSessionResponse();
resp.setId(session.getId());
resp.setStartTime(LocalDateTime.now());
resp.setTableNumber(session.getTableNumber());
resp.setActive(true);
return resp;
}
- The guard at the top prevents two active sessions for the same table: a common real-world bug if you skip the check.
ActiveSessionExistsExceptionmaps to HTTP 409 Conflict in the global exception handler: the right status when the request is valid but conflicts with current state.sessionEndis left null intentionally: that null value is what marks the session as active.
4. Ending a session
public EndSessionResponse endSession(String tableNumber) {
TableSession session = tableSessionRepository
.findActiveTableSessionByTableNumber(tableNumber)
.orElseThrow(NoActiveTableSessionFoundException::new);
session.setSessionEnd(LocalDateTime.now());
tableSessionRepository.save(session);
EndSessionResponse resp = new EndSessionResponse();
resp.setMessage("Session ended successfully");
resp.setEndTime(LocalDateTime.now());
resp.setStartTime(session.getSessionStart());
resp.setTableNumber(session.getTableNumber());
return resp;
}
- Setting
sessionEndto now is the only change: no status column to update, no extra table. The null-check queries automatically stop seeing this session as active. - The response includes both start and end times so the front end can display the session duration on the receipt.
5. Checkout summary
Before closing a session (or at any point), the front end can request a full summary: total orders, total items, total amount, and an item-by-item breakdown.
public SessionSummary getSessionSummaryForCheckout(Long sessionId) {
TableSession session = tableSessionRepository.findById(sessionId)
.orElseThrow(TableSessionNotFound::new);
SessionSummary summary = new SessionSummary();
summary.setSessionId(session.getId());
summary.setTableNumber(session.getTableNumber());
summary.setTotalOrders(session.getOrders().stream().count());
summary.setTotalItemOrdered(
session.getOrders().stream().flatMap(o -> o.getItems().stream()).count()
);
List<ItemSummaryDTO> items = getItemSummaryForSession(session.getId());
Double total = items.stream().mapToDouble(ItemSummaryDTO::getTotalPrice).sum();
summary.setTotalAmont(total);
summary.setItems(items);
return summary;
}
Example response from GET /sessions/5/checkout-summary:
{
"sessionId": 5,
"tableNumber": "T3",
"totalOrders": 3,
"totalItemOrdered": 11,
"totalAmont": 123.75,
"items": [
{ "itemName": "Grilled Salmon", "totalQuantity": 2, "totalPrice": 41.50, "served": true },
{ "itemName": "Caesar Salad", "totalQuantity": 3, "totalPrice": 29.85, "served": true }
]
}
- flatMap: flattens the two-level structure (session has orders, orders have items) into a single stream of items.
- The total is computed by summing the
totalPriceof eachItemSummaryDTO: not from a stored column, since individual prices can change.
Key Takeaways
- Use a nullable
sessionEndtimestamp to represent open vs. closed sessions instead of a separate boolean flag. - Always guard session creation against duplicates: a second open session for the same table is a data integrity bug.
- Use the table number (human-readable) for end-session and active-lookup paths; use the DB ID for summary and order-linking paths.
flatMapis the right tool for navigating nested collections (session → orders → items) in a single stream pipeline.- Compute totals at query time from individual prices rather than storing a cached total: prices can change and cached values go stale.