При работе с MapStruct возникает соблазн добавлять небольшие вспомогательные методы прямо в ваш mapper и вызывать их через expression. Однако если такой метод оперирует довольно общими типами (например, String -> String), MapStruct может обнаружить его и применить к другим полям того же типа — даже если вы этого не планировали.
Рассмотрим пример. Полный исходный код доступен на GitHub: pfilaretov42/spring-mapstruct-test. Посмотрите ветки fix-<n> для разных способов решения проблемы.
Настройка
Представим, что у нас есть простой DTO...
class BalrogDto(
val millenniaOld: Int,
val trueName: String,
val battleName: String,
)
...соответствующая модель...
class Balrog(
val millenniaOld: Int,
val trueName: String,
val battleName: String,
)
...и 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()
}
Здесь мы хотим изменить только поле trueName, преобразовав его в верхний регистр. Поле battleName должно остаться без изменений.
Проблема
Однако вот как выглядит сгенерированный mapper:
@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;
}
}
Под капотом MapStruct сканирует mapper в поисках доступных методов преобразования. Protected-метод String -> String выглядит как универсальный кандидат и будет применён ко всем остальным преобразованиям String -> String (например, к battleName). В результате battleName также будет преобразован с помощью метода uppercased, а это не то, чего мы хотели.
Почему это происходит
Это происходит потому, что MapStruct подбирает методы преобразования в первую очередь по сигнатуре. Если он видит метод String -> String в mapper-е, он может использовать его для преобразования любого поля типа String.
Использование этого метода внутри expression не ограничивает его область применения. Он остаётся «доступным» для MapStruct и для других полей того же типа.
Как избежать неожиданностей
Теперь посмотрим, как можно сделать намерения явными и избежать непредвиденного глобального применения.
1. Вынести вспомогательный метод за пределы mapper-а и вызывать его через expression
Можно поместить логику в отдельный утилитный класс, который MapStruct не будет сканировать как источник методов маппинга:
object StringUtils {
@JvmStatic
fun uppercased(s: String): String = s.uppercase()
}
Важно не указывать этот класс в @Mapper(uses = ...); вместо этого мы импортируем его в mapper и вызываем напрямую в 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
}
Так как StringUtils не является mapper-ом и не указан в uses, MapStruct не будет автоматически применять его к другим строковым полям. Мы по-прежнему получаем нужное преобразование через expression.
2. Использовать квалификаторы для изоляции методов
Если мы хотим оставить метод маппинга, но применять его только при явном вызове, можно пометить его с помощью аннотации @org.mapstruct.Named и указывать в конкретном поле через qualifiedByName:
@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. Сделать преобразование типоспецифичным
Ещё один способ — избегать обобщённых сигнатур методов. Мы можем обернуть понятие (trueName) в доменный тип (TrueName)...
class Balrog(
val millenniaOld: Int,
val trueName: TrueName,
val battleName: String,
)
class TrueName(val value: String)
...и тогда сигнатура метода mapper-а больше не будет String -> String:
@Mapper(componentModel = "spring")
abstract class BalrogMapper {
abstract fun toModel(dto: BalrogDto): Balrog
fun toTrueName(raw: String): TrueName = TrueName(raw.uppercase())
}
Таким образом, MapStruct не сможет случайно использовать этот метод для других строковых полей. Этот вариант немного тяжелее и обычно оправдан, если вы уже используете сложные доменные типы.
Заключение
Главные выводы:
Метод в mapper-е, выглядящий как общий
String -> String, может быть автоматически применён ко всем строковым полям.Использование такого метода в
expressionне ограничивает его область применения.Лучше изолировать логику для разового применения в утилитах и вызывать её через
expression, либо квалифицировать методы с помощью@NamedиqualifiedByName.Используйте универсальные методы маппинга в mapper-е только если вы действительно хотите, чтобы они применялись глобально.