package com.shop.orders;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private BigDecimal total;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt = Instant.now();
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<OrderLine> items = new ArrayList<>();
protected Order() {
}
public Long getId() {
return id;
}
public BigDecimal getTotal() {
return total;
}
public OrderStatus getStatus() {
return status;
}
public Instant getCreatedAt() {
return createdAt;
}
public List<OrderLine> getItems() {
return items;
}
}
package com.shop.orders.web;
import java.math.BigDecimal;
import java.util.List;
public record OrderResponse(
Long id,
BigDecimal total,
String status,
List<LineItem> items
) {
public record LineItem(
String sku,
int quantity,
BigDecimal unitPrice
) {
}
}
package com.shop.orders.web;
import com.shop.orders.Order;
import com.shop.orders.OrderLine;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import java.util.List;
@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(target = "status", expression = "java(order.getStatus().name())")
@Mapping(target = "items", source = "items")
OrderResponse toResponse(Order order);
@Mapping(target = "sku", source = "productSku")
OrderResponse.LineItem toLineItem(OrderLine line);
List<OrderResponse.LineItem> toLineItems(List<OrderLine> lines);
}
package com.shop.orders.web;
import com.shop.orders.Order;
import com.shop.orders.OrderRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final OrderMapper orderMapper;
public OrderController(OrderRepository orderRepository, OrderMapper orderMapper) {
this.orderRepository = orderRepository;
this.orderMapper = orderMapper;
}
@GetMapping("/{id}")
@Transactional(readOnly = true)
public ResponseEntity<OrderResponse> getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Order " + id + " not found"));
// items are lazy; touched here while the session is still open
return ResponseEntity.ok(orderMapper.toResponse(order));
}
}
This snippet shows the classic layered flow in a Spring Boot service: a persistent JPA entity is never exposed directly over HTTP, but is translated into a purpose-built response DTO by a mapper before leaving the application. Decoupling the wire contract from the database schema is the core idea — it keeps lazy-loaded associations, internal columns, and audit fields from leaking into JSON, and lets the API evolve independently of the table structure.
In Order entity, Order is a standard @Entity with a generated identifier, a BigDecimal total, a status enum, and a @OneToMany collection of OrderLine items marked FetchType.LAZY. Returning this object straight from a controller is a common trap: Jackson would touch the lazy items collection outside a transaction and trigger a LazyInitializationException, and internal fields like createdAt would silently become part of the public contract.
OrderResponse DTO is a Java record, which is a natural fit for an immutable, read-only response. It flattens what the client actually needs — the id, total, a string status, and a small LineItem projection — rather than mirroring the entity. Records give value semantics and a compact constructor for free, so no boilerplate getters are required.
OrderMapper is a MapStruct interface annotated with @Mapper(componentModel = "spring"), which makes it an injectable Spring bean. At compile time MapStruct generates the implementation, so the mapping has no reflection cost at runtime. The @Mapping on status uses an expression to call name() on the enum, and toResponse recursively maps the List<OrderLine> because a matching LineItem mapping method exists. This is why explicit hand-written mappers are avoided: they are error-prone and drift out of sync as fields are added.
OrderController wires it together. getOrder loads the aggregate inside a @Transactional(readOnly = true) boundary so the lazy items are initialized while the session is open, then hands the entity to orderMapper.toResponse and returns the DTO. Throwing EntityNotFoundException lets a @ControllerAdvice translate it to a 404. The trade-off is one extra object graph per request, which is negligible next to the safety of a stable, transaction-safe contract. This pattern is the default choice whenever an API must outlive its schema.
Share this code
Here's the card — post it anywhere.