Definition
- DTO (Data Transfer Object) – a simple object with fields and accessors but no business logic, designed to carry data across a boundary such as the API layer.
- Decoupling – the DTO defines the public contract independently of the JPA entity and database schema, so internal changes do not break clients.
- Security – a response DTO includes only safe fields, preventing accidental exposure of sensitive columns like
passwordHashorinternalRiskScore. - Data shaping – a DTO can flatten multiple entities, include computed values, and trim unnecessary fields for smaller payloads.
- API evolution – separate DTOs let you version the API (v1, v2) without touching the entity or the database.
At National Bank of India, the internal account ledger contains Rajesh's password hash, internal risk score, and audit notes. When Rajesh checks his balance on the app, the bank does not photocopy the entire ledger page. Instead, a clerk fills a clean summary slip with only what Rajesh should see: his name, account number, and balance. That summary slip is a DTO. It carries just the right data, hides confidential internals, and can be redesigned without touching the master ledger.
Understanding the Concepts
What is a DTO?
A DTO, or Data Transfer Object, is a plain Java object whose only responsibility is to carry data across a boundary. It has fields, accessors, and possibly validation annotations, but no business logic. In modern Java, records are the natural choice for DTOs because they are immutable and concise.
package com.bank.dto;
import java.math.BigDecimal;
public record AccountResponse(
String accountId,
String holderName,
BigDecimal balance
) {}
This record carries exactly three fields. It cannot be mutated after construction, serializes cleanly to JSON, and auto-generates equals, hashCode, and toString.
Why do DTOs exist? Why not expose the entity directly?
The entity is a JPA-managed class mapped to a database table. It reflects the schema, carries sensitive columns, and is tracked by the persistence context. Exposing it directly causes five distinct problems that DTOs solve: coupling, security leaks, wrong data shape, performance issues, and blocked API evolution.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.List;
@Entity
@Table(name = "accounts")
public class Account {
@Id private String id;
private String holderName;
private String passwordHash; // NEVER sent to clients
private BigDecimal balance;
private int internalRiskScore; // internal only
@OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
private List<Transaction> transactions;
}
How does a DTO provide decoupling?
The entity reflects the database schema, which changes for internal reasons like adding an index column or splitting a table. If you expose the entity directly, every schema change risks breaking clients. A DTO is a stable contract: the entity can change while the DTO stays the same, and a mapping layer bridges them.
package com.bank.service;
import com.bank.dto.AccountResponse;
import com.bank.model.Account;
public class AccountMapper {
public AccountResponse toResponse(Account entity) {
return new AccountResponse(
entity.getId(),
entity.getHolderName(),
entity.getBalance()
);
// passwordHash and internalRiskScore are simply never copied
}
}
When the team renames the id column to account_number in the database, the DTO field accountId stays the same and no client breaks.
How does a DTO protect security?
Entities often carry sensitive fields like a password hash, an internal risk score, or audit metadata. Serializing the entity directly leaks these to the client. A response DTO includes only the fields a client may see, so secrets never cross the boundary by accident. The default is private: new entity columns are hidden unless explicitly mapped into the DTO.
// Wrong – returning the entity leaks passwordHash and internalRiskScore:
@GetMapping("/accounts/{id}")
public Account getAccount(@PathVariable String id) {
return repository.findById(id).orElseThrow(); // LEAKS secrets
}
// Correct – returning a DTO exposes only safe fields:
@GetMapping("/accounts/{id}")
public AccountResponse getAccount(@PathVariable String id) {
Account entity = repository.findById(id).orElseThrow();
return mapper.toResponse(entity); // only safe fields
}
How does a DTO shape data for the client?
A client screen may need a combination of fields from several entities, or computed values like an available balance. A DTO can flatten multiple entities into one object and include computed fields.
package com.bank.dto;
import java.math.BigDecimal;
public record AccountSummary(
String accountId,
String holderName,
BigDecimal balance,
BigDecimal pendingHolds,
BigDecimal availableBalance // computed: balance - pendingHolds
) {
public AccountSummary(String accountId, String holderName,
BigDecimal balance, BigDecimal pendingHolds) {
this(accountId, holderName, balance, pendingHolds,
balance.subtract(pendingHolds));
}
}
How does a DTO improve performance?
Entities may have large graphs of related data. Exposing them can trigger lazy loading of relationships the client does not need, or serialize huge payloads. A DTO carries only the required fields, reducing payload size and avoiding LazyInitializationException.
// Entity has a lazy transactions list:
@OneToMany(fetch = FetchType.LAZY)
private List<Transaction> transactions;
// If the serialiser touches transactions outside a transaction → LazyInitializationException
// DTO avoids this by never referencing the lazy field:
public AccountResponse toResponse(Account a) {
return new AccountResponse(a.getId(), a.getHolderName(), a.getBalance());
}
How do DTOs enable API evolution?
With separate DTOs you can version the API independently of the entity. You create AccountResponseV1 and AccountResponseV2 mapping from the same entity, evolving the API on its own schedule.
// Version 1 – original contract:
public record AccountResponseV1(String accountId, BigDecimal balance) {}
// Version 2 – adds holder name and available balance:
public record AccountResponseV2(
String accountId,
String holderName,
BigDecimal balance,
BigDecimal availableBalance
) {}
// Both map from the same entity – v1 and v2 coexist:
@GetMapping("/api/v1/accounts/{id}")
public AccountResponseV1 getV1(@PathVariable String id) { ... }
@GetMapping("/api/v2/accounts/{id}")
public AccountResponseV2 getV2(@PathVariable String id) { ... }
Why separate request and response DTOs?
What a client may send differs from what it may receive. A CreateAccountRequest carries a password and validation annotations, while an AccountResponse never carries the password. Keeping them separate prevents mass-assignment vulnerabilities.
// Request DTO – client sends this:
public record CreateAccountRequest(
@NotBlank String holderName,
@NotBlank String password,
@DecimalMin("1000.00") BigDecimal initialDeposit
) {}
// Response DTO – client receives this (no password, no isAdmin):
public record AccountResponse(
String accountId,
String holderName,
BigDecimal balance
) {}
What are common mistakes with DTOs?
The most common mistake is not having DTOs at all and returning the entity directly. The second mistake is having one DTO serve both request and response, which creates mass-assignment risks. The third is placing the mapping in the controller instead of the service layer.
// Wrong – mapping in the controller, entity leaks above the service:
@PostMapping("/accounts")
public AccountResponse create(@RequestBody CreateAccountRequest req) {
Account entity = new Account();
entity.setHolderName(req.holderName());
entity.setBalance(req.initialDeposit());
repository.save(entity);
return new AccountResponse(entity.getId(), entity.getHolderName(), entity.getBalance());
}
// Correct – service owns the mapping:
@PostMapping("/accounts")
public AccountResponse create(@Valid @RequestBody CreateAccountRequest req) {
return accountService.createAccount(req); // service maps internally
}
What should a senior engineer know?
A senior engineer keeps the entity-to-DTO conversion in the service layer so the controller is a thin adapter that only speaks DTOs. They use tools like MapStruct to generate mapping code at compile time, avoiding hand-written copy errors. They know that Jackson supports records natively since version 2.12, so record AccountResponse(...) serializes to JSON without additional configuration. They also understand that in a banking API, the DTO boundary is where security decisions live: every field in the response DTO is an explicit choice to expose data, and every field absent from the request DTO is an explicit choice to block client input.
Visual Overview
Client Request API Boundary Internal
───────────── ────────────── ────────
CreateAccountRequest ───→ Controller ───→ Service ───→ Account (Entity)
{ holderName, (thin) (mapping) { id, holderName,
password, passwordHash,
initialDeposit } balance,
internalRiskScore,
transactions[] }
AccountResponse ←─── Controller ←─── Service ←─── Account (Entity)
{ accountId, (thin) (mapping)
holderName,
balance }
┌────────────────────────────────────────────────────────────┐
│ DTO boundary: only safe, shaped fields cross this line │
└────────────────────────────────────────────────────────────┘
Key Takeaway
A DTO is a plain object whose only job is to carry data across a boundary, usually between the service layer and the API. It holds fields and accessors but no business logic, and it defines the exact shape of what a client sends or receives. The five reasons to always use DTOs instead of exposing JPA entities are decoupling, security, data shaping, performance, and API evolution.
Decoupling means the external contract is independent of the internal database schema. At National Bank of India, when the team added an internal column to the accounts table, clients that had been parsing the entity shape broke, whereas a DTO would have kept the contract stable through that schema change. Security means secrets never leak: a penetration test found the API was returning the password hash and fraud risk score because the serializer dumped every entity field, and a response DTO fixed that by copying only safe fields.
Data shaping and performance go together. The account summary screen needed the holder name, the balance, and a computed available balance, and a DTO let us return exactly those three fields including the computed one, in a small payload, without pulling the full entity graph or hitting lazy-loading problems. API evolution means you can add a version-two response DTO with new fields or deprecate a field on the API's own schedule, without touching the entity or the database.
The practical discipline is to keep request and response DTOs separate and to put the mapping in the service layer. Separate DTOs matter because what a client may send differs from what it may receive, and because a request DTO without an isAdmin field blocks mass-assignment. Putting the entity-to-DTO conversion in the service layer keeps the controller a thin adapter that only speaks DTOs.
Remember one sentence: a DTO is the firewall between your database and your clients, exposing only what you choose and hiding everything else.
---