Stop Manual Reflection for MapStruct in Quarkus
Use the Quarkiverse extension to fix native and hot reload problems.
A follow-up to:
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, y…
In Part 1 of this series, I built a clean DTO architecture for a Quarkus blog API. The idea was straightforward: rather than exposing your JPA entities directly over HTTP, you define separate Data Transfer Objects for each use case. A PostDto for responses, a CreatePostDto for inbound creation requests, and an UpdatePostDto for edits. This keeps sensitive or internal fields off the wire, gives you independent control over your API contract, and plays nicely with GraalVM’s static analysis at native compile time.
I then introduced MapStruct to eliminate the mapping boilerplate. Instead of writing dto.title = post.title; dto.content = post.content; ... by hand, we declared a simple interface:
@Mapper(componentModel = "cdi")
public interface PostMapper {
PostDto toDto(Post post);
Post toEntity(CreatePostDto dto);
void updateEntityFromDto(UpdatePostDto dto, @MappingTarget Post post);
}MapStruct reads that interface at compile time and generates a concrete implementation class. The result was type-safe, fast, and required almost no maintenance as the domain model evolved.
The setup worked. But Part 1 included this honest caveat in the project setup section:
“For MapStruct to work seamlessly, we need to add some more to the project pom.xml file. Unfortunately there is no Quarkus extension available.”
That’s no longer true. There is now a Quarkiverse extension for MapStruct, and it quietly solves two problems that the manual setup leaves open. This article walks through what those problems are, what the extension does about them, and exactly what you need to change in your project to take advantage of it.
The two problems the extension solves
Before touching any code, it’s worth understanding why the extension exists. The manual setup from Part 1 is functionally correct in JVM mode. Your tests pass, your dev environment runs, everything looks fine. The surprises appear later, in two specific situations.
Problem 1: Native image builds failing silently
When you build a Quarkus native binary with -Dnative, GraalVM performs static analysis to determine which classes your application actually needs at runtime. Reflection is the tricky part. GraalVM can’t automatically discover classes that are loaded dynamically, it needs to be told about them upfront via a reflection configuration.
MapStruct’s generated mapper implementations are loaded this way. When you call Mappers.getMapper(PostMapper.class), MapStruct uses reflection to instantiate the generated PostMapperImpl class at startup. GraalVM’s static analysis doesn’t always trace that call back to PostMapperImpl, so the class can be absent from the native binary. You get a ClassNotFoundException. That is not at compile time where you’d catch it immediately, but at runtime when a real request hits your deployed container.
The workaround in Part 1 would be to add @RegisterForReflection to every mapper interface, or maintain a reflect-config.json file by hand. Neither is terrible, but both are easy to forget when you add a new mapper six months later.
Problem 2: Stale mappers in dev mode
quarkus:dev is one of Quarkus’s best features. It watches your source files and hot-reloads changes without restarting the JVM. The catch is that MapStruct’s generated implementation is a separate compile artifact. When you add a field to PostDto, Quarkus sees the DTO change and reloads it, but the PostMapperImpl that was generated before the field existed is still in memory. The mapper quietly ignores the new field until you do a full mvn compile.
In a fast development cycle, this is the kind of subtle inconsistency that makes you question your own code for ten minutes before you realise the mapper just needs a rebuild.
What the extension actually does
The quarkus-mapstruct extension hooks into two phases of the Quarkus build lifecycle.
At native compile time, it scans for every class annotated with @Mapper. It then follows that class’s uses attribute (shared utility mappers), any referenced @MapperConfig, and any @DecoratedWith decorator class. Every class it finds, including all their superclasses, gets registered for GraalVM reflection automatically. Your DTOs and entities are intentionally excluded from this scan because quarkus-rest and quarkus-hibernate-orm already handle those.
In dev mode, it tracks all the types referenced by each mapper implementation. When any of those types change on disk, it triggers a targeted recompile of the affected mapper class, not the entire project, just the mapper that depends on what changed.
Your mapper source code is untouched by all of this. The @Mapper annotation, @Mapping rules, @MappingTarget — everything from Part 1 stays exactly as written.
Updating the project
What changes
The changes are mostly confined to pom.xml. Here’s the before and after.
Part 1 setup required two MapStruct dependencies plus an annotation processor declaration:
<!-- Part 1: two MapStruct dependencies -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
<scope>provided</scope>
</dependency>
<!-- Part 1: annotation processor in maven-compiler-plugin -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>Part 2 setup replaces the standalone mapstruct-processor dependency with the Quarkiverse extension, keeps the core mapstruct library, and leaves the annotation processor path unchanged:
<!-- Part 2: Quarkiverse extension (replaces the provided mapstruct-processor dependency) -->
<dependency>
<groupId>io.quarkiverse.mapstruct</groupId>
<artifactId>quarkus-mapstruct</artifactId>
<version>1.1.0</version>
</dependency>
<!-- Keep the core MapStruct library -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
<!-- Annotation processor stays — this is still how MapStruct generates impls at compile time -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>The distinction is worth a moment’s thought. mapstruct-processor in the annotationProcessorPaths is what generates PostMapperImpl.java during compilation and that’s pure MapStruct, and it stays. The quarkus-mapstruct extension is a separate Quarkus build step that runs after compilation and handles reflection registration and dev-mode change tracking. They do different jobs and both are needed.
Full pom.xml snippet
Here’s a minimal, complete properties and dependencies block for a fresh project:
<properties>
<mapstruct.version>1.6.3</mapstruct.version>
</properties>
<dependencies>
<!-- Quarkus REST + JSON -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-rest-jackson</artifactId>
</dependency>
<!-- Hibernate ORM + Panache -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-orm-panache</artifactId>
</dependency>
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-jdbc-h2</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-hibernate-validator</artifactId>
</dependency>
<!-- MapStruct: Quarkiverse extension + core library -->
<dependency>
<groupId>io.quarkiverse.mapstruct</groupId>
<artifactId>quarkus-mapstruct</artifactId>
<version>1.1.0</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${mapstruct.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Everything else stays the same
This is the part worth emphasising. Your entity, your DTOs, your mapper interface, and your REST resource are all unchanged. Let’s walk through them briefly to confirm nothing needs to move.
The entity
@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();
}
}The DTOs
// Response shape
public class PostDto {
public Long id;
public String title;
public String content;
public String authorEmail;
public LocalDateTime creationDate;
public LocalDateTime lastModifiedDate;
}
// Creation payload
public class CreatePostDto {
@NotBlank @Size(max = 255) public String title;
@NotBlank public String content;
@Email public String authorEmail;
}
// Update payload
public class UpdatePostDto {
@Size(max = 255) public String title;
public String content;
}The mapper
@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);
}The REST resource
@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();
}
}Not a line changed. That’s the point.
Verifying the improvements
Dev mode hot reload
Start the application and try adding a field to PostDto while it’s running:
./mvnw quarkus:devOpen PostDto.java and add a new field:
public class PostDto {
public Long id;
public String title;
public String content;
public String authorEmail;
public LocalDateTime creationDate;
public LocalDateTime lastModifiedDate;
public int wordCount; // <-- add this
}Save the file, then immediately hit the endpoint:
curl http://localhost:8080/postsWithout the extension, the response omits wordCount because PostMapperImpl was generated before the field existed and hasn’t been recompiled. With the extension, Quarkus detects that PostDto is a tracked dependency of PostMapper, recompiles the mapper implementation, and the field appears in the response — all without a restart.
Native image build
# If you have GraalVM or Mandrel installed locally:
./mvnw package -Dnative
# If you'd rather use a container (no local GraalVM needed):
./mvnw package -Dnative -Dquarkus.native.container-build=true
# Run the binary
./target/quarkus-mapstruct-ext-1.0.0-SNAPSHOT-runnerThe application should start cleanly and serve requests without any ClassNotFoundException. No @RegisterForReflection, no reflect-config.json entries. The extension handled all of that during the build.
One edge case to keep in mind: if you ever call
Mappers.getMapper(PostMapper.class)directly instead of using@Inject, GraalVM’s static analysis might not trace that call to the generated implementation class, and the extension can’t always compensate for it. The safe pattern for Quarkus is always@Inject PostMapper mapper. WithcomponentModel = "cdi"on your@Mapper, that’s the natural choice anyway.
Bonus: the decorator pattern
One of the more advanced MapStruct features is @DecoratedWith, which lets you intercept generated mapper methods with custom logic without replacing the entire implementation. It’s the right tool when you need post-processing on specific mappings, for example, redacting part of a field value before it leaves the service layer.
The extension handles decorator classes automatically, which is worth knowing because they’re a common source of native image failures in the manual setup.
// 1. Write the decorator
@Decorator
public abstract class PostMapperDecorator implements PostMapper {
@Inject @Delegate
PostMapper delegate;
@Override
public PostDto toDto(Post post) {
PostDto dto = delegate.toDto(post);
// Mask the email domain before returning
if (dto.authorEmail != null) {
dto.authorEmail = dto.authorEmail.replaceAll("@.*", "@***.***");
}
return dto;
}
}
// 2. Reference it from the mapper
@Mapper(componentModel = "cdi", ...)
@DecoratedWith(PostMapperDecorator.class)
public interface PostMapper {
// methods unchanged
}The extension sees @DecoratedWith(PostMapperDecorator.class), follows the reference, and registers PostMapperDecorator for reflection during native compilation. You don’t need to do anything else.
Migration checklist for existing projects
If you’re updating a project that was set up following Part 1, here’s the complete list of changes:
[ ] Add
io.quarkiverse.mapstruct:quarkus-mapstruct:1.1.0to<dependencies>[ ] Remove the
<dependency>block fororg.mapstruct:mapstruct-processor(keep the<annotationProcessorPaths>entry)[ ] Keep
org.mapstruct:mapstructin<dependencies>— it’s still needed at runtime[ ] Remove any
@RegisterForReflectionannotations you added to mapper interfaces[ ] Remove any
reflect-config.jsonentries for mapper classes[ ] Verify all mapper usage goes through
@Injectrather thanMappers.getMapper()[ ] Run
./mvnw quarkus:devand test hot reload by modifying a DTO field[ ] Run
./mvnw package -Dnativeand confirm the binary starts cleanly
What’s next
The extension brings the infrastructure up to date, but the MapStruct story in Quarkus has more to explore:
PATCH support — try combining
NullValuePropertyMappingStrategy.IGNOREwith a dedicatedPatchPostDtoto support partial updates properly, rather than treating PUT as a full replacement@MapperConfig— when you have multiple mapper interfaces sharing the samecomponentModel,unmappedTargetPolicy, and strategy settings,@MapperConfiglets you declare those once and reference them from each mapperNested mappings — if
Postgains aCategoryrelationship, MapStruct can map nested entities to nested DTOs automatically, or you can compose aCategoryMapperintoPostMappervia theusesattributeService layer — injecting
PostMapperdirectly into the REST resource is fine for small APIs, but as business logic grows it belongs in a dedicated service class that the resource delegates to
The extension source code is also worth a read if you’re curious how the Quarkus build step and dev-mode watcher are implemented. It’s a clean, focused piece of Quarkus extension work.



