PunchKano begins diphtheria immunisation in five Kano LGsRTP DesportoFrancisco Cabral na final de pares em HangzhouESPN DeportesGrecia derrotó a Alemania por primera vez en la historia y golpeó a Klopp en su debut ante su públicoThe Jerusalem PostIsrair awaits final approval for Tokyo, Miami flights, adds new European destinations for 2027Daily MaverickWe worried AI would make things up, we should also worry when it doesn’tInquirerRains to continue in Visayas, Mindanao due to ITCZ until Sept. 30Bollywood HungamaVinod Kapri's Pyre to release in theaters on October 23, 2026BlickNächstes Amt abgelegt: Jens Spahn zieht sich aus Haushaltsausschuss zurückRapplerPhilippines should fix tax gaps and procurement, not raise tax rates – WBSportstarIndia in Athletics LIVE Updates, Asian Games 2026: Vithya Ramraj breaks National Record to win 400m hurdles bronze; Javelin throw final at 4:45 PM ISTIl Fatto QuotidianoMorto Stefano Milani, il tifoso del Milan diventato famoso su X. Da Bertolucci a Valenti: “Ha lottato come nessuno, mai un passo indietro”7sur7“Pourquoi je perds toujours?”: quand André Agassi taquine Alexander Zverev
The Daily Newsstand · Free, Always
Monday, September 28, 2026

Как и зачем я сделал свой маппер

Translate

Я не люблю Clean Architecture, не люблю DTO и не люблю мапперы.

Они превращают сохранение одной строки в базу в путешествие через четыре проекта, три слоя абстракций и папку Mappings, в которую никто не заглядывал со времён первого коммита. Чтобы добавить одно поле на форму, надо поменять сущность, DTO, команду, валидатор, профиль маппинга и тест на профиль маппинга. Шесть файлов ради одной колонки. А если в одном из них забыть, никто ничего не скажет.

Поэтому я, конечно же, написал маппер.

Если серьёзно, то есть два места, где проблема настоящая. В этой статье я расскажу, что это за места, почему популярные мапперы их нормально не закрывают и что я в итоге с этим сделал.

Зачем нужен маппер

Типичный CRUD. Есть сущность, в базе у неё тридцать полей:

public class User
{
    public Guid Id { get; set; }
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
    public string? Bio { get; set; }
    public bool IsBlocked { get; set; }
    public DateTimeOffset CreatedAt { get; set; }
    // ...и ещё два десятка
}

Есть модель для чтения и модель для записи:

public class UserResponse
{
    public Guid Id { get; init; }
    public string FirstName { get; init; } = "";
    public string LastName { get; init; } = "";
    public bool IsBlocked { get; init; }
}

public class UpdateUserRequest
{
    public string FirstName { get; init; } = "";
    public string LastName { get; init; } = "";
    public string? Bio { get; init; }
}

Эти модели никто не проектирует. Это просто срезы User. С сущностью их связывают два места, которые пишутся руками.

Место первое - проекция в запросе:

var users = await db.Users
    .Where(u => !u.IsBlocked)
    .Select(u => new UserResponse
    {
        Id = u.Id,
        FirstName = u.FirstName,
        LastName = u.LastName,
        IsBlocked = u.IsBlocked,
    })
    .ToListAsync(ct);

Проекция нужна, чтобы не тащить сущность целиком ради четырёх полей. Меньше данных по сети, нет лишнего трекинга.

Место второе - присваивания в хендлере команды или эндплоинте:

var user = await db.Users.FindAsync([request.Id], ct);

user.FirstName = request.FirstName;
user.LastName = request.LastName;
user.Bio = request.Bio;

await db.SaveChangesAsync(ct);

Такой код нужен чтобы валидировать входящие данные и избежать overposting в эндпоинтах.

Про этот код никто не пишет статей, потому что в нём нечего обсуждать. Но именно этот код ломается когда не ожидаешь.

Что может пойти не так

Приходит требование: в ответе нужен CreatedAt. Добавляю поле в модель:

public class UserResponse
{
    // ...
    public DateTimeOffset CreatedAt { get; init; } // новое
}

Если забыть дописать CreatedAt = u.CreatedAt в проекцию, то не произойдёт ничего. Object initializer не обязан заполнять все свойства. Сборка зелёная, тесты зелёные, а в API уезжает 0001-01-01T00:00:00. Судя по ответу сервиса, пользователь зарегистрировался при Октавиане Августе.

С хендлером то же самое. Добавили в сущность поле, которое должно заполняться при записи, - присваивания не обязаны его заполнять.

Компилятор контролирует только одну сторону:

  • переименовали поле в источнике - сборка упадёт

  • появилось незаполненное поле в цели - тишина

А как же required?

На этом месте внимательный читатель скажет: в C# 11 для этого есть required.

public class UserResponse
{
    public required DateTimeOffset CreatedAt { get; init; }
}

var r = new UserResponse { Id = Guid.NewGuid() };
// error CS9035: Required member 'CreatedAt' must be set in the object initializer.

Работает. Но только там, где объект создаёт компилятор. Runtime-мапперы создают объекты через рефлексию или скомпилированные выражения, мимо object initializer. Для них required - обычное свойство. Это не недоработка конкретной библиотеки, а прямое следствие рантайм-подхода.

Итого, что мне нужно на самом деле:

  • проекция IQueryable, которая транслируется в SQL и сама следует за изменениями модели

  • заполнение существующей сущности, которую трекает EF, без её пересоздания

  • незаполненное поле в цели - это сигнал на этапе сборки, а не null в проде

Не фреймворк маппинга. Два места и детектор расхождений.

Что есть на рынке

Разберём по очереди. Спойлер: всё плохо.

AutoMapper

Сам я AutoMapper в проект никогда не добавлял. Но почти в каждом проекте, куда я приходил, он уже был. Обычно в комплекте с Clean Architecture, MediatR и той самой папкой Mappings.

public class UserProfile : Profile
{
    public UserProfile()
    {
        CreateMap<User, UserResponse>();
        CreateMap<UpdateUserRequest, User>();
    }
}

Выглядит невинно. Теперь по пунктам.

1. Ошибки только в рантайме. Забыли CreateMap - узнаете из AutoMapperMappingException: Missing type map configuration or unsupported mapping в логах. Поле в цели не нашло источник - не узнаете вообще, оно просто останется default. Для этого есть AssertConfigurationIsValid(), которую надо не забыть вызвать в тестах. Угадайте, как часто про неё забывают.

2. Переименование ломает маппинг молча. Маппинг по конвенции - это сопоставление строк. Переименовали свойство рефакторингом в IDE - студия поправила все обращения, а профиль ничего не заметил. Find Usages по свойству DTO не покажет, откуда оно заполняется. F12 никуда не ведёт.

3. Конфигурация - это код, только хуже. Как только конвенции не хватает, появляются ForMember, MapFrom, ConvertUsing, IValueResolver, AfterMap. Резолверы получают зависимости из DI, в MapFrom уезжают вычисления, и через год в профилях живёт бизнес-логика, которую никто не видит, потому что профили никто не открывает. Чтобы понять, откуда в ответе взялось значение, надо найти профиль, в профиле - ForMember, в нём - резолвер, в резолвере - сервис.

4. Используется не по назначению. Автор AutoMapper сам писал, что библиотека сделана под один сценарий: цель - подмножество источника, сложная модель сплющивается во view model. Если ваша задача другая, инструмент вам не подходит. А в проектах через AutoMapper маппят команды в сущности, то есть ровно в обратную сторону.

5. IMapper везде. Для Map нужен IMapper, для ProjectTo - IConfigurationProvider. Значит, AddAutoMapper в DI, сканирование сборок при старте и IMapper в конструкторе каждого хендлера. Зависимость, которую внедряют ради того, чтобы переложить поля из одного объекта в другой.

6. required не работает, см. выше.

7. Медленно. AutoMapper медленнее ручного кода в 8 раз. Цифры будут ниже.

Лицензия. С 15-й версии (июль 2025) AutoMapper распространяется по коммерческой лицензии, последняя версия под MIT - 14-я. К техническим проблемам, которые достались вместе с проектом, добавилась ещё и юридическая.

Это законное решение автора. Но если всё равно придётся переезжать, то непонятно, зачем переезжать на ещё один AutoMapper.

Mapperly

Mapperly - лучший из трёх, поэтому его недостатки особенно обидны. Source generator, на выходе читаемый C#, ноль рефлексии. Незаполненные члены цели ловит (RMG012), required уважает, потому что генерирует обычный object initializer, и язык сам выдаст CS9035.

А теперь цена:

[Mapper]
public static partial class UserMapper
{
    public static partial UserResponse ToResponse(User user);
    public static partial void UpdateUser(UpdateUserRequest request, User user);
    public static partial IQueryable<UserResponse> ProjectToResponse(this IQueryable<User> q);
}

1. Маппер на каждую пару. Тридцать DTO - тридцать partial-методов. Плюс отдельный метод на каждую проекцию. Плюс отдельный метод для маппинга в существующий объект. Ручной маппинг, от которого мы хотели избавиться, превратился в ручное объявление маппинга. Строк меньше, церемоний столько же.

2. Конфигурация переехала в атрибуты. Нестандартный случай - это [MapProperty(nameof(User.FirstName), nameof(UserResponse.Name))] или [MapperIgnoreTarget(...)]. Те же ForMember из AutoMapper, только в квадратных скобках.

3. Вызов не на месте. Хочешь маппинг - найди, в каком маппере объявлен нужный метод. Нет такого - иди объявляй. Маппер становится ещё одним слоем, который надо поддерживать. Как раз то, чего мне в Clean Architecture и так хватает.

4. Generic-код не работает. Mapperly генерирует код только для пар типов, известных на этапе компиляции. Если маппинг вызывается внутри generic-метода, например в базовом репозитории или обобщённом хендлере, типов там нет, есть только TEntity и TDto:

public Task<List<TDto>> GetAll<TEntity, TDto>() where TEntity : class
{
    IQueryable<TEntity> query = db.Set<TEntity>();
    IQueryable<TDto> projected = ???; // какой partial-метод маппера сюда подставить?
    return projected.ToListAsync();
}

Для такого кода нужен рантайм-фоллбек, который построит маппинг, когда типы станут известны. В Mapperly его нет: generic-методы маппера умеют выбирать только из пар, заранее объявленных в том же маппере.

Mapster

Mapster - это AutoMapper, который решил обойтись без профилей. Проще на входе, работает быстрее, но архитектурные проблемы тоже есть.

var dto = user.Adapt<UserResponse>();                                    // новый объект
request.Adapt(user);                                                     // в существующий объект
var list = await db.Users.ProjectToType<UserResponse>().ToListAsync(ct); // проекция

Нестандартные случаи настраиваются в конфигурации:

TypeAdapterConfig<User, UserResponse>
    .NewConfig()
    .Map(d => d.FullName, s => s.FirstName + " " + s.LastName);

1. Adapt висит на object. Метод объявлен как расширение для object, поэтому IntelliSense предлагает его на всём подряд. 42.Adapt<UserResponse>() прекрасно компилируется. Тип источника компилятору неизвестен, проверять нечего.

2. Глобальное изменяемое состояние. Конфигурация по умолчанию живёт в статическом TypeAdapterConfig.GlobalSettings. Любой код в любой сборке может её поменять, а порядок инициализации - ваша проблема.

3. Ошибки в рантайме. Маппинг компилируется при первом вызове. Проверку, что у каждого поля цели есть источник, можно включить: RequireDestinationMemberSource = true плюс вызов Compile() при старте. По умолчанию она выключена. И даже включённая, она сработает при запуске приложения, а не при сборке.

4. Генератор сбоку. Генерация кода у Mapster есть, но это отдельный инструмент Mapster.Tool. Его надо поставить как dotnet tool и прописать запуск после сборки:

<Target Name="Mapster" AfterTargets="AfterBuild">
  <Exec WorkingDirectory="$(ProjectDir)" Command="dotnet tool restore" />
  <Exec WorkingDirectory="$(ProjectDir)"
        Command="dotnet mapster extension -a &quot;$(TargetDir)$(ProjectName).dll&quot;" />
</Target>

Инструмент читает уже собранную DLL и генерирует файлы с мапперами по атрибутам, интерфейсам или fluent-конфигурации. Например, с атрибутами:

[AdaptTo("[name]Dto"), GenerateMapper]
public class User { ... }

// Сгенерируется класс UserDto и статический UserMapper:
var dto = user.AdaptToDto();
user.AdaptTo(existingDto);
var list = db.Users.Select(UserMapper.ProjectToDto);

Что тут не так:

  • это не Roslyn source generator, который работает в IDE при каждом изменении, а отдельный шаг после сборки

  • генерация идёт после компиляции, поэтому свежий маппер попадает в сборку только при следующем билде

  • инструмент надо поставить локально, на CI и каждому новому разработчику

  • API у сгенерированного кода другой, не Adapt<T>(): перейти с рантайма на генерацию - значит переписать все вызовы

  • диагностик в месте вызова нет

Сводная таблица

Нужно

Кто даёт

Вызов на месте, без классов-мапперов

Mapster, AutoMapper

Без регистраций в DI

Mapster, Mapperly

Ошибка при сборке, если поле цели не заполнено

Mapperly (ценой входа)

Проекция IQueryable в SQL

AutoMapper, Mapster, Mapperly

Работа в generic-коде

AutoMapper, Mapster (рантайм)

required соблюдается

Mapperly

Всё сразу, без церемоний

Никто

Когда на рынке пусто, вариантов два: смириться или написать своё. Я выбрал второе.

Как я хочу

Сначала я хотел такой API:

var dto = user.MapTo<UserResponse>();            // новый объект
request.MapTo(user);                             // в существующий объект
var list = db.Users.ProjectTo<UserResponse>();   // проекция в SQL

Для рантайм-версии это работает: метод объявляется как MapTo<TTarget>(this object source), а тип источника можно получить через source.GetType().

Для генератора так не подойдёт. Генератору нужен статический тип источника, значит, он должен быть в сигнатуре: MapTo<TSource, TTarget>(this TSource source). А C# выводит generic-параметры либо все, либо никакие. Указать только TTarget нельзя, и вызов превращается в такое:

var dto = user.MapTo<User, UserResponse>();
var list = db.Users.ProjectTo<User, UserResponse>();

Многословно, и тип источника дублируется на каждом вызове. Для варианта в существующий объект проблемы нет: оба типа выводятся из аргументов. Но ради одного метода API не делают.

Поэтому вызов разбит на два шага. Метод расширения возвращает структуру-обёртку, которая запоминает тип источника, а у обёртки уже есть To<TTarget>(), где указывается только цель:

public readonly struct FusionSource<TSource>(TSource value)
{
    public TTarget To<TTarget>() => FusionMapper<TSource, TTarget>.Map(value);
    public TTarget To<TTarget>(TTarget target) => FusionMapper<TSource, TTarget>.Map(value, target);
}

Обёртка - структура, так что лишних аллокаций нет. В итоге API такой:

var dto = user.Map().To<UserResponse>();            // новый объект
request.Map().To(user);                             // в существующий объект
var list = db.Users.Project().To<UserResponse>();   // проекция в SQL

Никаких профилей, классов-мапперов и регистраций в DI. Если у поля цели нет источника - предупреждение в месте вызова. Если источника нет у required-поля - ошибка компиляции.

Библиотека называется FusionMapper: слияние рантайм и source-generated мапера. Учитывая количество кода, который в этом проекте сгенерировал ИИ, я хотел назвать его SlopMapper.

История создания

Расскажу как делал и с чем столкнулся

Сначала рантайм

Первый коммит - 5 августа. Начал я сознательно не с генератора, а с прототипа на expression trees.

Генератор долго итерировать. А конвенции маппинга сначала надо определить, и удобнее всего это делать на работающем ядре: добавил правило, прогнал тесты.

Рантайм-генератор и тесты к нему в основном написал Qwen. Бесплатный, прямо в чате на сайте, без всяких IDE-интеграций. Я описывал правило, получал код и тесты, запускал, возвращал ошибки. Сам я только немного рефакторил. Первые дни ушли на семантику:

  • совпадение по имени: точное, без учёта регистра, без ведущего подчёркивания; поля наравне со свойствами

  • флэттенинг: CustomerAddressCity ← Customer.Address.City, на любую глубину

  • nullable reference types: переход через nullable-объект даёт null, а не NullReferenceException; nullable-строка в non-nullable поле - контрактная ошибка

  • конструкторы, required и init: выбор конструктора с сопоставлением параметров по имени, [SetsRequiredMembers] учитывается

  • агрегаты - про них отдельно

К 12 августа были бенчмарки, и версия на выражениях обгоняла Mapster. Но главного она не давала: ошибки всё ещё появлялись только при выполнении, а IDE молчала до запуска тестов.

Агрегаты

Почти в каждой view-модели есть сводка по коллекции: сумма по строкам заказа, признак «есть активные строки», последний покупатель.

Мне было интересно, как это решают другие. У AutoMapper по конвенции ровно один агрегат - Count. И тот не фича, а побочный эффект флэттенинга: имя LinesCount разбивается на Lines + Count, а Count - обычное свойство List<T>. Стоит объявить коллекцию как IEnumerable<T>, и всё перестаёт работать. Я проверил на AutoMapper 15: LinesCount маппится без конфигурации, LinesAmountSum молча остаётся нулём. Остальное пишется руками:

CreateMap<Order, OrderDto>()
    .ForMember(d => d.Total, o => o.MapFrom(s => s.Lines.Sum(l => l.Amount)))
    .ForMember(d => d.HasActive, o => o.MapFrom(s => s.Lines.Any(l => l.IsActive)));

В FusionMapper агрегат описывается именем свойства:

Имя свойства в DTO

Что генерируется

LinesCount

Lines.Count

LinesAmountSum

Lines.Sum(x => x.Amount)

LinesAmountAverage, ...Max, ...Min

агрегат по селектору

LinesIsActiveAny, LinesIsActiveAll

Lines.Any/All(x => x.IsActive)

LinesBuyerLastOrDefault

Lines.Select(x => x.Buyer).LastOrDefault()

Всего одиннадцать агрегатов. Конфигурации нет, и в Project().To<>() это уезжает в SQL.

Две честные оговорки:

  • имя свойства - это magic string, опечатку в суффиксе поймает не ошибка, а предупреждение FMAP005

  • поведение на пустых коллекциях как в LINQ: Average, Max, First бросят исключение, для таких случаев есть варианты *OrDefault

И да, если очень хочется, чтобы поле называлось просто Total, придётся уступить. Аналога ForMember нет.

Source generator

Заготовку source generator с интерсепторами я взял у Andrew Lock - у него есть подробные статьи и примеры на эту тему. Дальше попросил Qwen переделать рантайм-генератор в source generator поверх этой заготовки. Логика построения маппинга переехала почти без изменений, поменялся только выход: вместо Expression - строки C#.

Интерсепторы

Главная идея, которую я хотел проверить, - C# Interceptors. Генератор может подменить конкретный вызов в конкретной строке своим методом. Пользователь пишет:

var dto = user.Map().To<UserDto>();

А компилятор вызывает сгенерированный метод:

public static UserDto Map__User__To__UserDto(User source)
{
    if (source == null) return default;
    return new UserDto
    {
        Name = source.Name,
        Age = source.Age
    };
}

Обычный object initializer, ноль рефлексии. И невозможный маппинг становится ошибкой компиляции, а не исключением в проде.

Звучит хорошо, на практике очень сложная отладка. Если накосячис с интерсепторами, то код просто не заменится и даже не знаешь вызвался сгенерированный метод или рантайм фоллбек. Только если тест в сгенерированном методе упадет это будет видно по стектрейсу.

Есть и ещё одно ограничение, на котором я потерял много времени. Внутри expression tree интерсептор применять нельзя: там нет вызова метода, есть только данные, описывающие вызов. Поэтому генератор должен фильтровать места вызова и пропускать те, что находятся внутри лямбды, которая компилируется в Expression<...>.

Первую рабочую версию интерсепторов, которые перехватывали все вызовы .To<T>(), сгенерировал Qwen. Этот фильтр в ней был. А потом я при рефакторинге случайно его сломал. Интерсепторы начали применяться к вызовам внутри expression, и компилятор стал падать. Не с ошибкой в коде, а целиком, без внятного сообщения и без указания строки, на которой он упал.

Отлаживал я это долго. История коммитов за 15-16 августа:

Added interceptors for To method calls
Refactoring? temporary disabled generators
Восстановлены интерсепторы

Когда наконец починил, написал сообщение коммита на русском. Единственный раз за всю историю репозитория. Думаю, комментарии излишни.

В итоге получились две схемы:

  • .NET 9+ - интерсепторы по умолчанию, рефлексии нет, невозможный маппинг - ошибка компиляции

  • .NET 8 или EnableFusionMapperInterceptor=false - сгенерированные мапперы регистрируются при старте через [ModuleInitializer], то, что генератор не смог построить, строится в рантайме, а диагностика становится предупреждением

Рантайм-версия на выражениях никуда не делась, и это закрывает проблему generic-кода. Если вызов находится внутри generic-метода и типы на этапе компиляции неизвестны, генератор его пропускает, и маппинг строится в рантайме при первом вызове с конкретными типами:

public Task<List<TDto>> GetAll<TEntity, TDto>() where TEntity : class
    => db.Set<TEntity>().Project().To<TDto>().ToListAsync();

Там, где типы известны, работает сгенерированный код. Там, где нет, - рантайм-фоллбек. Вызывающий код одинаковый.

Проекции

Для Project().To<T>() нужен не код, а данные - Expression<Func<TSource, TTarget>>, который EF Core транслирует в SQL. Интерсепторы тут не помогают. Поэтому генератор создаёт выражения заранее и через тот же [ModuleInitializer] кладёт их в кэш при загрузке сборки. В рантайме деревья выражений не строятся.

Кэширование в генераторе

То, что сгенерировал Qwen, работало, но работало медленно. Roslyn запускает генератор практически на каждое нажатие клавиши, и если генератор каждый раз пересчитывает все маппинги, студия на большом солюшене начинает заметно тормозить.

Incremental generator кэширует результаты шагов пайплайна, но только если модели между шагами сравниваются по значению. Массивы по значению не сравниваются, ссылки на символы Roslyn держать в модели нельзя. Поэтому архитектуру генератора я переписал сам: модели стали неизменяемыми записями без ссылок на семантическую модель, коллекции - EquatableArray, хэш-коды считаются явно. EquatableArray и тесты на кэшируемость генератора я опять же подсмотрел у Andrew Lock. Такой тест прогоняет генератор второй раз на копии той же компиляции и проверяет, что все отслеживаемые шаги пайплайна вернули Cached или Unchanged, то есть ничего не пересчитали. Qwen в это время чинил падающие тесты.

Скучная инженерия, но именно она отличает «работает у меня» от «работает у пользователя». И это та часть, которую LLM сама не сделала: код без кэширования проходит все тесты, проблема видна только в IDE.

Контроль

Ради этого всё и затевалось.

Добавили поле в UserResponse и забыли про источник - на каждом месте вызова появляется предупреждение:

warning FMAP005: The following members of 'UserResponse' have no matching source members
and will keep their default values: CreatedAt.

Поле не маппится осознанно - ставите [FusionMapperIgnore]. Это единственный атрибут во всём API. Можно подавить FMAP005 для всего проекта, но тогда непонятно, зачем вам эта библиотека.

Если поле required и источника для него нет, то это FMAP001: ошибка компиляции при работе через интерсепторы или MappingException со списком полей в рантайм-режиме. Генератор строит настоящий object initializer, поэтому обещание языка наконец выполняется.

Правило для всех неоднозначностей одно: диагностика при сборке или исключение со списком полей. Никогда - ноль в поле.

Коллекции

Наивный генератор на всё пишет Select(...).ToList(). Но цель может быть IReadOnlyList<T>, коллекцией с конструктором от IEnumerable<T>, коллекцией с AddRange или только с Add. Генератор смотрит на тип цели и выбирает способ создания. Для интерфейсов генерируется collection expression [.. items].

Отдельная история - маппинг в существующий объект. Коллекцию нельзя просто заменить новой: сломается биндинг в WPF или MAUI, а change tracker в EF увидит совсем не то, что вы ожидаете. Поэтому вызывается Clear() и потом AddRange() или Add() в цикле, ссылка на коллекцию остаётся той же. Мелочь, о которой узнаёшь, только поймав баг.

Бенчмарк

BenchmarkDotNet, Intel i9-9900KF, .NET 10.

Плоский объект:

Метод

Mean

Allocated

FusionMapper

6.24 ns

48 B

Mapperly

6.56 ns

48 B

Ручной маппинг

6.61 ns

48 B

Mapster

16.66 ns

48 B

AutoMapper

52.08 ns

48 B

Вложенный объект, 4 уровня:

Метод

Mean

Allocated

Ручной маппинг

8.88 ns

48 B

Mapperly

16.68 ns

48 B

FusionMapper

17.44 ns

48 B

Mapster

39.17 ns

48 B

AutoMapper

104.04 ns

48 B

Проекция EF-запроса на 1000 строк: ручная 1.09 ms, AutoMapper 1.11 ms, Mapster 1.13 ms, FusionMapper 1.14 ms. Паритет, и это логично: всю работу делает база.

Быстрее ручного маппинга на 0.37 наносекунды - это погрешность, а не победа. С Mapperly паритет, на вложенных графах он даже немного впереди. Реальная разница есть только с AutoMapper на горячих путях: в 6-8 раз.

Быстродействие не было целью. Оно получилось как следствие подхода: если генерировать тот же код, что пишется руками, то и работать он будет так же.

Оптимизации по результатам бенчмарков делал GLM-5.3-Flash.

Проверка на реальном проекте

Маппер хорошо смотрится на примере с классом User, поэтому я взял шаблон Clean Architecture Джейсона Тейлора: .NET 10, Aspire, EF Core, MediatR. И полностью убрал из него AutoMapper 16.

  • 5 файлов изменено

  • 3 профиля удалены целиком, вместе с единственным ForMember на весь проект

  • AddAutoMapper удалён из DI, регистрировать больше нечего

  • IMapper больше не внедряется в хендлер

Самое сложное место - проекция в EF:

// Было
Lists = await _context.TodoLists
    .ProjectTo<TodoListDto>(_mapper.ConfigurationProvider)
    .OrderBy(t => t.Title)
    .ToListAsync(cancellationToken);

// Стало
Lists = await _context.TodoLists
    .Project()
    .To<TodoListDto>()
    .OrderBy(t => t.Title)
    .ToListAsync(cancellationToken);

Оба варианта транслируются в SQL. Маппинг никуда не делся, модели те же. Ушла инфраструктура вокруг него.

Конвертацию примера тоже делал GLM-5.3-Flash. Для него это идеальная задача: механическая, с понятным критерием готовности - собирается и тесты зелёные.

Код примера смотреть тут: https://github.com/gandjustas/FusionMapper/tree/main/examples/CleanArchitecture

Чего нет

  • ручной конфигурации: никаких MapFrom, ConvertUsing и резолверов

  • циклических графов

  • полиморфизма в рантайме: маппинг по статическим типам

  • поддержки .NET ниже 8

Каждую возможность я проверял одним вопросом: нужно ли это в тех двух местах из начала статьи? Полиморфизм - нет. Циклы - нет. Конфигурация - нет.

Когда FusionMapper не нужен

  • в проекте три DTO - напишите руками или пусть ИИ генерирует, это нормально

  • маппинг не выводится из имён и нужна ручная конфигурация

  • модели с циклическими ссылками или полиморфизмом - это уже не про DTO

  • проект на .NET Framework или .NET 6

Когда нужен

  • много проекций в EF и хендлеров, которые заполняют сущности

  • вы хотите, чтобы изменение модели ломало сборку, а не прод

  • вы уходите с AutoMapper после смены лицензии и не хотите заводить класс-маппер на каждую пару типов

Итого

Кто что делал, если собрать в одном месте:

  • Qwen - рантайм-генератор, тесты, перенос на source generator, исправление тестов

  • GLM-5.3-Flash - оптимизация по бенчмаркам, конвертация примеров

  • Claude - финальная полировка решения

  • я - API, конвенции, архитектура генератора с кэшированием, решения о том, чего в библиотеке не будет

От первого коммита до пакета в NuGet прошло 16 дней. 37 коммитов, 201 тест. Без LLM это заняло бы в разы больше. Но то, что LLM не сделала сама, - кэширование в генераторе и отказ от лишних возможностей - как раз и определило, получилась библиотека или нет.

Главное, что я вынес из этой истории: тишина - худший вид ошибки, как было сказано в одной умной книге

crash, don’t trash

Мораль: если в проекте три DTO - пишите маппинг руками. Если их тридцать - пусть за ними следит сборка, а не пользователи в проде.

Код: https://github.com/gandjustas/FusionMapper

Пакет:

dotnet add package FusionMapper

Если найдёте случай, когда конвенции сломались молча - это баг по определению. Заводите issue на github.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.