Back to API Versioning & Content Negotiation
Q1Question 1 of 1

Why should request and response use separate DTOs, and where should entity↔DTO conversion live

Key Concepts
  • A request DTO includes only client-settable fields with validation annotations, deliberately omitting server-controlled fields like id, balance, and role.
  • A response DTO omits secrets like passwords and includes server-generated fields like id and createdAt.
  • Mass-assignment is prevented by excluding server-controlled fields from the request DTO, making the attack structurally impossible.
  • Entity-to-DTO conversion belongs in the service layer so it is reusable, testable, and the controller stays a thin adapter.
  • The controller should never see the entity: it speaks only DTOs, keeping the web layer ignorant of persistence.
  • MapStruct or manual mapping in the service centralises the conversion, preventing drift across controllers.

Definition

Input and output are different shapes with different rules, so use a validated request DTO and a secret-free response DTO, and do the entity mapping in the service layer, not the controller.
  • Request DTO – models what a client may send; carries validation annotations like @NotBlank, @Email, @Size; deliberately omits server-controlled fields like id, balance, and role to prevent mass-assignment.
  • Response DTO – models what a client may receive; omits secrets like passwords and includes server-generated fields like id and createdAt.
  • Mass-assignment – if the request DTO includes server-controlled fields, a client can set balance or role directly, which is a critical vulnerability.
  • Service-layer mapping – entity-to-DTO conversion belongs in the service layer so it is reusable, testable, and the controller stays a thin adapter.
  • Controller ignorance – the controller should never see the entity; it speaks only DTOs.

At National Bank of India, the form Rajesh fills to open an account is different from the receipt he gets back. The opening form asks for his name, email, and a chosen password, with boxes the clerk checks for validity. The receipt shows his new account number and name, but never reprints his password. Using the same slip for both would be absurd and unsafe. And the translation between the customer's slip and the bank's internal ledger is done by a back-office officer (the service layer), not by the front-desk clerk (the controller).

Understanding the Concepts

Why use separate request and response DTOs?

The data a client is allowed to send is not the same as the data it is allowed to receive. A request DTO carries input fields with validation, and deliberately omits server-controlled fields. A response DTO omits secrets and includes server-generated fields. If you use one shared DTO, a password field appears on the response (a leak) and server fields appear on the request (mass-assignment).

JAVA
package com.bank.dto;

import jakarta.validation.constraints.*;
import java.time.Instant;

// Request DTO: client-settable fields only, with validation.
public record CreateAccountRequest(
        @NotBlank String holderName,
        @Email String email,
        @NotBlank @Size(min = 8) String password,
        @NotNull java.math.BigDecimal initialDeposit) {}
// No id, no balance, no role, no createdAt.

// Response DTO: safe output fields only.
public record AccountResponse(
        String id,
        String holderName,
        String email,
        java.math.BigDecimal balance,
        Instant createdAt) {}
// No password.

What is mass-assignment and why is it dangerous?

Mass-assignment happens when the request DTO includes fields the client should not control, and the framework binds them directly to the entity. If the DTO has a balance field, a client can create an account with any balance. If it has a role field, a client can escalate to admin.

The fix is a request DTO that structurally excludes these fields, making the attack impossible rather than merely blocked.

JAVA
// Vulnerable: shared DTO with balance and role on the request.
public record AccountDto(
        String id,           // Client should not set this
        String holderName,
        String email,
        String password,     // Should not appear on response
        java.math.BigDecimal balance, // Client should not set this!
        String role) {}      // Client should not set this!

// Attack: POST /api/v1/accounts
// {"holderName": "Rajesh", "email": "r@nbi.in", "password": "...",
//  "balance": 10000000, "role": "ADMIN"}
// → Account created with 10 million balance and admin role!

// Fixed: request DTO has no balance or role field.
public record CreateAccountRequest(
        @NotBlank String holderName,
        @Email String email,
        @NotBlank String password,
        @NotNull java.math.BigDecimal initialDeposit) {}
// Attack impossible: balance and role fields do not exist to bind.

Why does the response DTO omit secrets?

A response DTO should never include passwords, tokens, or internal security fields. If a shared DTO returns the password hash on a GET, any caller can see it. Separate response DTOs close this by construction.

JAVA
// Wrong: shared DTO leaks password on GET response.
// GET /api/v1/accounts/SAV2001
// {"id": "SAV2001", "holderName": "Rajesh", "password": "$2a$10$..."}
// ← Password hash visible to anyone with read access!

// Correct: response DTO has no password field.
// GET /api/v1/accounts/SAV2001
// {"id": "SAV2001", "holderName": "Rajesh", "email": "r@nbi.in", "balance": 500000}

How does validation differ between request and response DTOs?

The request DTO needs validation annotations because input must be checked before business logic runs. The response DTO does not need validation because the server produced it and trusts its own output.

JAVA
// Request: validation annotations on every input field.
public record CreateAccountRequest(
        @NotBlank String holderName,
        @Email String email,
        @NotBlank @Size(min = 8, max = 64) String password,
        @NotNull @DecimalMin("500") java.math.BigDecimal initialDeposit) {}

// Response: no validation needed – server produced this data.
public record AccountResponse(
        String id,
        String holderName,
        String email,
        java.math.BigDecimal balance,
        java.time.Instant createdAt) {}

Where should entity-to-DTO conversion live?

The conversion belongs in the service layer. The controller should be a thin adapter that receives a request DTO, delegates to the service, and returns a response DTO. The controller should never see the entity.

Keeping mapping in the service means the logic and its data shaping live together, can be unit-tested without a web layer, and are reusable by non-HTTP callers like a message consumer or a batch job.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import java.math.BigDecimal;

@Service
public class AccountService {

    public AccountResponse create(CreateAccountRequest req) {
        Account entity = new Account(
                generateId(),
                req.holderName(),
                req.email(),
                hashPassword(req.password()),
                req.initialDeposit());
        accountRepo.save(entity);
        return toResponse(entity); // Mapping here, in the service.
    }

    private AccountResponse toResponse(Account a) {
        return new AccountResponse(
                a.getId(),
                a.getHolderName(),
                a.getEmail(),
                a.getBalance(),
                a.getCreatedAt());
    }
}

Why not do mapping in the controller?

If mapping lives in the controller, every entry point reimplements it, drifts apart over time, and a non-HTTP caller cannot reuse it. The nightly batch job, the message consumer, and the gRPC adapter all need the same mapping, and only the service layer is shared by all of them.

JAVA
// Wrong: mapping in the controller – duplicated, not reusable.
@PostMapping("/accounts")
public ResponseEntity<AccountResponse> create(@Valid @RequestBody CreateAccountRequest req) {
    Account entity = new Account(/* ... */);
    accountRepo.save(entity);
    AccountResponse resp = new AccountResponse(entity.getId(), /* ... */); // Mapping here!
    return ResponseEntity.created(URI.create("/api/v1/accounts/" + resp.id())).body(resp);
}

// Correct: controller is thin, service does everything.
@PostMapping("/accounts")
public ResponseEntity<AccountResponse> create(@Valid @RequestBody CreateAccountRequest req) {
    AccountResponse resp = accountService.create(req);
    return ResponseEntity.created(URI.create("/api/v1/accounts/" + resp.id())).body(resp);
}

How does MapStruct help with mapping?

For larger projects, a dedicated mapper like MapStruct generates the mapping code at compile time. The service layer invokes the mapper, keeping the mapping centralised and type-safe.

JAVA
package com.bank.mapper;

import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface AccountMapper {
    AccountResponse toResponse(Account entity);
    Account toEntity(CreateAccountRequest request);
}

// Service uses the mapper:
@Service
public class AccountService {
    private final AccountMapper mapper;
    // ...
    public AccountResponse create(CreateAccountRequest req) {
        Account entity = mapper.toEntity(req);
        entity.setId(generateId());
        entity.setPassword(hashPassword(req.password()));
        accountRepo.save(entity);
        return mapper.toResponse(entity);
    }
}

What are common mistakes with DTOs?

Using one shared DTO for both request and response. Putting mapping logic in the controller. Letting the controller return entities directly. Not validating input DTOs.

JAVA
// Wrong: controller returns the entity directly – leaks everything.
@GetMapping("/accounts/{id}")
public Account getAccount(@PathVariable String id) {
    return accountRepo.findById(id).orElseThrow(); // Entity exposed!
}
// Password hash, internal fields, all visible.

// Correct: controller returns a response DTO.
@GetMapping("/accounts/{id}")
public AccountResponse getAccount(@PathVariable String id) {
    return accountService.findById(id); // Returns safe DTO.
}

What should a senior engineer know about DTOs?

A senior engineer knows that the controller should never see the entity. It speaks only DTOs. This keeps the web layer ignorant of persistence, so you can swap the data store or add a second transport like gRPC and reuse the same service and mapping. The separation of request vs response DTOs plus service-layer mapping is a core part of clean layering that interviewers use to gauge whether you understand boundaries.

Visual Overview

DIAGRAM
┌──────────────────────────────────────────────────────────┐
│         Request/Response DTO Separation                    │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  Client sends:                                           │
│  ┌─────────────────────────────────┐                    │
│  │ CreateAccountRequest             │                    │
│  │   holderName: "Rajesh"           │                    │
│  │   email: "r@nbi.in"              │                    │
│  │   password: "securePass"         │ ◀─ validated       │
│  │   initialDeposit: 50000          │                    │
│  │   (no id, no balance, no role)   │ ◀─ safe from       │
│  └─────────────────────────────────┘    mass-assignment  │
│       │                                                  │
│       ▼                                                  │
│  Controller (thin adapter)                               │
│       │ passes request DTO to service                    │
│       ▼                                                  │
│  Service Layer:                                          │
│  ┌─────────────────────────────────┐                    │
│  │ 1. Map request DTO → Entity     │                    │
│  │ 2. Run business logic            │                    │
│  │ 3. Save entity                   │                    │
│  │ 4. Map entity → Response DTO     │                    │
│  └─────────────────────────────────┘                    │
│       │                                                  │
│       ▼                                                  │
│  Client receives:                                        │
│  ┌─────────────────────────────────┐                    │
│  │ AccountResponse                  │                    │
│  │   id: "SAV2001"                  │ ◀─ server-generated│
│  │   holderName: "Rajesh"           │                    │
│  │   email: "r@nbi.in"              │                    │
│  │   balance: 50000.00              │ ◀─ server-generated│
│  │   createdAt: "2026-03-15"        │ ◀─ server-generated│
│  │   (no password)                  │ ◀─ secret excluded │
│  └─────────────────────────────────┘                    │
└──────────────────────────────────────────────────────────┘

Key Takeaway

Request and response should use separate DTOs because the data a client may send is not the same as the data it may receive. A request DTO models input: it carries validation annotations and includes only client-settable fields. A response DTO models output: it omits secrets and includes server-generated fields. If you share one DTO, a password field leaks on the response and server-controlled fields on the request create a mass-assignment vulnerability.

That mass-assignment risk is concrete. At National Bank of India, the team first used one shared account DTO. Because it had a balance field on the create path, a tester created an account with ten million rupees just by putting it in the request body, since the shared DTO bound balance straight to the entity. A request DTO with no balance field makes the attack structurally impossible. The same shared DTO also had a password field, so the read endpoint returned the password hash, which a response DTO without password fixed.

The second part is where the conversion lives, and the answer is the service layer, never the controller. The controller should be a thin adapter that receives a request DTO, delegates to the service, and returns a response DTO. It should never even see the entity. The service maps the request DTO to an entity, runs the business logic, and maps the entity to a response DTO. This centralises the mapping, makes it unit-testable without a web context, and lets non-HTTP callers like a batch job reuse it.

The rule is: a validated request DTO with only client-settable fields, a response DTO with no secrets, and all entity mapping in the service layer. This keeps the web layer ignorant of persistence, closes mass-assignment and leak holes by construction, and lets you add a second transport like gRPC that reuses the same service.

Remember one sentence: separate request and response DTOs close mass-assignment and secret-leak holes by construction, and service-layer mapping keeps the controller thin and the logic reusable.

---

Share