WL
Software Engineer
Wassim Lagnaoui

Tutorial 06: Tables API

Session lifecycle: open a table, track it, and close it with a checkout summary.

← Back to Tutorials

Every 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 Optional for 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.
  • ActiveSessionExistsException maps to HTTP 409 Conflict in the global exception handler: the right status when the request is valid but conflicts with current state.
  • sessionEnd is 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 sessionEnd to 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 totalPrice of each ItemSummaryDTO: not from a stored column, since individual prices can change.

Key Takeaways

  • Use a nullable sessionEnd timestamp 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.
  • flatMap is 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.