При работе с 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-е только если вы действительно хотите, чтобы они применялись глобально.

Комментарии (0)