Skip to main content

Wait, You're Exposing Your Password?! Understanding Input vs Output DTOs in Spring Boot

Β· 14 min read
Mahmut Salman
Software Developer

I'll never forget the day I accidentally exposed user passwords in my API responses. I returned the entire User entity from a GET endpoint, thinking "Spring Boot will handle it!" Spoiler alert: It returned EVERYTHINGβ€”including hashed passwords, internal IDs, and timestamps. My code reviewer nearly had a heart attack. 😱 That's when I learned about DTOs. Let me save you from this nightmare.

The Security Disaster That Started It All​

Picture this: You're building a user profile endpoint. You write this innocent-looking code:

@GetMapping("/users/{id}")
public ResponseEntity<User> getUser(@PathVariable Long id) {
User user = userRepository.findById(id).orElseThrow();
return ResponseEntity.ok(user); // 😱 Danger!
}

What you expected:

{
"id": 1,
"username": "john_doe",
"email": "john@example.com"
}

What actually happened:

{
"id": 1,
"username": "john_doe",
"email": "john@example.com",
"password": "$2a$10$xyzABCabcABC...", // ❌ HASHED PASSWORD EXPOSED!
"createdAt": "2024-01-15T10:30:00",
"updatedAt": "2024-10-20T14:22:00",
"lastLoginIp": "192.168.1.100", // ❌ INTERNAL DATA!
"failedLoginAttempts": 0, // ❌ INTERNAL DATA!
"accountLocked": false, // ❌ INTERNAL DATA!
"internalNotes": "VIP customer" // ❌ INTERNAL DATA!
}

My code reviewer: "Are you trying to get us hacked?!" 😀

Me: "But... but... it's hashed!" πŸ€¦β€β™‚οΈ

Lesson learned: Never expose your entities directly. Use DTOs (Data Transfer Objects).


What Are DTOs and Why Do We Need Them?​

The Definition​

DTO (Data Transfer Object): A simple object that carries data between processesβ€”specifically between your backend and frontend/client.

Think of DTOs as translators between your internal data structures and the outside world.

The Real Purpose​

DTOs serve multiple critical purposes:

  1. Security - Hide sensitive internal data
  2. Decoupling - Separate internal structure from API contract
  3. Performance - Send only necessary data
  4. Versioning - Maintain API stability when entities change
  5. Validation - Control what data can come in

The Analogy πŸ β€‹

Imagine your database entities as your house interior:

  • You have personal items, bills, passwords written on sticky notes
  • Messy rooms, private spaces, internal wiring

Your API is like inviting guests over:

  • You don't show them everything
  • You present a clean, organized version
  • You control what they see and touch

DTOs are your "guest-friendly presentation" of your data.


The Two Types of DTOs​

Output DTO (Response DTO) πŸ“€β€‹

Purpose: Control what data leaves your API

Use Case: GET endpoints, responses

Example from your code:

public class CartDTO {
private Long id;
private List<CartItemDTO> items;
private Integer totalItems; // ← Calculated field
private BigDecimal totalPrice; // ← Calculated field

public CartDTO(Long id, List<CartItemDTO> items) {
this.id = id;
this.items = items;

// Calculate derived values
this.totalItems = items.stream()
.mapToInt(CartItemDTO::getQuantity)
.sum();

this.totalPrice = items.stream()
.map(CartItemDTO::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}

// Only getters - read-only response
public Long getId() { return id; }
public List<CartItemDTO> getItems() { return items; }
public Integer getTotalItems() { return totalItems; }
public BigDecimal getTotalPrice() { return totalPrice; }
}

What makes this an Output DTO:

  • βœ… Only getters (no setters needed)
  • βœ… Includes calculated fields (totalItems, totalPrice)
  • βœ… Designed for presentation
  • βœ… Client can't modify it (read-only)

Used in controller:

@GetMapping("/cart")
public ResponseEntity<CartDTO> getCart() {
Cart cart = cartService.getCurrentUserCart();
CartDTO cartDTO = convertToDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Sending response
}

Input DTO (Request DTO) πŸ“₯​

Purpose: Control what data enters your API

Use Case: POST, PUT, PATCH endpoints

Example:

public class AddToCartDTO {
@NotNull(message = "Product ID is required")
private Long productId;

@NotNull(message = "Quantity is required")
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Quantity cannot exceed 100")
private Integer quantity;

// Getters and setters - needs setters for input
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }

public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

What makes this an Input DTO:

  • βœ… Has setters (Spring needs them to populate from request)
  • βœ… Has validation annotations
  • βœ… Only includes fields client should provide
  • βœ… No calculated fields
  • βœ… No sensitive internal fields

Used in controller:

@PostMapping("/cart/items")
public ResponseEntity<CartDTO> addToCart(@Valid @RequestBody AddToCartDTO dto) {
Cart cart = cartService.addItem(dto.getProductId(), dto.getQuantity());
CartDTO cartDTO = convertToDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Input DTO received, Output DTO sent
}

Complete Example: E-Commerce Cart System​

Let's see Input and Output DTOs working together in a real system.

The Entity (Internal - Never Exposed)​

@Entity
@Table(name = "carts")
public class Cart {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne
private User user;

@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL)
private List<CartItem> items;

private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String sessionId; // ← Internal tracking
private String sourceChannel; // ← Internal tracking
private Boolean isAbandoned; // ← Internal flag
private String internalNotes; // ← Internal notes

// getters/setters
}

Problems if exposed directly:

  • ❌ Exposes user relationship (might reveal user details)
  • ❌ Exposes internal tracking fields
  • ❌ Exposes session IDs
  • ❌ Exposes internal flags and notes
  • ❌ Sends unnecessary data to client

Output DTO: CartDTO (Response)​

public class CartDTO {
private Long id;
private List<CartItemDTO> items;
private Integer totalItems;
private BigDecimal totalPrice;

public CartDTO(Long id, List<CartItemDTO> items) {
this.id = id;
this.items = items;

// Calculate aggregates
this.totalItems = items.stream()
.mapToInt(CartItemDTO::getQuantity)
.sum();

this.totalPrice = items.stream()
.map(CartItemDTO::getSubtotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}

// Only getters - read-only
public Long getId() { return id; }
public List<CartItemDTO> getItems() { return items; }
public Integer getTotalItems() { return totalItems; }
public BigDecimal getTotalPrice() { return totalPrice; }
}

Response to client:

{
"id": 1,
"items": [
{
"productId": 5,
"productName": "Laptop",
"quantity": 1,
"price": 1200.00,
"subtotal": 1200.00
}
],
"totalItems": 1,
"totalPrice": 1200.00
}

What we achieved:

  • βœ… Clean, minimal response
  • βœ… No internal fields exposed
  • βœ… Calculated values included
  • βœ… Frontend-friendly format

Input DTO: AddToCartDTO (Request)​

public class AddToCartDTO {
@NotNull(message = "Product ID is required")
private Long productId;

@NotNull(message = "Quantity is required")
@Min(value = 1, message = "Quantity must be at least 1")
@Max(value = 100, message = "Quantity cannot exceed 100")
private Integer quantity;

// Constructor
public AddToCartDTO() {}

// Getters and setters
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }

public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

Request from client:

{
"productId": 5,
"quantity": 1
}

What client CANNOT send:

  • ❌ id (server generates)
  • ❌ userId (from authentication)
  • ❌ totalPrice (server calculates)
  • ❌ Internal fields

What happens if they try?

{
"productId": 5,
"quantity": 1,
"totalPrice": 9999999, // ← Ignored
"isAbandoned": false, // ← Ignored
"hackAttempt": "malicious" // ← Ignored
}

Spring Boot only populates fields that exist in your DTO. Everything else is ignored! πŸ›‘οΈ


Input DTO: UpdateCartItemDTO (Request)​

public class UpdateCartItemDTO {
@NotNull(message = "Quantity is required")
@Min(value = 0, message = "Quantity cannot be negative")
@Max(value = 100, message = "Quantity cannot exceed 100")
private Integer quantity; // ← Only field that can be updated

// Constructor
public UpdateCartItemDTO() {}

// Getter and setter
public Integer getQuantity() { return quantity; }
public void setQuantity(Integer quantity) { this.quantity = quantity; }
}

Use case: User changes quantity of item already in cart

Request:

{
"quantity": 3
}

Endpoint:

@PatchMapping("/cart/items/{itemId}")
public ResponseEntity<CartDTO> updateCartItem(
@PathVariable Long itemId,
@Valid @RequestBody UpdateCartItemDTO dto) {

Cart cart = cartService.updateItemQuantity(itemId, dto.getQuantity());
CartDTO cartDTO = convertToDTO(cart);
return ResponseEntity.ok(cartDTO);
}

The Complete Controller Example​

@RestController
@RequestMapping("/api/cart")
public class CartController {

@Autowired
private CartService cartService;

// GET - Returns Output DTO
@GetMapping
public ResponseEntity<CartDTO> getCart() {
Cart cart = cartService.getCurrentUserCart();
CartDTO cartDTO = CartMapper.toDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Output DTO
}

// POST - Receives Input DTO, Returns Output DTO
@PostMapping("/items")
public ResponseEntity<CartDTO> addToCart(@Valid @RequestBody AddToCartDTO dto) {
Cart cart = cartService.addItem(dto.getProductId(), dto.getQuantity());
CartDTO cartDTO = CartMapper.toDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Input + Output DTOs
}

// PATCH - Receives Input DTO, Returns Output DTO
@PatchMapping("/items/{itemId}")
public ResponseEntity<CartDTO> updateCartItem(
@PathVariable Long itemId,
@Valid @RequestBody UpdateCartItemDTO dto) {

Cart cart = cartService.updateItemQuantity(itemId, dto.getQuantity());
CartDTO cartDTO = CartMapper.toDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Input + Output DTOs
}

// DELETE - Returns Output DTO
@DeleteMapping("/items/{itemId}")
public ResponseEntity<CartDTO> removeFromCart(@PathVariable Long itemId) {
Cart cart = cartService.removeItem(itemId);
CartDTO cartDTO = CartMapper.toDTO(cart);
return ResponseEntity.ok(cartDTO); // ← Output DTO
}
}

Notice the pattern:

  • Input DTOs control what comes IN
  • Output DTOs control what goes OUT
  • Entities never leave the service layer

Real-World Scenarios: When Input β‰  Output​

Scenario 1: User Registration​

Input DTO (RegisterDTO):

public class RegisterDTO {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 20)
private String username;

@Email(message = "Invalid email format")
@NotBlank(message = "Email is required")
private String email;

@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
private String password; // ← Client sends password

// getters/setters
}

Output DTO (UserDTO):

public class UserDTO {
private Long id;
private String username;
private String email;
private String role;
private LocalDateTime createdAt;
// ❌ NO PASSWORD FIELD!

// Only getters
}

Why different?

  • Input: Client sends password (needed for registration)
  • Output: Client NEVER receives password (security)

Endpoint:

@PostMapping("/register")
public ResponseEntity<UserDTO> register(@Valid @RequestBody RegisterDTO dto) {
User user = userService.registerUser(dto);
UserDTO userDTO = UserMapper.toDTO(user);
return ResponseEntity.ok(userDTO); // ← Password not in response
}

Scenario 2: Product Creation vs Product Display​

Input DTO (CreateProductDTO):

public class CreateProductDTO {
@NotBlank
private String name;

@NotNull
@DecimalMin(value = "0.01")
private BigDecimal price;

@NotNull
@Min(0)
private Integer stockQuantity;

private String description;

// No ID - server generates
// No timestamps - server generates
// No view count - starts at 0

// getters/setters
}

Output DTO (ProductDTO):

public class ProductDTO {
private Long id; // ← Added by server
private String name;
private BigDecimal price;
private Integer stockQuantity;
private String description;
private Integer viewCount; // ← Generated by server
private Double averageRating; // ← Calculated by server
private LocalDateTime createdAt; // ← Added by server
private Boolean inStock; // ← Calculated: stockQuantity > 0

// Only getters
}

Why different?

  • Input: Client provides basic product info
  • Output: Server adds IDs, calculations, timestamps, derived fields

Scenario 3: Partial Updates​

Input DTO (UpdateProfileDTO):

public class UpdateProfileDTO {
// All fields optional - partial update
@Size(min = 3, max = 20)
private String username; // ← Optional

@Email
private String email; // ← Optional

// Notice: No password field
// Password updates go through separate endpoint

// getters/setters
}

Output DTO (UserDTO):

public class UserDTO {
private Long id;
private String username;
private String email;
private String role;
private LocalDateTime createdAt;
private LocalDateTime lastUpdated; // ← Shows when profile was updated

// Only getters
}

Why different?

  • Input: Optional fields for partial updates
  • Output: Complete user profile with all current data

Common Patterns and Best Practices​

Pattern 1: Calculated Fields in Output DTOs​

Bad: Calculate in controller or frontend

// ❌ Bad - calculation in controller
@GetMapping("/cart")
public ResponseEntity<CartDTO> getCart() {
Cart cart = cartService.getCurrentUserCart();
CartDTO dto = new CartDTO();
dto.setId(cart.getId());
dto.setItems(cart.getItems());

// ❌ Calculating here is messy
int total = 0;
for (CartItem item : cart.getItems()) {
total += item.getQuantity();
}
dto.setTotalItems(total);

return ResponseEntity.ok(dto);
}

Good: Calculate in DTO constructor

// βœ… Good - calculation in DTO
public class CartDTO {
private Long id;
private List<CartItemDTO> items;
private Integer totalItems;

public CartDTO(Long id, List<CartItemDTO> items) {
this.id = id;
this.items = items;

// βœ… Clean calculation in DTO
this.totalItems = items.stream()
.mapToInt(CartItemDTO::getQuantity)
.sum();
}

// Only getters
}

Pattern 2: Validation in Input DTOs​

Always validate input DTOs:

public class CreateProductDTO {
@NotBlank(message = "Product name is required")
@Size(min = 3, max = 100, message = "Product name must be between 3 and 100 characters")
private String name;

@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be at least 0.01")
@DecimalMax(value = "999999.99", message = "Price cannot exceed 999,999.99")
private BigDecimal price;

@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock quantity cannot be negative")
@Max(value = 100000, message = "Stock quantity cannot exceed 100,000")
private Integer stockQuantity;

@Size(max = 1000, message = "Description cannot exceed 1000 characters")
private String description;

// getters/setters
}

Use in controller:

@PostMapping("/products")
public ResponseEntity<ProductDTO> createProduct(
@Valid @RequestBody CreateProductDTO dto) { // ← @Valid triggers validation

Product product = productService.create(dto);
ProductDTO productDTO = ProductMapper.toDTO(product);
return ResponseEntity.ok(productDTO);
}

Pattern 3: DTO Mapping with MapStruct​

Manual mapping gets tedious:

// ❌ Tedious manual mapping
public CartDTO toDTO(Cart cart) {
CartDTO dto = new CartDTO();
dto.setId(cart.getId());

List<CartItemDTO> itemDTOs = cart.getItems().stream()
.map(item -> {
CartItemDTO itemDTO = new CartItemDTO();
itemDTO.setId(item.getId());
itemDTO.setProductId(item.getProduct().getId());
itemDTO.setProductName(item.getProduct().getName());
itemDTO.setQuantity(item.getQuantity());
itemDTO.setPrice(item.getPrice());
return itemDTO;
})
.collect(Collectors.toList());

dto.setItems(itemDTOs);
return dto;
}

Better: Use MapStruct (library):

// βœ… Clean with MapStruct
@Mapper(componentModel = "spring")
public interface CartMapper {

@Mapping(target = "totalItems", expression = "java(calculateTotalItems(cart))")
@Mapping(target = "totalPrice", expression = "java(calculateTotalPrice(cart))")
CartDTO toDTO(Cart cart);

default Integer calculateTotalItems(Cart cart) {
return cart.getItems().stream()
.mapToInt(CartItem::getQuantity)
.sum();
}

default BigDecimal calculateTotalPrice(Cart cart) {
return cart.getItems().stream()
.map(item -> item.getPrice().multiply(new BigDecimal(item.getQuantity())))
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}

Pattern 4: Nested DTOs​

Bad: Exposing entire related entities

// ❌ Bad
public class CartItemDTO {
private Long id;
private Product product; // ← Entire product entity exposed!
private Integer quantity;
}

Good: Nested DTOs for related data

// βœ… Good
public class CartItemDTO {
private Long id;
private ProductSummaryDTO product; // ← Controlled product representation
private Integer quantity;
private BigDecimal subtotal;
}

public class ProductSummaryDTO {
private Long id;
private String name;
private BigDecimal price;
private String imageUrl;
// Only essential product info for cart display
}

When to Use Which DTO Type​

Use Input DTO When:​

  • βœ… Creating new resources (POST)
  • βœ… Updating existing resources (PUT/PATCH)
  • βœ… Accepting user input
  • βœ… You need validation
  • βœ… You want to restrict what fields can be modified

Use Output DTO When:​

  • βœ… Returning data to client (GET)
  • βœ… After successful create/update operations
  • βœ… You need calculated/derived fields
  • βœ… You want to hide internal data
  • βœ… You need to aggregate data from multiple sources

Use Both When:​

  • βœ… Create/Update endpoints (receive Input, return Output)
  • βœ… Complex business operations
  • βœ… Input and output shapes are different

Common Questions Answered​

Q: Can I use the same DTO for input and output?​

A: You can, but it's often not ideal:

// Shared DTO - not recommended for most cases
public class ProductDTO {
private Long id; // ← Output only (server generates)
private String name; // ← Both
private BigDecimal price; // ← Both
private Integer viewCount; // ← Output only (server tracks)

// Has both getters AND setters
}

Problems:

  • Client can try to set id (should be ignored)
  • Client can try to set viewCount (should be ignored)
  • Validation rules might differ (input vs output)
  • Confusing which fields are read-only

Better: Separate DTOs

// Input
public class CreateProductDTO {
private String name;
private BigDecimal price;
// setters needed
}

// Output
public class ProductDTO {
private Long id;
private String name;
private BigDecimal price;
private Integer viewCount;
// only getters needed
}

Q: Do I always need DTOs?​

A: Not always, but usually yes for production apps:

Skip DTOs when:

  • ❌ Internal microservice communication
  • ❌ Very simple CRUD with no sensitive data
  • ❌ Prototype/proof of concept

Use DTOs when:

  • βœ… Public-facing APIs
  • βœ… Sensitive data exists
  • βœ… You need calculated fields
  • βœ… Different input/output shapes
  • βœ… Production applications

Q: Where should DTO conversion happen?​

A: Controller layer or dedicated Mapper classes:

Good: Controller layer

@PostMapping("/products")
public ResponseEntity<ProductDTO> create(@Valid @RequestBody CreateProductDTO dto) {
Product product = productService.create(dto);
ProductDTO productDTO = ProductMapper.toDTO(product); // ← Convert here
return ResponseEntity.ok(productDTO);
}

Also good: Service layer returns DTOs

@PostMapping("/products")
public ResponseEntity<ProductDTO> create(@Valid @RequestBody CreateProductDTO dto) {
ProductDTO productDTO = productService.create(dto); // ← Service returns DTO
return ResponseEntity.ok(productDTO);
}

Bad: Mixing entities and DTOs in service

// ❌ Don't do this
public interface ProductService {
Product create(CreateProductDTO dto); // ← Input DTO, returns Entity (confusing)
}

The Complete Picture: DTOs in Action​

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CLIENT β”‚
β”‚ (React/Vue/Angular) β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
JSON Request Body
{
"productId": 5,
"quantity": 2
}
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CONTROLLER LAYER β”‚
β”‚ β”‚
β”‚ @PostMapping("/cart/items") β”‚
β”‚ addToCart(@Valid @RequestBody AddToCartDTO dto) { β”‚
β”‚ ↓ β”‚
β”‚ Input DTO validates β”‚
β”‚ ↓ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SERVICE LAYER β”‚
β”‚ β”‚
β”‚ Cart cart = cartService.addItem( β”‚
β”‚ dto.getProductId(), β”‚
β”‚ dto.getQuantity() β”‚
β”‚ ); β”‚
β”‚ ↓ β”‚
β”‚ Works with entities internally β”‚
β”‚ ↓ β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ REPOSITORY LAYER β”‚
β”‚ (Database Access) β”‚
β”‚ β”‚
β”‚ Saves/retrieves entities β”‚
β”‚ Cart, CartItem, Product entities β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ SERVICE LAYER β”‚
β”‚ β”‚
β”‚ Returns Cart entity to controller β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CONTROLLER LAYER β”‚
β”‚ β”‚
β”‚ CartDTO cartDTO = CartMapper.toDTO(cart); β”‚
β”‚ ↓ β”‚
β”‚ Convert entity to Output DTO β”‚
β”‚ ↓ β”‚
β”‚ return ResponseEntity.ok(cartDTO); β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
↓
JSON Response Body
{
"id": 1,
"items": [...],
"totalItems": 2,
"totalPrice": 2400.00
}
↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ CLIENT β”‚
β”‚ Receives clean data β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Summary: The DTO Lifecycle​

Input DTOs (Request)​

Purpose: Control what comes IN to your API

Characteristics:

  • βœ… Has setters (Spring needs them)
  • βœ… Has validation annotations
  • βœ… Only fields client should provide
  • βœ… No calculated fields
  • βœ… No IDs (server generates)

Use Cases:

  • POST (create)
  • PUT/PATCH (update)
  • Any endpoint accepting data

Output DTOs (Response)​

Purpose: Control what goes OUT from your API

Characteristics:

  • βœ… Only getters (read-only)
  • βœ… Includes calculated fields
  • βœ… Hides sensitive data
  • βœ… Can aggregate from multiple entities
  • βœ… Includes IDs and timestamps

Use Cases:

  • GET (read)
  • Response after POST/PUT/PATCH
  • Any endpoint returning data

Key Takeaways πŸŽ―β€‹

  1. Never expose entities directly - Always use DTOs
  2. Input DTOs validate - Output DTOs present
  3. Different shapes - Input β‰  Output in most cases
  4. Security first - DTOs prevent data leaks
  5. Calculated fields - Put them in Output DTOs
  6. Validation - Always validate Input DTOs
  7. Separation of concerns - Entities stay internal, DTOs face the world

What's Next?​

Now that you understand DTOs, explore:

  • MapStruct for automatic DTO mapping
  • DTO validation with custom validators
  • Pagination with DTOs
  • API versioning with different DTOs
  • GraphQL (where you define exactly what fields to return)

Questions about DTOs? Drop them in the comments! πŸ‘‡

Found this helpful? Share it with developers confused about Input vs Output DTOs! πŸš€