MapStruct in a Reactive World: Keep Mappers Pure
If your mapper calls the database, the mapper is not a mapper anymore.
That sounds obvious when written directly, but it is one of the easiest shortcuts to miss in a blocking codebase. I have seen it happen because the code looks clean at first: the DTO comes in, MapStruct builds an entity, and small helper methods inside the mapper quietly load referenced entities by id.
Reactive migration exposes that shortcut very quickly.
After trying this in real migration work, the rule I trust is more precise than “never map ids”:
- DTO id to entity reference is okay in a mapper
- DTO id to database lookup belongs in a service
- DTO nested object to child update usually belongs in a service plus mapper
Repo branch for this article: article-3-mapper-cleanup
What Changed In The Previous Step
The series starts with a blocking Panache Next baseline. In the previous article, the market read path moves to reactive Panache Next with calls such as:
return Market_.managedReactive()
.find("lower(symbol) like ?1 order by symbol", normalizedQuery.toLowerCase() + "%")
.list()
.map(mapper::toViews);
That move is small, but it changes the pressure on the rest of the code.
The mapper in that flow is still simple:
@Mapper(componentModel = "jakarta")
public interface MarketMapper {
MarketView toView(Market entity);
List<MarketView> toViews(List<Market> entities);
}
It receives entities that are already loaded and converts them to response objects. No database calls. No session assumptions. No hidden blocking.
That is the shape I want before adding more reactive write paths.
The Shortcut That Causes Trouble
A common blocking-era pattern looks like this:
@Mapper(componentModel = "jakarta")
public interface PriceAlertMapper {
@Mapping(target = "market", source = "marketId")
@Mapping(target = "userAccount", source = "userAccountId")
PriceAlert toEntity(CreatePriceAlertRequest request);
default Market fromMarketId(Long id) {
return Market_.managedBlocking().findById(id);
}
default UserAccount fromUserAccountId(Long id) {
return UserAccount_.managedBlocking().findById(id);
}
}
The service looks smaller, but the mapper is now doing persistence work.
That means the mapper owns responsibilities that do not belong to mapping:
- relation loading
- transaction and session assumptions
- null or not-found behavior
- hidden query cost
- blocking behavior in code that appears to be a pure conversion
In a blocking application, this can survive for a long time because the code still works. In a reactive path, the mismatch becomes visible because relation loading returns Uni<T>.
Do Not Make the Mapper Reactive
One tempting fix is to return Uni from the helper:
default Uni<Market> fromMarketId(Long id) {
return Market_.managedReactive().findById(id);
}
That does not fix the boundary. It just moves reactive orchestration into MapStruct.
Another tempting fix is worse:
default Market fromMarketId(Long id) {
return Market_.managedReactive().findById(id).await().indefinitely();
}
That keeps the old synchronous mapper shape by blocking inside a layer that should never have owned I/O.
The better rule is simple:
- mappers copy values or create id-only references
- services own database lookup, validation, and transactional orchestration
- child update semantics should be explicit, not hidden behind generic mapping
Approach One: Map IDs To References
This branch adds a small PriceAlert create flow. The request carries ids:
public record CreatePriceAlertRequest(
@NotNull Long userAccountId,
@NotNull Long marketId,
@NotNull BigDecimal targetPrice,
@NotBlank String direction
) {
}
For this flow, the application does not need to load the full UserAccount or Market before inserting the alert. The database foreign key is enough for this simple create command.
That makes an id-only reference mapper reasonable:
@ApplicationScoped
public class EntityReferenceMapper {
public <T extends PanacheEntity> T ref(Long id, @TargetType Class<T> type) {
if (id == null) {
return null;
}
try {
T entity = type.getDeclaredConstructor().newInstance();
entity.id = id;
return entity;
} catch (Exception e) {
throw new IllegalArgumentException("Cannot create reference for " + type, e);
}
}
}
The PriceAlertMapper can use that helper without doing any database access:
@Mapper(componentModel = "jakarta", uses = EntityReferenceMapper.class)
public interface PriceAlertMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "userAccount", source = "userAccountId")
@Mapping(target = "market", source = "marketId")
@Mapping(target = "enabled", constant = "true")
PriceAlert toEntity(CreatePriceAlertRequest request);
}
The generated mapper is still pure. It constructs UserAccount and Market objects with only ids assigned. It does not check whether those rows exist, whether the market is active, or whether the current user can use that market.
That is the important distinction: creating a reference is mapping. Verifying the reference is database work.
With this contract, the service can stay small:
@WithTransaction
public Uni<PriceAlertView> create(CreatePriceAlertRequest request) {
PriceAlert alert = mapper.toEntity(request);
return PriceAlert_.managedReactive()
.persist(alert)
.replaceWith(alert)
.map(mapper::toView);
}
This approach is acceptable when the operation only needs to store foreign keys and can let the database enforce referential integrity.
Approach Two: Load References In The Service
If the service needs domain behavior, the mapper should not hide it.
For example, if alert creation must reject missing users with a clean 404, reject inactive markets, check ownership, or return the market symbol in the response, the service should load the references explicitly:
@WithTransaction
public Uni<PriceAlertView> create(CreatePriceAlertRequest request) {
PriceAlert alert = mapper.toEntity(request);
return UserAccount_.managedReactive()
.findById(request.userAccountId())
.onItem().ifNull().failWith(() -> new NotFoundException("User account not found"))
.chain(userAccount -> Market_.managedReactive()
.findById(request.marketId())
.onItem().ifNull().failWith(() -> new NotFoundException("Market not found"))
.chain(market -> {
alert.userAccount = userAccount;
alert.market = market;
return PriceAlert_.managedReactive().persist(alert).replaceWith(alert);
}))
.map(mapper::toView);
}
This version is longer than the reference-only version. It is also more honest when the operation really depends on loaded state.
One detail matters here: the two entity lookups are composed sequentially. In Hibernate Reactive, parallel database operations on the same session are not a safe default. This is another reason I prefer the service to own the flow visibly instead of hiding it in mapper helpers.
The code now shows:
- which references are loaded
- where the asynchronous boundary is
- where not-found behavior belongs
- where the transaction starts
- what is persisted after references are attached
That visibility is worth the extra lines.
Approach Three: Nested Object Updates
A nested object is a different problem again.
For example, this request shape has different semantics from marketId:
public record UpdateUserRequest(
String displayName,
NotificationSettingsRequest notificationSettings
) {
}
That nested object is not an id-only reference. It usually means “create or update owned child state.” The service should load the aggregate, decide whether the child already exists, and then let a mapper copy fields into the child object:
@WithTransaction
public Uni<UserAccountView> updateUser(Long id, UpdateUserRequest request) {
return UserAccount_.managedReactive().findById(id)
.onItem().ifNull().failWith(() -> new NotFoundException("User not found"))
.invoke(user -> {
mapper.updateUser(user, request);
settingsMapper.updateOrCreate(user, request.notificationSettings());
})
.map(mapper::toView);
}
That is not the same operation as marketId -> Market{id}. Treating both as one generic mapper problem usually creates misleading abstractions.
Reuse Without Hiding I/O Again
The next concern is usually repetition. If every service resolves nullable references by hand, code can become noisy.
That concern is legitimate. The answer is not to push I/O back into MapStruct. The answer is to add helpers that make I/O explicit.
A small utility like this is reasonable:
public final class ReactiveRefs {
private ReactiveRefs() {
}
public static <ID, E> Uni<E> resolveByIdNullable(ID id, Function<ID, Uni<E>> finder) {
return id == null ? Uni.createFrom().nullItem() : finder.apply(id);
}
}
Usage should still make the lookup visible:
return ReactiveRefs.resolveByIdNullable(request.marketId(), Market_.managedReactive()::findById);
That keeps the service as the orchestration layer while removing mechanical boilerplate.
When a Resolver Service Is Better
Use a typed resolver service when the lookup has domain behavior:
- authorization
- active/inactive checks
- ownership checks
- custom failure messages
- tenant isolation
For example:
@ApplicationScoped
public class MarketResolver {
public Uni<Market> resolveActiveMarket(Long id) {
return Market_.managedReactive().findById(id)
.onItem().ifNull().failWith(() -> new NotFoundException("Market not found"))
.invoke(market -> {
if (!market.active) {
throw new BadRequestException("Market is not active");
}
});
}
}
That abstraction is justified because it represents domain resolution, not simple DTO mapping.
Do Not Merge Different Semantics
Another migration mistake is to force unrelated operations through one generic helper.
These are different problems:
- resolve an existing reference by id
- persist or update an owned nested object
For the alert example:
marketIdcan mean “create an id-only market reference”userAccountIdcan mean “create an id-only user reference”marketIdcan also mean “load and validate market” if the service needs that behavior- a nested settings object would mean “create or update owned state”
Those operations have different ownership rules and different null-handling contracts. A generic helper that tries to cover all of them usually becomes misleading.
Why This Matters Beyond Reactive Code
Mapper purity is not a reactive fashion rule. It is a boundary rule.
Reactive persistence makes the boundary harder to ignore because database calls return Uni<T>, but the same cleanup helps blocking code too:
- mappers become deterministic and cheap to test
- services show where I/O happens
- hidden queries disappear
- not-found behavior becomes consistent
- relation resolution becomes reusable in the right layer
This is one of the practical cleanup steps I would do early in a hybrid migration. Before asking whether a path should be blocking or reactive, make sure the code is honest about where persistence work happens.
Closing
The lesson here is small but important: keep MapStruct boring.
Let it copy values and build view objects from data already in memory. Put database access in services or explicit resolver components. That makes reactive migration less surprising, and it also makes the blocking side easier to maintain.
In the next article, I will look at Panache Next metamodels and repositories from the same practical angle: where they improve discovery and consistency, and where they do not replace good service boundaries.