When I started building my Student CRUD API using Spring Boot, my main focus was getting the basic operations working.

Creating a student, fetching students, updating student details, and deleting a student. I had already organized my application using the Controller → Service → Repository architecture.

But there was something I hadn't thought much about:

Should I use my JPA entity directly as the request and response object of my REST API?

Initially, I did exactly that.

It worked. I could send JSON from Postman, receive data from the API, and perform database operations without any problem. However, as I started improving the project, I realized that my database entity and my API's data structure shouldn't necessarily be the same thing.

That's when I introduced DTOs.

In this article, I'll explain what DTOs are, why I decided to use them, and how I implemented them in my Student CRUD API.

1. How I Initially Handled API Requests

Let's start with the approach I used before introducing DTOs. I had a Student entity that represented the students table in my database.

A simplified version looked something like this:

@Entity @Table(name = "students") public class Student { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String email; private Integer age; // Constructors, getters and setters } 

This class served two purposes:

  1. It represented the database entity.
  2. It was also used to receive request data and return API responses.

For example, my controller could accept a Student directly:

@PostMapping public ResponseEntity<Student> create( @RequestBody Student student) { Student savedStudent = studentService.create(student); return ResponseEntity .status(HttpStatus.CREATED) .body(savedStudent); } 

The client could send:

{ "name": "Nishant", "email": "nishant@example.com", "age": 21 } 

Spring Boot would use Jackson to deserialize the JSON into a Student object.

The service would save that object using the repository, and the saved entity would be returned to the client.

Simple, right?

And technically, there's nothing inherently wrong with making a small application this way. But as an application grows, this approach can create problems.

Let's understand why.

2. Problems With Exposing JPA Entities Directly

Using the same class for database persistence and API communication creates a strong connection between two different responsibilities.

Your entity represents how data is stored.

Using JPA Entities directly makes your DB tightly coupled with HTTP, creating vulnerabilities.

Your API request and response represent how clients communicate with your application. These responsibilities may overlap, but they aren't necessarily identical.

Here are some problems I started considering.

Problem 1: Exposing Internal Database Structure

Imagine my Student entity eventually contains additional fields:

@Entity public class Student { private Long id; private String name; private String email; private Integer age; private String internalNotes; private LocalDateTime createdAt; } 

Now imagine returning this entity directly from an API.

Depending on the serialization configuration, all these fields could become part of the JSON response. But should a client receive internalNotes?

Should it know about every field stored in my database?

Probably not.

An API should expose only the information that clients are supposed to access. This is one reason to separate the entity from the response object.

Problem 2: Clients Can Send Fields They Shouldn't Control

Consider this request:

{ "id": 100, "name": "Nishant", "email": "nishant@example.com", "age": 21 } 

If I accept the Student entity directly, the client can attempt to send an ID along with the other fields.

Depending on the entity mapping, setters, and application logic, this could create unintended behavior.

For example, I generally want the database to generate the student's ID.

The client shouldn't decide which database ID a newly created student receives.

This is also related to a broader security concern called mass assignment: allowing incoming data to bind to fields that the client shouldn't be allowed to control.

Using a dedicated request DTO helps restrict the accepted fields, provided I explicitly map only the permitted values.

Problem 3: Request and Response Structures Can Be Different

Consider creating a student.

The client needs to send:

{ "name": "Asha", "email": "asha@example.com", "age": 21 } 

But the API should return:

{ "id": 1, "name": "Asha", "email": "asha@example.com", "age": 21 } 

Notice the difference.

The request doesn't need an ID, but the response does.

If I use the same entity for both operations, I am making one Java class represent two different API contracts.

This becomes harder to manage when the request and response requirements start changing independently.

Problem 4: Validation Becomes Tied to the Entity

Different operations can have different requirements.

For example:

  • Creating a student requires a name, email, and age.
  • Updating a student requires all three fields.

Partially updating a student allows only some fields to be provided.

If I put all validation rules directly on the entity, it becomes difficult to express these different requirements cleanly.

I wanted each operation to have its own input structure and validation rules.

And that's where DTOs became useful.

3. What Is a DTO?

DTO stands for Data Transfer Object.

A DTO is an object used to transfer data between different parts of an application or across application boundaries.

In a Spring Boot REST API, DTOs are commonly used to define the data accepted from clients and the data returned to them.

The important idea is:

A DTO defines what data should be transferred, while a JPA entity represents how data is persisted.

For example:

Client | | JSON Request v CreateStudentRequest (DTO) | v Student Entity | v StudentRepository | v Database 

When returning data:

Database | v Student Entity | v StudentResponse (DTO) | v Client 

The entity remains responsible for persistence, while DTOs define the API's input and output structures.

This separation gives us more control over the data entering and leaving our application.

4. Request DTO vs Response DTO

Before implementing DTOs, it's important to understand that request DTOs and response DTOs serve different purposes.

Request DTO
A request DTO represents the data that the client is allowed to send to the application.

For example:

public record CreateStudentRequest( String name, String email, Integer age ) {} 

It doesn't contain the database-generated ID because the client doesn't need to provide it when creating a student.

Response DTO
A response DTO represents the data that the application chooses to return to the client.

For example:

public record StudentResponse( Long id, String name, String email, Integer age ) {} 

This includes the ID because the client may need it to retrieve, update, or delete the student later.
Difference between Request DTO and Response DTO
Instead of forcing one entity to serve every purpose, we can create DTOs according to the requirements of each operation.

5. Implementing DTOs in My Student CRUD API

Now let's look at how I implemented this in my project.

I created a separate dto package.
My structure looks like this:

com.example.crud │ ├── controller │ └── StudentController.java │ ├── services │ └── StudentService.java │ ├── repository │ └── StudentRepository.java │ ├── entity │ └── Student.java │ └── dto ├── CreateStudentRequest.java ├── UpdateStudentRequest.java ├── PatchStudentRequest.java └── StudentResponse.java 

I created three request DTOs and one response DTO.

Why three request DTOs?
Because creating, fully updating, and partially updating a student are three different operations with different input requirements.

A. CreateStudentRequest

When creating a student, the client must provide a name, email, and age.

Here's my implementation:

package com.example.crud.dto; import jakarta.validation.constraints.*; public record CreateStudentRequest( @NotBlank(message = "Name is required") @Size(max = 100, message = "Name must be within 100 characters") String name, @NotBlank(message = "Email is required") @Email(message = "Email must be valid email") @Size(max = 100, message = "Email must be within 100 characters") String email, @Min(value = 1, message = "Age must be at least 1") @Max(value = 120, message = "Age must be at most 120") Integer age ) {} 

A few things worth noticing:

  • @NotBlank ensures that the name and email aren't null, empty, or whitespace-only.
  • @Size restricts the maximum length.
  • @Email checks whether the email follows the expected email format.
  • @Min and @Max define the allowed age range.

These are Jakarta Bean Validation constraints. I'll cover validation in detail in a separate article.

Notice that I haven't included the id field. The database is responsible for generating it.

B. UpdateStudentRequest

A full update is different from a partial update.

When using PUT, I expect the client to provide all the fields required for the update.

Here's my DTO:

package com.example.crud.dto; import jakarta.validation.constraints.*; public record UpdateStudentRequest( @NotBlank(message = "Name is required") @Size(max = 100, message = "Name must be within 100 characters") String name, @NotBlank(message = "Email is required") @Email(message = "Email must be a valid email") @Size(max = 100, message = "Email must be within 100 characters") String email, @NotNull(message = "Age is required") @Min(value = 1, message = "Age must be at least 1") @Max(value = 120, message = "Age must be at most 120") Integer age ) {} 

Here, @NotNull is important for age.

Why?
Because Integer is a wrapper class and can hold null.

For a full update, I want the age to be provided rather than silently accepting a missing value.

C. PatchStudentRequest

Now comes the interesting part.

PATCH is used for partial updates. Suppose a student wants to change only their age.

The client can send:

{ "age": 25 } 

It shouldn't be necessary to send the name and email again.

That's why I created a separate DTO:

package com.example.crud.dto; import jakarta.validation.constraints.*; public record PatchStudentRequest( @Pattern(regexp = ".*\\S.*", message = "name must not be blank") @Size(max = 100, message = "name must be at most 100 characters") String name, @Pattern(regexp = ".*\\S.*", message = "email must not be blank") @Email(message = "email must be a valid email address") @Size(max = 100, message = "email must be at most 100 characters") String email, @Min(value = 1, message = "age must be at least 1") @Max(value = 120, message = "age must be at most 120") Integer age ) {} 

Here, I don't use @NotBlank because the fields are optional.

If the client doesn't provide a field, its value will be null. But if the client does provide a name or email, I don't want it to contain only whitespace.

That's why I used @Pattern for those fields.

My service checks which values are present and updates only those fields.

One limitation of this implementation is worth mentioning: with ordinary nullable fields, null means "not provided". So this approach cannot distinguish an omitted field from an explicitly supplied null.

For my current Student API, that behavior is acceptable.

D. StudentResponse

Finally, I needed a DTO to control what the API returns.

Here's my implementation:

package com.example.crud.dto; import com.example.crud.entity.Student; public record StudentResponse( Long id, String name, String email, Integer age ) { public static StudentResponse from(Student student) { return new StudentResponse( student.getId(), student.getName(), student.getEmail(), student.getAge() ); } } 

Something interesting here is the static from() method.

Instead of manually mapping the entity to a response DTO everywhere, I created one reusable conversion method.

For example:

StudentResponse.from(student); 

It takes a Student entity and creates a StudentResponse object from it.

This keeps the conversion logic in one place and makes the service methods cleaner.

6. How Everything Works Together

Now let's connect all these pieces. The architecture hasn't changed.

I still use:

Controller → Service → Repository

What changed is how data moves through these layers.

Before DTOs
Initially, the entity was used directly for API communication.

Client-to-Database Student Data Flow Before DTO

The entity was doing two jobs:

  1. Representing the database structure.
  2. Representing the API request and response structure.

After DTOs
After introducing DTOs, the flow became:

Client-to-Database Student Data Flow After DTO

Now the responsibilities are clearer.

  • The request DTO defines what the client can send.
  • The service converts the request DTO into an entity.
  • The repository handles persistence.
  • The response DTO defines what the client receives.

The Controller → Service → Repository architecture remains exactly the same.

DTOs improve the data contract between the client and the application. They don't replace the existing layers.

7. A Real Example: Creating a Student

Let's follow a complete create operation.

The client sends:

POST /api/students Content-Type: application/json 

Request body:

{ "name": "Asha", "email": "asha@example.com", "age": 21 } 

Step 1: Receive the Request

The controller receives a CreateStudentRequest.

Conceptually, the method looks like this:

@PostMapping public ResponseEntity<StudentResponse> create( @Valid @RequestBody CreateStudentRequest request) { StudentResponse response = studentService.create(request); return ResponseEntity .status(HttpStatus.CREATED) .body(response); } 

@Valid triggers validation of the request DTO when the appropriate validation dependency is configured.

I'll explain this in more detail in My Next Article.

Step 2: Convert the DTO into an Entity

The service receives the request DTO.

@Transactional public StudentResponse create(CreateStudentRequest request) { Student student = new Student( request.name(), request.email(), request.age() ); Student saved = repo.save(student); return StudentResponse.from(saved); } 

Notice that the service creates a new Student entity.

The repository doesn't need to know anything about CreateStudentRequest.It only works with the entity.

Step 3: Save the Entity

The repository saves the student in the database.

repo.save(student); 

The saved entity contains the generated database ID.

Step 4: Convert the Entity into a Response DTO

The service converts the saved entity:

StudentResponse.from(saved); 

The result is a StudentResponse object.

Step 5: Return the Response

The client receives:

{ "id": 1, "name": "Asha", "email": "asha@example.com", "age": 21 } 

The client gets exactly the information the API intends to expose.

It doesn't need to know how the entity is structured internally.

8. What Changed After Introducing DTOs?

Here's a quick comparison.

Before DTOs vs After DTOs comparison

DTOs also make it easier to change the API without necessarily changing the database entity.

For example, if I later want to return a student's full name instead of separate first and last names, I can change the response DTO and its mapping without necessarily changing the database structure.

That's the flexibility I wanted.

9. What I Learned

Before implementing DTOs, I was mainly thinking about making my REST endpoints work.

But this implementation helped me understand something important:

Just because two objects contain similar data doesn't mean they should have the same responsibility.

A JPA entity and a DTO might contain the same fields, but they serve different purposes.

  • An entity represents persisted data.
  • A request DTO represents accepted input.
  • A response DTO represents exposed output.

I also learned that separating these responsibilities gives me more control over my API as it grows.

Of course, DTOs introduce some additional classes and mapping code. For a very small application, this can feel like extra work. But when an application has multiple endpoints, different request requirements, and changing API contracts, that separation becomes valuable.

I'm still improving my Student CRUD API, and this was one of the steps that helped me understand how to structure a Spring Boot application more thoughtfully.

There is still a lot to explore, and that's exactly what I'm documenting here.

What's Next?

DTOs define the structure of incoming data, but they don't automatically guarantee that the data is valid.

For example, what happens if someone sends an empty name, an invalid email, or an age of -5?

That's where Bean Validation comes in.

I'll cover that in my next article:
How I Added Input Validation to My Spring Boot REST API.

Let's Connect! 👋

I'm sharing my learning journey as I build projects and improve my backend development skills.

If you're also learning Spring Boot, follow me here on DEV.to. Let's learn and grow together!