Tutorial 07: Orders API: Part 1
Placing an order: session lookup, building order items, computing the total, and returning the response.
← Back to TutorialsThe 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
getOrderBySessionis{id}but the parameter is namedsessionId: the@PathVariable("id")annotation maps them explicitly. - No
@ValidonplaceOrderin this implementation: adding it with Bean Validation annotations onPlaceOrderRequestwould 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
itemsonly needs the menu item's ID and a quantity: the price is looked up from theMenuItementity 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
Ordertable 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 mapsOrderItem → 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@Queryneeded.
5. Endpoint map
| Method | Path | Purpose |
|---|---|---|
| POST | /orders | Place a new order |
| GET | /orders/{id} | Get one order by ID |
| GET | /orders/sessions/{id} | All orders for a session |
| GET | /orders/sessions/{id}/served | Served items for a session |
| GET | /orders/sessions/{id}/unserved | Unserved items for a session |
| POST | /orders/{id}/serve | Mark whole order as served |
| POST | /orders/orderItem/{id}/serve | Mark one item as served |
| GET | /orders/kitchen/queue | Kitchen view of pending items |
| GET | /orders/{id}/Items-status | Check served status of all items |
Key Takeaways
- Always look up prices server-side from the
MenuItementity: never trust prices from the client request. @Transactionalon 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.