WL
Software Engineer
Wassim Lagnaoui

Tutorial 07: Orders API: Part 1

Placing an order: session lookup, building order items, computing the total, and returning the response.

← Back to Tutorials

The Orders API is the heart of the system. Every food item a customer gets starts here. This tutorial walks through the placeOrder flow: how the service validates the session, builds the order and its items, calculates the total, and constructs a detailed response without a mapper class.


1. Controller: order endpoints

@RestController
@RequestMapping("/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping()
    public ResponseEntity<PlaceOrderResponse> placeOrder(
            @RequestBody PlaceOrderRequest placeOrderRequest) {
        return ResponseEntity.status(HttpStatus.CREATED)
            .body(orderService.placeOrder(placeOrderRequest));
    }

    @GetMapping("/{id}")
    public ResponseEntity<OrderResponse> getOrderById(@PathVariable Long id) {
        return ResponseEntity.ok(orderService.getOrderById(id));
    }

    @GetMapping("/sessions/{id}")
    public ResponseEntity<List<OrderResponse>> getOrderBySession(
            @PathVariable Long sessionId) {
        return ResponseEntity.ok(orderService.getOrderBySessionId(sessionId));
    }
}
  • POST /orders returns 201 Created: a new resource was created, so 201 is semantically correct over 200.
  • The path variable on getOrderBySession is {id} but the parameter is named sessionId: the @PathVariable("id") annotation maps them explicitly.
  • No @Valid on placeOrder in this implementation: adding it with Bean Validation annotations on PlaceOrderRequest would be a straightforward improvement.

2. The PlaceOrderRequest DTO

// PlaceOrderRequest: what the client sends
public class PlaceOrderRequest {
    private Long tableSessionId;          // which active session to attach this order to
    private List<OrderItemRequest> items; // one entry per line item
}

// OrderItemRequest: one item line
public class OrderItemRequest {
    private Long menuItemId; // references MenuItem.id
    private int quantity;
}
  • The client references a tableSessionId (DB ID), not a table number. At this point in the flow, the front end already has the session ID from the start-session call.
  • Each item in items only needs the menu item's ID and a quantity: the price is looked up from the MenuItem entity server-side, so the client can never manipulate it.

3. placeOrder: the full service flow

@Transactional
public PlaceOrderResponse placeOrder(PlaceOrderRequest orderRequest) {
    // 1. Verify the session is active
    TableSession tableSession = tableSessionRepository
        .findActiveTableSessionById(orderRequest.getTableSessionId())
        .orElseThrow(NoActiveTableSessionFoundException::new);

    // 2. Create the Order shell
    Order order = new Order();
    order.setStatus(OrderStatus.PLACED.name());
    order.setTableSession(tableSession);
    order.setOrderDate(LocalDateTime.now());

    // 3. Build each OrderItem and accumulate the total
    List<OrderItem> orderItems = new ArrayList<>();
    Double total = 0.0;

    for (OrderItemRequest req : orderRequest.getItems()) {
        MenuItem menuItem = menuItemRepository.findById(req.getMenuItemId())
            .orElseThrow(MenuItemNotFoundException::new);

        OrderItem orderItem = new OrderItem();
        orderItem.setServed(false);
        orderItem.setMenuItem(menuItem);
        orderItem.setQuantity(req.getQuantity());
        orderItem.setOrder(order);           // back-reference so JPA cascades correctly

        total += req.getQuantity() * menuItem.getPrice();
        orderItems.add(orderItem);
    }

    order.setItems(orderItems);
    order.setTotal(total);
    orderRepository.save(order);            // cascades to OrderItems via @OneToMany(cascade = ALL)

    // 4. Build the response
    PlaceOrderResponse response = new PlaceOrderResponse();
    response.setOrderId(order.getId());
    response.setSessionId(tableSession.getId());
    response.setCreatedAt(order.getOrderDate());
    response.setStatus(order.getStatus());

    List<OrderItemResponse> itemResponses = orderItems.stream().map(oi -> {
        OrderItemResponse r = new OrderItemResponse();
        r.setItemId(oi.getId());
        r.setMenuItemId(oi.getMenuItem().getId());
        r.setName(oi.getMenuItem().getName());
        r.setQuantity(oi.getQuantity());
        r.setServed(oi.getServed());
        r.setTotalPrice(oi.getQuantity() * oi.getMenuItem().getPrice());
        return r;
    }).collect(Collectors.toList());

    response.setItems(itemResponses);
    return response;
}
  • @Transactional: wraps the entire method in a single DB transaction. If the loop throws (e.g., a menu item ID doesn't exist), the partial order is rolled back automatically.
  • Active session check first: if there's no active session, we fail fast before touching the Order table at all.
  • Price from the server: menuItem.getPrice() is read from the DB, not from the request. This means the client cannot send a modified price: the server always uses the canonical value.
  • orderItem.setOrder(order): the back-reference is set explicitly so JPA's cascade save picks up the items when orderRepository.save(order) is called.
  • total accumulation in the loop: simpler and more readable than a stream for this use case: keeps the item-building and total-calculation together.

4. Fetching orders by session

public List<OrderResponse> getOrderBySessionId(Long sessionId) {
    TableSession tableSession = tableSessionRepository.findById(sessionId)
        .orElseThrow(NoTableSessionFoundException::new);

    List<Order> orders = orderRepository.findByTableSession(tableSession);

    return orders.stream().map(order -> {
        OrderResponse resp = new OrderResponse();
        resp.setOrderId(order.getId());
        resp.setStatus(order.getStatus());
        resp.setSessionId(order.getTableSession().getId());

        List<OrderItemResponse> items = order.getItems().stream().map(oi -> {
            OrderItemResponse ir = new OrderItemResponse();
            ir.setItemId(oi.getId());
            ir.setMenuItemId(oi.getMenuItem().getId());
            ir.setName(oi.getMenuItem().getName());
            ir.setQuantity(oi.getQuantity());
            ir.setServed(oi.getServed());
            ir.setUnitPrice(oi.getMenuItem().getPrice());
            ir.setTotalPrice(oi.getMenuItem().getPrice() * oi.getQuantity());
            return ir;
        }).collect(Collectors.toList());

        resp.setOrderItems(items);
        return resp;
    }).collect(Collectors.toList());
}
  • The outer stream maps Order → OrderResponse; the inner stream maps OrderItem → OrderItemResponse. Nested streams are readable here because each level is short.
  • findByTableSession(tableSession) is a derived query: Spring Data generates the JPQL from the method name. No @Query needed.

5. Endpoint map

MethodPathPurpose
POST/ordersPlace a new order
GET/orders/{id}Get one order by ID
GET/orders/sessions/{id}All orders for a session
GET/orders/sessions/{id}/servedServed items for a session
GET/orders/sessions/{id}/unservedUnserved items for a session
POST/orders/{id}/serveMark whole order as served
POST/orders/orderItem/{id}/serveMark one item as served
GET/orders/kitchen/queueKitchen view of pending items
GET/orders/{id}/Items-statusCheck served status of all items

Key Takeaways

  • Always look up prices server-side from the MenuItem entity: never trust prices from the client request.
  • @Transactional on the service method ensures the entire order (plus all items) is saved atomically, or not at all.
  • Set the back-reference (orderItem.setOrder(order)) before saving so JPA's cascade picks up the child records correctly.
  • Validate the active session before creating any records: fail fast before writing partial data.
  • Inline response mapping (no mapper class) is fine for complex, one-off response shapes; extract a mapper only when the same mapping is reused in multiple places.