Quarkus Hands-On Tutorial: Effortless Data Transfer with Entities and DTOs
Quarkus might be known for its blazing speed, but that doesn't mean we should be reckless with our data models. As your API surface grows, exposing your JPA entities directly is like leaving your front door wide open. Convenient, but risky. This is where DTOs (Data Transfer Objects) step in like a well-trained bouncer, keeping your internal data safe, your payloads lean, and your clients happy.
In this tutorial, we’ll walk through how to implement DTOs in Quarkus, starting from JPA entities all the way to a polished REST API. We’ll manually map objects for full control, then bring in MapStruct for a taste of automation that would make even the Spring crowd nod in approval.
Why Use DTOs in Quarkus?
Before diving into code, let’s answer the "why".
DTOs help you:
Avoid accidentally exposing internal or sensitive fields (think passwords or audit metadata).
Shape your data differently for creation, updates, and views.
Isolate your persistence layer from API contracts which is crucial for maintainability and versioning.
Create a static, compile-time friendly structure that works well with GraalVM native compilation.
So no, DTOs aren’t just for Java EE nostalgia or bureaucratic overkill. They’re a practical way to keep your code clean, your APIs safe, and your future self sane.
Project Setup
Let’s bootstrap our Quarkus app. You can use either the CLI or code.quarkus.io:
quarkus create app org.acme:quarkus-dto-tutorial \
--extension="rest-jackson,hibernate-orm-panache,jdbc-h2,hibernate-validator,smallrye-openapi" \
--no-code
cd quarkus-dto-tutorial
In application.properties
, configure the H2 in-memory DB for local testing:
quarkus.datasource.db-kind=h2
quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
quarkus.datasource.username=sa
quarkus.datasource.password=sa
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
For MapStruct to work seamlessly, we need to add some more to the project pom.xml file. Unfortunately there is no Quarkus extension available.
Add the following to your pom.xml
.
<org.mapstruct.version>1.6.3</org.mapstruct.version>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
And add the annotation processor to your maven-compiler-plugin configuration:
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
Defining the Entity
Our Post
entity represents blog posts.
package org.acme.domain;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import java.time.LocalDateTime;
@Entity
public class Post extends PanacheEntity {
@Column(nullable = false)
public String title;
@Column(columnDefinition = "TEXT")
public String content;
public String authorEmail;
public LocalDateTime creationDate;
public LocalDateTime lastModifiedDate;
public Post() {}
public Post(String title, String content, String authorEmail) {
this.title = title;
this.content = content;
this.authorEmail = authorEmail;
this.creationDate = LocalDateTime.now();
this.lastModifiedDate = LocalDateTime.now();
}
}
Creating the DTOs
We’ll define three mapping DTOs:
PostDto.java
For API responses.
package org.acme.dto;
import java.time.LocalDateTime;
public class PostDto {
public Long id;
public String title;
public String content;
public String authorEmail;
public LocalDateTime creationDate;
public LocalDateTime lastModifiedDate;
}
CreatePostDto.java
Used when creating new posts.
package org.acme.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class CreatePostDto {
@NotBlank
@Size(max = 255)
public String title;
@NotBlank
public String content;
@Email
public String authorEmail;
}
UpdatePostDto.java
Used for full updates (POST).
package org.acme.dto;
import jakarta.validation.constraints.Size;
public class UpdatePostDto {
@Size(max = 255)
public String title;
public String content;
}
Mapping: Manual vs MapStruct
Let’s look at both worlds: Starting with good old manual coding.
ManualPostMapper.java
package org.acme.mapper;
import org.acme.domain.Post;
import org.acme.dto.*;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
public class ManualPostMapper {
public static PostDto toDto(Post post) {
PostDto dto = new PostDto();
dto.id = post.id;
dto.title = post.title;
dto.content = post.content;
dto.authorEmail = post.authorEmail;
dto.creationDate = post.creationDate;
dto.lastModifiedDate = post.lastModifiedDate;
return dto;
}
public static Post toEntity(CreatePostDto dto) {
return new Post(dto.title, dto.content, dto.authorEmail);
}
public static void updateEntity(UpdatePostDto dto, Post post) {
if (dto.title != null) post.title = dto.title;
if (dto.content != null) post.content = dto.content;
post.lastModifiedDate = LocalDateTime.now();
}
public static List<PostDto> toDtoList(List<Post> posts) {
return posts.stream().map(ManualPostMapper::toDto).collect(Collectors.toList());
}
}
Pros: No extra dependencies, full control.
Cons: Boilerplate, error-prone for complex objects, tedious to maintain.
Now let’s see what life looks like with MapStruct.
PostMapper.java (MapStruct)
MapStruct generates mapping code at compile time, ensuring type safety and performance.
package org.acme.mapper;
import org.acme.domain.Post;
import org.acme.dto.*;
import org.mapstruct.*;
import java.time.LocalDateTime;
import java.util.List;
@Mapper(componentModel = "cdi",
unmappedTargetPolicy = ReportingPolicy.IGNORE,
nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface PostMapper {
PostDto toDto(Post post);
@Mapping(target = "id", ignore = true)
@Mapping(target = "creationDate", expression = "java(java.time.LocalDateTime.now())")
@Mapping(target = "lastModifiedDate", expression = "java(java.time.LocalDateTime.now())")
Post toEntity(CreatePostDto dto);
List<PostDto> toDtoList(List<Post> posts);
@Mapping(target = "id", ignore = true)
@Mapping(target = "creationDate", ignore = true)
@Mapping(target = "authorEmail", ignore = true)
@Mapping(target = "lastModifiedDate", expression = "java(java.time.LocalDateTime.now())")
void updateEntityFromDto(UpdatePostDto dto, @MappingTarget Post post);
}
@Mapper(componentModel = "cdi")
: Makes MapStruct generate a CDI bean that can be injected.@Mapping
: Used for custom field mappings or ignoring fields.@MappingTarget
: Indicates the parameter that should be updated.@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
: Useful for PATCH-like updates where only provided fields in the DTO should modify the entity.
MapStruct handles boilerplate like a champ: Clean, type-safe, and fast.
Building the REST API
Let’s wire it all together in a REST resource.
package org.acme;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import org.acme.domain.Post;
import org.acme.dto.*;
import org.acme.mapper.PostMapper; // Using MapStruct
// import org.acme.mapper.ManualPostMapper; // Or using manual mapper
import java.util.List;
@Path("/posts")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class PostResource {
@Inject
PostMapper mapper;
@POST
@Transactional
public Response create(CreatePostDto dto) {
Post post = mapper.toEntity(dto);
post.persist();
return Response.status(Response.Status.CREATED).entity(mapper.toDto(post)).build();
}
@GET
public List<PostDto> list() {
return mapper.toDtoList(Post.listAll());
}
@GET
@Path("/{id}")
public Response get(@PathParam("id") Long id) {
Post post = Post.findById(id);
if (post == null) return Response.status(Response.Status.NOT_FOUND).build();
return Response.ok(mapper.toDto(post)).build();
}
@PUT
@Path("/{id}")
@Transactional
public Response update(@PathParam("id") Long id, UpdatePostDto dto) {
Post post = Post.findById(id);
if (post == null) return Response.status(Response.Status.NOT_FOUND).build();
mapper.updateEntityFromDto(dto, post);
return Response.ok(mapper.toDto(post)).build();
}
@DELETE
@Path("/{id}")
@Transactional
public Response delete(@PathParam("id") Long id) {
Post post = Post.findById(id);
if (post == null) return Response.status(Response.Status.NOT_FOUND).build();
post.delete();
return Response.noContent().build();
}
}
Testing It Out
Start Quarkus:
./mvnw quarkus:dev
Open your browser to http://localhost:8080/q/swagger-ui and start testing your endpoints.
Wrap-Up and Where to Go Next
You’ve just implemented a clean DTO architecture in Quarkus. You:
Created a JPA entity using Panache.
Built DTOs for different use cases.
Wrote both manual and MapStruct-based mappers.
Developed a REST API that uses DTOs exclusively.
Validated inputs and provided structured responses.
This design sets the foundation for real-world APIs that scale, evolve, and behave well under native compilation constraints.
I also have added some tests to the repository with the working source code in case you want to have a look.
Next steps?
Try adding pagination.
Explore partial updates via PATCH.
Add a service layer for business logic separation.
Use custom exception mappers for structured error responses (see: RFC 7807).