Clean Spring Boot APIs: Separating Entities, DTOs, and Mappers

Clean Spring Boot APIs: Separating Entities, DTOs, and Mappers

Mixing your database domain models (entities) with your API transport models (DTOs) might seem convenient at first, but it often leads to headaches down the road. When a JPA entity is directly used in a controller or returned in a REST response, you risk exposing internal fields or needing lots of annotations to hide data. This tight coupling can also trigger lazy-loading issues (e.g. LazyInitializationException) if the database session is closed. In short, conflating entities and DTOs makes the code harder to maintain, trickier to test, and can even introduce security vulnerabilities. Let’s explore why separating these concerns is so important.

Entities vs. DTOs vs. Mappers: What’s the Difference?

In a layered Spring Boot application, each of these plays a distinct role. Separating them clearly improves the structure of your code:

  • Entity: A database model annotated with @Entity (JPA). It represents a table in your database, including relationships and persistence logic. Entities often have all fields needed for data storage (including internal or sensitive fields) and are managed by an ORM (Hibernate). Example: a User entity with ID, email, password hash, etc.
  • DTO (Data Transfer Object): A data carrier for your API, used to send or receive data through controllers. DTOs are simple containers with no business logic – just fields and accessors. They are shaped for API needs (maybe excluding sensitive info or flattening complex relations) to avoid exposing internal details. You might have separate request DTOs (for incoming data) and response DTOs (for output), each tailored to what the client needs to send or receive.
  • Mapper: A component that translates between DTOs and Entities. This can be manual (mapping fields in code) or using a library like MapStruct to generate the code. The mapper ensures the DTOs and entities remain decoupled – the rest of your app (service, controller) can call the mapper to convert data without entangling the layers.

By keeping these layers separate, you encapsulate your persistence details in entities, your API contract in DTOs, and the conversion logic in mappers. This separation of concerns makes the application easier to reason about.

Data Flow Through Layers

Data flow through a Spring Boot application’s layers: the Controller receives data as a Request DTO, converts it via a Mapper into an Entity for the database. On the flip side, Entities from the DB are converted to Response DTOs before returning to the client. This ensures the external API model is decoupled from the internal database model.

Article content

To make this concrete, consider a simple User domain. We define a JPA entity for the database, and separate DTO classes for the API requests and responses. The entity might have extra fields (like a password hash) that we don’t want to expose via the API. The DTOs will include only the necessary fields for their purpose (e.g. the request might include a raw password, while the response omits it).

User Entity (database model): This is a typical JPA entity with annotations:

Article content

User DTOs (API models): We create two DTOs – one for incoming data (e.g. user registration) and one for outgoing data. Notice that the request DTO contains a plain password (to be hashed internally) and no ID, while the response DTO contains an ID and omits the password:

Article content

These DTO classes are simple POJOs – no JPA annotations, no business methods. They exist solely to ferry data to/from the outside world. By using them in our controller, we ensure that our User entity’s internals (like passwordHash) never leak out, and the client only sends the data we expect (no extra fields that could be misused).

Mapping: Manual vs. MapStruct

Now, we need to convert between User and these DTOs. We have two main approaches:

1. Manual Mapping: Write conversion code by hand. This gives you full control and is straightforward for simple cases, but can become repetitive if you have many fields.

Article content

In the manual mapper above, we explicitly copy each field. This is easy to read and you can insert custom logic (for example, hashing a password before setting it). However, you must remember to update this code if fields change or new fields are added, which can be error-prone in large applications.

2. MapStruct (Automatic Mapping): Use the MapStruct library to generate mapping code at compile time. You define an interface with mapping methods, and MapStruct creates the implementation for you:

Article content

Here, the @Mapper annotation tells MapStruct to generate an implementation of this interface. We specify one mapping detail: the password from the DTO should map to passwordHash in the User (since the field names differ). MapStruct will automatically map the other fields with matching names (username, email, id). At runtime, you can inject this UserMapper (as a Spring bean) and call mapper.toEntity(dto) or mapper.toDto(user) – the library handles the heavy lifting of copying properties.

Both approaches achieve the same result: converting between DTO and entity. The choice often depends on your project’s needs.

When to Use Manual Mapping vs. MapStruct

Manual mapping and MapStruct each have their place, and you can even mix them in a project. Some considerations when choosing:

  • Project Size & Maintainability: For a small project with only a few DTO/entity pairs, manual mapping is simple and avoids adding extra dependencies. As the project grows, however, maintaining dozens of manual mappers becomes tedious. In larger projects, MapStruct shines by reducing boilerplate – you add a new field to your DTO and entity, and MapStruct’s generated code handles it (or fails to compile if something is unmapped, alerting you to fix it). This makes MapStruct easier to maintain as your codebase scales.
  • Performance: Both approaches are very efficient. Manual mapping is just plain Java code. MapStruct essentially generates the same kind of code you’d write by hand, just automatically. In fact, MapStruct’s compile-time approach yields performance comparable to manual code (and much faster than reflection-based mappers). There’s no significant runtime overhead, so performance is rarely a deciding factor between these two.
  • Complex Mapping Logic: If you have custom conversion logic – e.g. hashing passwords, combining multiple fields, or calling external services during mapping – manual mapping gives you ultimate control. MapStruct can handle a lot of this via expressions or calling custom methods, but for very complex scenarios you might choose manual implementation for clarity. In many cases, you can also use MapStruct and manually map only the tricky parts.
  • Team Preference and Consistency: Some teams prefer the explicitness of manual mappers, while others favor the concise declarations of MapStruct. What’s important is to stay consistent. It’s common to use MapStruct for most mappings and do a manual tweak for special cases. The key is to avoid repetitive, error-prone code if a tool can do it for you safely.

In summary, MapStruct is generally recommended for typical use-cases in mid-to-large applications (for its productivity and compile-time safety), whereas manual mapping is perfectly fine for small projects or special situations.

Benefits of Keeping DTOs and Entities Separate

Finally, what do we gain by all this separation? Quite a lot:

  • Decoupling & Maintainability: Your API layer is decoupled from your persistence layer. This means you can change the database schema or internals of your entities without immediately breaking external APIs, as long as the mapper adapts to those changes. The code is cleaner because each class has a single purpose (database, API, or conversion), improving overall maintainability.
  • Safer APIs: DTOs let you control exactly what data is exposed or accepted, avoiding accidental leaks of sensitive information. For example, our UserResponseDTO never carries a password hash, and the client can’t send an id to override something unintended. This principle of data hiding makes your API more secure by default. It also prevents clients from over-posting fields that should be set only on the server side.
  • Easier Testing: Testing becomes simpler when each layer is isolated. You can unit test your mapping logic in one place. Controllers can be tested using DTOs (which are simple to construct) without involving a real database. Likewise, you can test JPA entity behavior (constraints, relationships) in isolation. This separation of concerns leads to more focused and faster tests.
  • Versioning & Evolution: Over time, your API may need to evolve (new fields, deprecated fields, different representations for new clients). With DTOs, you can create new versions of request/response models without touching the underlying entities. For instance, you might have UserResponseDTOv2 with extra fields, while your User entity remains unchanged. This flexibility is crucial in maintaining backward compatibility on APIs. The mapper can handle translating the new DTO to the old entity or vice versa. Without DTOs, you’d be tightly locked to your entity structure in your API, making versioning difficult.

By separating concerns in this way, you get a cleaner, more modular application. Your controllers, services, and repositories each deal in the currency that makes sense for them (DTOs for the web layer, entities for the database layer), and mappers bridge the gap. The result is code that’s easier to reason about, less prone to bugs, and safer to expose to the world. In modern Spring Boot development, this pattern of using DTOs with entity mappers is considered a best practice for writing clean and maintainable APIs.

#Java #SpringBoot #DTO #MapStruct #CleanCode #BackendDevelopment

Thiago Nunes Monteiro

Senior Mobile Developer | Android Software Engineer | Jetpack Compose | GraphQL | Kotlin | Java | React Native | Swift

1w

Great article!

Julio César

Senior Software Engineer | Java | Spring Boot | React | Angular | AWS | APIs

1w

Excellent Post.

Luis A.

Senior Software Engineer specialized in Java, AWS and microservices architecture

1w

Sorry for the question here, does this applies to JavaScript world such as NestJs framework?

Leandro Veiga

Senior Software Engineer | Full Stack Developer | C# | .NET | .NET Core | React | Amazon Web Service (AWS)

1w

Very informative. Thanks for sharing.

João Paulo Ferreira Santos

Data Engineer | AWS | Azure | Databricks | Data Lake | Spark | SQL | Python | Qlik Sense | Power BI

1w

Great content!

To view or add a comment, sign in

More articles by Fabio Ribeiro

Insights from the community

Others also viewed

Explore topics