DTOs and Response Shaping
Every beginner makes the same mistake: returning JPA entities directly from REST controllers. This article explains why that’s dangerous, and how to design clean DTOs that make your API stable, secure, and maintainable.
Why Not Return Entities Directly?
Consider this:
@GetMapping("/{id}")
public Order getOrder(@PathVariable UUID id) {
return orderRepository.findById(id).orElseThrow(); // Entity returned directly
}
Problems with this:
1. Serialization of lazy-loaded relationships crashes
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY)
private List<OrderItem> items; // Not loaded yet
// Jackson tries to serialize this → LazyInitializationException
}
2. Sensitive data leaks
@Entity
public class Customer {
private String passwordHash; // Exposed in API response
private String internalNotes; // Exposed in API response
private UUID internalOwnerId; // Exposed in API response
}
3. Circular references cause infinite loops
@Entity
public class Order {
@ManyToOne
private Customer customer;
}
@Entity
public class Customer {
@OneToMany
private List<Order> orders;
}
// Jackson: Order → Customer → orders → Order → Customer → ... StackOverflowError
4. Tight coupling between DB schema and API contract
Change a column name → breaking API change. Rename an entity field → API changes for all clients.
5. No versioning
You can’t return different shapes for different API versions if you’re returning the entity.
DTOs with Java Records
Java records are perfect for DTOs — immutable, compact, automatically implement equals/hashCode/toString:
// Response DTO
public record OrderResponse(
UUID id,
String orderNumber,
CustomerSummary customer,
List<OrderItemResponse> items,
OrderStatus status,
MoneyAmount total,
Instant createdAt
) {
// Nested DTOs
public record CustomerSummary(UUID id, String name, String email) {}
public record OrderItemResponse(
UUID productId,
String productName,
int quantity,
MoneyAmount unitPrice,
MoneyAmount lineTotal
) {}
public record MoneyAmount(BigDecimal amount, String currency) {}
}
// Request DTO
public record CreateOrderRequest(
UUID customerId,
List<OrderItemRequest> items,
ShippingAddressRequest shippingAddress
) {
public record OrderItemRequest(UUID productId, int quantity) {}
public record ShippingAddressRequest(
String line1, String line2,
String city, String country, String postalCode
) {}
}
The Mapping Layer
You need to convert between entities and DTOs. There are three approaches:
1. Static factory method on the DTO (simplest)
public record OrderResponse(/* fields */) {
public static OrderResponse from(Order order) {
return new OrderResponse(
order.getId(),
order.getOrderNumber(),
CustomerSummary.from(order.getCustomer()),
order.getItems().stream()
.map(OrderItemResponse::from)
.toList(),
order.getStatus(),
MoneyAmount.of(order.getTotalAmount(), order.getCurrency()),
order.getCreatedAt()
);
}
public record CustomerSummary(UUID id, String name, String email) {
public static CustomerSummary from(Customer customer) {
return new CustomerSummary(
customer.getId(), customer.getName(), customer.getEmail()
);
}
}
}
Use in the controller:
@GetMapping("/{id}")
public OrderResponse getOrder(@PathVariable UUID id) {
return orderService.findById(id)
.map(OrderResponse::from)
.orElseThrow(() -> new OrderNotFoundException(id));
}
Good for small APIs. Gets verbose with many fields.
2. Mapper service (for complex mappings)
@Component
public class OrderMapper {
private final PricingService pricingService;
public OrderMapper(PricingService pricingService) {
this.pricingService = pricingService;
}
public OrderResponse toResponse(Order order) {
return new OrderResponse(
order.getId(),
order.getOrderNumber(),
toCustomerSummary(order.getCustomer()),
order.getItems().stream().map(this::toItemResponse).toList(),
order.getStatus(),
toMoneyAmount(order.getTotalAmount()),
order.getCreatedAt()
);
}
public Order toEntity(CreateOrderRequest request) {
Order order = new Order();
order.setCustomerId(request.customerId());
order.setItems(
request.items().stream()
.map(this::toItemEntity)
.toList()
);
return order;
}
private CustomerSummary toCustomerSummary(Customer customer) {
return new CustomerSummary(customer.getId(), customer.getName(), customer.getEmail());
}
private OrderItemResponse toItemResponse(OrderItem item) {
BigDecimal lineTotal = item.getUnitPrice()
.multiply(BigDecimal.valueOf(item.getQuantity()));
return new OrderItemResponse(
item.getProductId(),
item.getProductName(),
item.getQuantity(),
toMoneyAmount(item.getUnitPrice()),
toMoneyAmount(lineTotal)
);
}
private MoneyAmount toMoneyAmount(BigDecimal amount) {
return new MoneyAmount(amount, "USD");
}
}
3. MapStruct (for large codebases)
MapStruct generates mapping code at compile time — no runtime overhead, no reflection:
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.5.5.Final</version>
<scope>provided</scope>
</dependency>
@Mapper(componentModel = "spring")
public interface OrderMapper {
@Mapping(source = "customer.name", target = "customerName")
@Mapping(source = "customer.email", target = "customerEmail")
@Mapping(target = "total", expression = "java(toMoney(order.getTotalAmount()))")
OrderResponse toResponse(Order order);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "status", constant = "PENDING")
Order toEntity(CreateOrderRequest request);
List<OrderResponse> toResponseList(List<Order> orders);
default MoneyAmount toMoney(BigDecimal amount) {
return new MoneyAmount(amount, "USD");
}
}
MapStruct generates the implementation at compile time. Inject and use it:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository repository;
private final OrderMapper mapper;
public OrderResponse create(CreateOrderRequest request) {
Order entity = mapper.toEntity(request);
Order saved = repository.save(entity);
return mapper.toResponse(saved);
}
}
Response Shaping: Different Views for Different Consumers
Sometimes you need different response shapes from the same endpoint — a list view (minimal fields) vs a detail view (full fields).
Approach 1: Separate DTOs per use case
// Light summary for list views
public record OrderSummary(
UUID id,
String orderNumber,
OrderStatus status,
BigDecimal total,
Instant createdAt
) {}
// Full detail for single-resource views
public record OrderDetail(
UUID id,
String orderNumber,
CustomerSummary customer,
List<OrderItemResponse> items,
OrderStatus status,
BigDecimal total,
ShippingAddressResponse shippingAddress,
List<AuditEvent> history,
Instant createdAt,
Instant updatedAt
) {}
@GetMapping
public List<OrderSummary> listOrders() {
return orderService.findAll().stream()
.map(orderMapper::toSummary)
.toList();
}
@GetMapping("/{id}")
public OrderDetail getOrder(@PathVariable UUID id) {
return orderMapper.toDetail(
orderService.findByIdWithDetails(id).orElseThrow()
);
}
This is the cleanest approach. Each DTO is optimized for its use case.
Approach 2: Spring Data Projections
For database-driven responses, Spring Data can project directly to interfaces or records:
// Interface projection — Spring generates the implementation
public interface OrderSummaryProjection {
UUID getId();
String getOrderNumber();
OrderStatus getStatus();
BigDecimal getTotalAmount();
Instant getCreatedAt();
}
// In repository
public interface OrderRepository extends JpaRepository<Order, UUID> {
List<OrderSummaryProjection> findAllProjectedBy();
// Or with a class-based projection (DTO projection)
<T> List<T> findAllProjectedBy(Class<T> type);
}
// DTO projection via constructor
public record OrderSummary(UUID id, String orderNumber, OrderStatus status, Instant createdAt) {}
// In repository — JPQL constructor expression
@Query("SELECT new com.devopsmonk.order.dto.OrderSummary(o.id, o.orderNumber, o.status, o.createdAt) FROM Order o")
List<OrderSummary> findAllSummaries();
DTO projections are efficient — the DB query only fetches the needed columns.
Handling null Fields in Responses
By default, Jackson includes null fields. Configure to omit them globally:
spring:
jackson:
default-property-inclusion: non_null
Or per-class:
@JsonInclude(JsonInclude.Include.NON_NULL)
public record OrderResponse(
UUID id,
String orderNumber,
String promoCode, // omitted from JSON if null
// ...
) {}
Or use Optional for nullable fields:
public record OrderResponse(
UUID id,
String orderNumber,
Optional<String> promoCode // serialized as null or value, never omitted
) {}
Field Renaming and Custom Serialization
public record OrderResponse(
@JsonProperty("order_id")
UUID id, // serialized as "order_id"
@JsonProperty("placed_at")
Instant createdAt, // serialized as "placed_at"
@JsonIgnore
String internalRef, // never serialized
@JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss'Z'")
Instant updatedAt // custom date format
) {}
API Versioning Strategies
When your API evolves, DTOs shield clients from internal changes:
Strategy 1: URL versioning (/api/v1/orders, /api/v2/orders)
@RestController
@RequestMapping("/api/v1/orders")
public class OrderControllerV1 {
@GetMapping("/{id}")
public OrderResponseV1 getOrder(@PathVariable UUID id) { ... }
}
@RestController
@RequestMapping("/api/v2/orders")
public class OrderControllerV2 {
@GetMapping("/{id}")
public OrderResponseV2 getOrder(@PathVariable UUID id) { ... }
}
Strategy 2: Header versioning (Accept: application/vnd.devopsmonk.v2+json)
@GetMapping(value = "/{id}", produces = "application/vnd.devopsmonk.v1+json")
public OrderResponseV1 getOrderV1(@PathVariable UUID id) { ... }
@GetMapping(value = "/{id}", produces = "application/vnd.devopsmonk.v2+json")
public OrderResponseV2 getOrderV2(@PathVariable UUID id) { ... }
URL versioning is simpler to implement and test. Header versioning is cleaner but harder to debug.
A Complete Request-Response Cycle
Here’s the full picture for a POST /api/orders:
Client sends JSON
↓
@RequestBody deserializes to CreateOrderRequest (DTO in)
↓
@Valid validates the request (Article 11)
↓
OrderService.create(request) — business logic
↓
OrderMapper.toEntity(request) — request DTO → entity
↓
OrderRepository.save(entity) — entity persisted
↓
OrderMapper.toResponse(entity) — entity → response DTO
↓
ResponseEntity.created(...).body(response) — wraps in HTTP response
↓
@ResponseBody serializes to JSON
↓
Client receives JSON
The entity never leaves the service layer.
What You’ve Learned
- Never return JPA entities from REST controllers — they cause serialization failures, data leaks, and tight coupling
- Use separate request DTOs (input) and response DTOs (output)
- Java records are ideal for DTOs — immutable, compact, no boilerplate
- Use static factory methods for simple mappings, dedicated mapper classes or MapStruct for complex ones
- Use separate DTOs for list views (summary) and detail views — different consumers need different shapes
- Configure Jackson to omit null fields; use
@JsonPropertyand@JsonIgnorefor field control
Next: Article 11 — Bean Validation — validating request data with @Valid, custom validators, and structured error responses.