When using MapStruct, it’s tempting to tuck small helper methods into your mapper and use them via expression. However, if that helper looks like a generic mapping method (for instance, String -> String), MapStruct may discover and apply it to other fields of the same type — even when you didn’t intend to.
Let's look at an example. The full source code is available on GitHub: pfilaretov42/spring-mapstruct-test. Check out the fix-<n> branches for different ways to address the problem.
The Setup
Imagine we have a simple DTO...
class BalrogDto( val millenniaOld: Int, val trueName: String, val battleName: String, ) ...a corresponding model...
class Balrog( val millenniaOld: Int, val trueName: String, val battleName: String, ) ...and a mapper:
@Mapper(componentModel = "spring") abstract class BalrogMapper { @Mapping(target = "trueName", expression = "java(uppercased(dto.getTrueName()))") abstract fun toModel(dto: BalrogDto): Balrog protected fun uppercased(value: String): String = value.uppercase() } Here we only want to customize the trueName field by uppercasing it. The battleName should remain as is.
The Pitfall
However, here is what the mapper implementation looks like:
@Generated(...) @Component public class BalrogMapperImpl extends BalrogMapper { @Override public Balrog toModel(BalrogDto dto) { if ( dto == null ) { return null; } int millenniaOld = 0; String battleName = null; millenniaOld = dto.getMillenniaOld(); battleName = uppercased( dto.getBattleName() ); String trueName = uppercased(dto.getTrueName()); Balrog balrog = new Balrog( millenniaOld, trueName, battleName ); return balrog; } } Under the hood, MapStruct scans the mapper for available mapping methods. A protected String -> String method looks like a perfect general-purpose candidate and will be applied to other String-to-String mappings (like battleName). So, battleName will also be mapped using the uppercased method, which is not what we wanted.
Why This Happens
It happens because MapStruct selects mapping methods primarily by signature. If it sees a String -> String method on the mapper, it may use that method to map any String field.
Using that method in an expression does not "scope" it to a single target. It still remains discoverable by MapStruct for other fields of the same type.
How To Avoid The Surprise
Now, let's see how we can keep the intention explicit and avoid accidental global application.
1. Move the helper out of the mapper and call it via expression
We can put the logic in a separate utility class that MapStruct does not scan as a source of mapping methods:
object StringUtils { @JvmStatic fun uppercased(s: String): String = s.uppercase() } Remember not to list it in @Mapper(uses = ...); instead, import it in the mapper and call it explicitly in expression:
@Mapper(componentModel = "spring", imports = [StringUtils::class]) abstract class BalrogMapper { @Mapping(target = "trueName", expression = "java(StringUtils.uppercased(dto.getTrueName()))") abstract fun toModel(dto: BalrogDto): Balrog } Because StringUtils is not a mapper and not listed in uses, MapStruct won’t auto-apply it to other String fields. We still get our one-off transformation through the expression.
2. Qualify and isolate mapping methods
If we want to keep a mapping method but apply it only when explicitly referenced, we can annotate the method with the @org.mapstruct.Named annotation and reference it using qualifiedByName on the specific field mapping:
@Mapper(componentModel = "spring") abstract class BalrogMapper { @Mapping(target = "trueName", qualifiedByName = ["uppercased"]) abstract fun toModel(dto: BalrogDto): Balrog @Named("uppercased") protected fun uppercased(value: String): String = value.uppercase() } 3. Make the transformation type-specific
Another approach is to avoid generic method signatures. We can wrap the concept (trueName, in our case) into a domain-specific type (TrueName)...
class Balrog( val millenniaOld: Int, val trueName: TrueName, val battleName: String, ) class TrueName(val value: String) ...so the mapper’s method signature is no longer String -> String:
@Mapper(componentModel = "spring") abstract class BalrogMapper { abstract fun toModel(dto: BalrogDto): Balrog fun toTrueName(raw: String): TrueName = TrueName(raw.uppercase()) } This way, MapStruct cannot accidentally reuse it for unrelated String fields. This option is heavier and typically only worthwhile if you already embrace rich domain types.
Conclusion
Here are the key takeaways:
- A method that looks like a general
String -> Stringmapper inside your@Mappercan be auto-applied to anyStringfield. - Using such a method in an
expressiondoes not limit its scope. - Prefer isolating one-off logic in a utility and calling it via
expression, or qualify mapping methods with@Namedand reference them withqualifiedByName. - Keep your mapper free of unintended generic mapping methods unless you truly want them globally applied.
Dream your code, code your dream.
Top comments (0)