Всем привет! Я Игорь Эльяс — бэкенд-разработчик, сейчас работаю в МТС Веб Сервисы. Однажды мне досталась задача «перенести приложение в другую БД, за XX времени», где ХХ — короткий интервал с точки зрения потенциального объема работ при подходе «переделываем все по-нормальному — на Entity Framework» :-)
Проект на C#, изначальная БД — MS SQL + мы использовали самодельный ORM. Возможно, вы сейчас подумали — «очередное легаси!». И да и нет. Проект родился еще во времена Framework 2.0, но регулярно обновлялся и сейчас работает на .NET8. Он пережил десятки циклов рефакторинга, поэтому не безнадежен для доработок.
В материале расскажу, что мы в итоге решили со всем этим делать, почему и для чего выбрали именно Dapper, а еще — познакомлю вас с мощным, но малоизвестным инструментом Dynamitey, благодаря которому удалось выполнить все в срок и без лишний приключений.

Архитектура проекта и задача
Расскажу подробнее, что под капотом у проекта.
1. Классическая схема изоляции слоев кода Repository <-> Manager <-> Service.
2. Взаимодействие с БД локализовано на уровне Repository и там же происходит конвертация в row-level DTO, которые являются результатом выполнения метода. Трансформация данных на этом уровне минимальная, но все же есть (как же без размытия ответственности?).
ORM жестко завязан на функционал драйвера MS-SQL ADO.NET, использует SqlTypes в качестве целевых типов при передаче параметров, а также при считывании данных из DataReader. В процессе маппинга полей на свойства мы учитываем некоторые типы данных времени из NodaTime (Duration, Instant, LocalDateTime, etc.).
ORM трансформирует данные DataReader в row-level DTO's (есть механизм считывания множества датасетов) и возвращает Tuple.Row-level DTO следуют правилу имя_поля = имя_свойства, для полей в таблицах/table-udf/запросах MS-SQL использовалась pascal нотация.
В моделях row-level можно использовать атрибуты типа [SqlIgnoreIn], [SqlIgnoreOut], [SqlIgnore] — они регулируют использование свойств при конверсии объекта в параметры или в row-level DTO. А еще — атрибуты типа [SqlField("имя_поля")] для переименования.
3. Row-level DTO вынесены в отдельный проект Repository.Contracts, и между слоем Manager'ов и Repositor'иев ходят только простые типы и DTO, которые объявили в этом проекте. В исходной версии, конечно, все было не настолько хорошо, но мы и времени потратили не много, чтобы привести проект к такому виду.
4. Логика методов Repository соответствует шаблонному алгоритму: трансформируем входные объекты в параметры для ORM, дергаем хранимку/запрос — и получаем результат формируем Tuple/ValueTuple либо упаковываем результаты в какой-то композитный объект.
5. Уровень Manager трансформирует сырые объекты row-level in/out в объекты presentation-level, с которыми затем работает Service-слой. Помимо этого, используем специальный уровень композитных Manager'ов, которые управляют транзакциями. Здесь потребовалась доработка из-за интенсивного использования REPEATABLE READ. Ранее мы не учитывали, что для этого уровня изоляции PostgreSQL может выдавать ошибки при COMMIT’e транзакций.
6. Уровень Service также трансформирует данные перед выдачей в UI: в зависимости от прав пользователя некоторые поля могут скрываться.
Мне нужно было внедрить поддержку PostgreSQL и предусмотреть переход на Firebird в будущем. Это позволит предлагать более простое в обслуживании решение для клиентов с небольшими базами данных, без выделенного DBA.
Глубина зоны поражения, или Что мешало просто сделать миграцию

Во-первых, количество SP у нас превышало 300 штук, для многих из них требовалось больше 40 параметров — в основном TVP. Некоторые SP возвращали более 20 наборов строк, но данных в каждом датасете сравнительно немного.
Во-вторых, количество шаблонов для создания запросов на ходу — около 50, а количество параметров в районе 10. Генерация запросов непростая: нужно учитывать множество параметров, в шаблон по условиям добавляются те или иные сегменты JOIN и кусочки условий в WHERE-блоке.
В-третьих, количество row-level DTO больше 600.
Ну и в-четвертых, в row-level DTO мы активно использовали Enum C# от типа byte, а также поля TINYINT — набор битов, с помощью которых можно включать или выключать статусы. А вот в PostgreSQL типа TINYINT нет — мне предстояло заменить его на SMALLINT.
Было очевидно, что задача не из легких. Просто взять и перенести БД не получится. Нужно было учесть все вводные и ограничения, а еще придумать, как в таких условиях все сделать быстро.
Что и как решили поменять
В результате долгих дебатов, в которых мы периодически переходили от весомых аргументов к увесистым :), нам удалось прийти к компромиссу. Учли все возможные решения и ограничения как технического, так и административного характера (где самым главным как обычно было — скорость выполнения задачи).

Вот каким было наше финальное решение.
1. На этапе миграции БД:
Оставить прежней структуру БД в PostgreSQL и поменять только имена объектов из нотации pascal в snake.
SP с одним выходным датасетом превратить в табличную FUNCTION. SP c множеством датасетов сделать FUNCTION, которая будет возвращать SET OF CURSOR.
Переписать шаблоны для генерируемых запросов в терминах PostgreSQL и нотации snake.
TVP заменить на массив c использованием пользовательского complex_type.
Расширить некоторые числовые поля INTEGER -> BIGINT при миграции (в качестве закладки на будущее).
2. Использовать Dapper.Transaction в качестве ORM. Почему? Как уже было сказано, изначально в проекте использовали самодельный ORM, чем-то похожий на Dapper. Например, идеологически и синтаксически близким способом там выполняются:
вызовы SP и запросов в БД и получение результатов;
передача параметров (похоже на DynamicParameters, но в Dapper мощнее).
3. Запретить вносить специфику PostgreSQL/Firebird в уже существующие row-level-модели. Примеры:
Если Enum: byte в row-level DTO, он должен считываться из SMALLINT (short) в PostgreSQL.
GUID как CHAR (16) OCTETS (byte[]) из Firebird, тогда как в PostgreSQL есть специальный тип UUID.
4. Сделать отдельный проект для реализации репозиториев для PostgreSQL и отдельный для Firebird. Весь специфичный для той или иной БД код должен храниться только в здесь.
5. Запретить создавать новые row-level DTO, специфические для Dapper в паре с PostgreSQL или Firebird. Зачем? Это добавило бы еще более 600 классов для PostgreSQL и столько же для Firebird, почти идентичных существующим для MS SQL. Было решено, что это избыточно.
6. Разрешить создание специфических для PostgreSQL row-level-классов, только для созданных в БД комплексных типов при помощи CREATE TYPE, которые потом будут зарегистрированы в NpgsqlDataSource при помощи .MapComposite<>().
7. Включить обработку типов NodaTime. В проекте вся работа с временем реализована через NodaTime, самодельный ORM также корректно обрабатывает типы NodaTime для параметров и row-level DTO.
new NpgsqlDataSourceBuilder(connectionString).UseNodaTime().
Как использовали Dapper и почему выбрали Dynamitey
Dapper будет возвращать строку в виде внутреннего dynamic-объекта (DapperRow), где имена свойств соответствуют имени поля из БД. Для PostgreSQL и Firebird это будет нотация с нижним или верхним регистром в snake. Dapper возьмет на себя правильную трансформацию DbNull.
Передача объекта в качестве параметров: трансформировать исходный объект row-level в ExpandoObject, исходные имена полей — в нотацию snake (нижний регистр для PostgreSQL и верхний для Firebird), а затем использовать получившийся объект как набор параметров запроса или как порцию для Dapper.DynamicParameters().
Но чтобы все заработало как задумано, нужно было сделать простой маппер объектов dynamic->strong_type и strong_type->ExpandoObject, который включил бы в себя то, что не поддерживает Dapper из коробки:
Преобразование значения из SMALLINT в Enum: byte и обратно.
Преобразование VARCHAR() в Enum, причем в двух вариантах:
- строка из БД соответствует имени значения Enum,
- строка из БД соответствует значению из атрибута на значении Enum:
public enum LockEnum : byte { [EnumMember(Value = "S")] Shared, [EnumMember(Value = "X")] Exclusive }Перевод числовых полей в другой числовой тип, например, INTEGER -> long, BIGINT -> int и так далее. Это нужно, поскольку некоторые числовые поля в PostgreSQL и Firebird были увеличены.
Возможность указывать имя поля в БД вручную через атрибут, если оно не соответствует трансформации pascal-snake из имени свойства.
Возможность указывать при помощи атрибута, что свойство в целевом объекте row-level необходимо проигнорировать.
Возможность трансформировать поле с датой или временем из стандартных .NET DateTime, DateTimeOffset, Timespan в соответствующие типы NodaTime (LocalDateTime, Instant, Duration). Если для PostgreSQL читать такие типы автоматически после включения UseNodaTime() позволяет драйвер Npgsql, то для Firebird нужно делать преобразование самостоятельно.
Итого: нужно было сделать микромаппер «из» и «в» динамический объект. Для этих целей идеально подходил Dynamitey — мощный инструмент, с которым я познакомился, когда реализовывал движок исполнения выражений на CSharpScript для ETL. Тогда же я выяснил, что Dynamitey быстрее аналогичной операции при использовании рефлексии.
Тут же с его помощью можно было быстро реализовать маппинг плоских объектов с учетом всех требований, поскольку не пришлось бы погружаться в особенности C# как языка строгой типизации и представления типов Nullable.
Какие возможности Dynamitey нам были нужны:
1. Метод object Dynamic.CoerceConvert(object target, Type type) — позволяет преобразовать значение из target в тип из type с помощью DLR (dynamic language runtime) для принудительного преобразования типов. Метод подходит для преобразования short в Enum : Byte и наоборот.
2. Механизм Cacheable Invocation для всего остального:
new CacheableInvocation(InvocationKind.Convert, convertType: targetType) — конверсии типов по правилам DLR;
new CacheableInvocation(InvocationKind.Set, "MyProperty", context: typeof(T)); — чтобы установить значение свойства целевого объекта DTO;
new CacheableInvocation(InvocationKind.Get, "MyProperty", context: typeof(T)); — чтения значений из целевого объекта при построении ExpandoObject для параметров.
Реализация решения с помощью Dynamitey
Вот так по шагам выглядит трансформация исходного объекта строки (dynamic, DapperRow) в целево��:
Из целевого объекта берем имена свойств в нотации pascal и трансформируем их в нотацию snake. Разрешаем указывать другое имя поля при помощи атрибута.
Конвертируем считанное из DapperRow значение в целевой тип с использованием неявной конверсии (int->long) либо с применением принудительной конверсии (coerce).
Закладываем возможность реализовать собственный конвертер для определенного поля. Необходимо в случаях, например, когда значение Enum берется из строки, указанной в атрибуте.
Устанавливаем значения свойств целевого объекта.
Процесс трансформации исходного строгого типа в ExpandoObject для параметров выглядит так:
берем имена из оригинального объекта и трансформируем их в нотацию snake с возможностью изменить целевое имя атрибутом;
предусматриваем смену целевого типа через собственный конвертер;
устанавливаем значение свойства в ExpandoObject.
Понадобится два объекта для конверсии:
Конверсия средствами Dynamitey
public class DynamicConverter : IDynamicConverter { private readonly CacheableInvocation? _invocation; private readonly Type _targetType; public DynamicConverter(Type targetType, bool needsCoerceConvert = false) { _targetType = targetType; if (!needsCoerceConvert) _invocation = new CacheableInvocation(InvocationKind.Convert, convertType: targetType); } public dynamic? Convert(dynamic? source) { return source is null ? null : DirectConvert(source); } private dynamic DirectConvert(dynamic source) { return _invocation != null ? _invocation.Invoke(source) : Dynamic.CoerceConvert(source, _targetType); } }
Здесь используется new CacheableInvocation(InvocationKind.Convert, convertType: targetType) для конвертации исходного значения по правилам DLR и Dynamic.CoerceConvert(source, _targetType) для принудительной конверсии, для типов Enum, преобразований long-> int и из строки.
Конверсия через делегат
internal class DynamicConverterByDelegate(Func<dynamic, dynamic> converter) : IDynamicConverter { public dynamic? Convert(dynamic? source) { return source is null ? null : DirectConvert(source); } private dynamic DirectConvert(dynamic source) => converter(source); }
Копирование одного свойства в объект row-level с конверсией
public class PropertyCopyOperation<T>( string fromPropertyName, PropertyInfo setProperty, IDynamicConverter? converter ) { private readonly CacheableInvocation _getter = new(InvocationKind.Get, fromPropertyName); private readonly CacheableInvocation _setter = new(InvocationKind.Set, setProperty.Name, context: typeof(T)); public void Copy(dynamic from, T to) { var value = converter == null ? _getter.Invoke(from) : converter.Convert(_getter.Invoke(from)); _setter.Invoke(to, [value]); } }
Для считывания значения свойства исходный тип нам неизвестен, поэтому для CacheableInvocation мы не указываем контекст, а для целевого типа это row-level DTO. Мы его знаем, поэтому устанавливаем контекст, что помогает отлавливать некоторые проблемы.
Чтение из row-level DTO в ExpandoObject
public class PropertyGetOperation<T>( string targetPropertyName, PropertyInfo sourceProperty, IDynamicConverter? converter ) { private readonly CacheableInvocation _getter = new(InvocationKind.Get, sourceProperty.Name, context: typeof(T)); public string TargetPropertyName => targetPropertyName; public dynamic? Get(T row) => converter == null ? _getter.Invoke(row) : converter.Convert(_getter.Invoke(row)); }
Здесь известен исходный тип, потому передаем его для конструктора CacheableInvocation.
Создание и заполнение row-level DTO
public class PropertyMap<T>(IEnumerable<PropertyCopyOperation<T>> map) where T : class, new() { private readonly ImmutableArray<PropertyCopyOperation<T>> _map = [..map]; public T New(dynamic from) { if (from == null) throw new ArgumentNullException(nameof(from)); var instance = new T(); foreach (var operation in _map) { operation.Copy(from, instance); } return instance; } public IEnumerable<T> ToEnumerable(IEnumerable<dynamic> from) => from.Select(row => (T)New(row)); public T[] ToArray(IEnumerable<dynamic> rows) => ToEnumerable(rows).ToArray(); }
Создание и заполнение ExpandoObject для параметров
public class PropertyExpandoMap<T>(IEnumerable<PropertyGetOperation<T>> map) where T : class, new() { private readonly ImmutableDictionary<string, PropertyGetOperation<T>> _map = map.ToImmutableDictionary(k => k.TargetPropertyName, v => v); public ExpandoObject New(T from, ISet<string>? exclude = null) { IDictionary<string, object?> dict = new ExpandoObject(); foreach (var (propertyName, getOperation) in _map) { if (exclude?.Contains(propertyName) != true) { dict[propertyName] = getOperation.Get(from); } } return (ExpandoObject)dict; } }
Теперь самая интересная часть — как работают билдеры PropertyMapBuilder<T> и PropertyExpandoMapBuilder<T>:
1. Методы UseSnakeCase и UseCamelCase устанавливают конвертер имени свойства объекта row-level, без использования этих методов берется оригинальное имя.
2. Метод UseNamingAttribute позволяет использовать имя свойства, которое извлекается из свойства атрибута.
3. Метод UseIgnoreAttribute — свойство с указанным атрибутом будет проигнорировано при маппинге.
4. Метод AddConverter позволит задать собственный конвертер типа данных.
5. Атрибут [CoerceConvert] на свойстве активирует принудительную конверсию типа при считывании из источника.
PropertyMapBuilder<T>
Аналогичным образом строится PropertyExpandoMapBuilder, который можно посмотреть в исходниках.
public class PropertyMapBuilder<T> where T : class, new() { private Func<string, string> _nameTranslator = name => name; private readonly Dictionary<string, Func<dynamic, dynamic>> _customTypeConverters = new(); private Type? _ignoreAttributeType; private Func<Attribute, string>? _nameFromAttribute; private Type? _nameAttributeType; public PropertyMapBuilder<T> UseSnakeCase(bool useUpperCase = false) { if (useUpperCase) _nameTranslator = name => name.ToSnakeCase().ToUpper(); else _nameTranslator = name => name.ToSnakeCase(); return this; } public PropertyMapBuilder<T> UseCamelCase() { _nameTranslator = pascalName => pascalName.ToCamelCase(); return this; } public PropertyMapBuilder<T> AddConverter<TProperty>(string propertyName, Func<dynamic, TProperty> toConverter) { _customTypeConverters.Add(propertyName, c => toConverter(c)); return this; } public PropertyMapBuilder<T> UseNamingAttribute<TAttribute>(Func<TAttribute, string> nameTranslator) where TAttribute : Attribute { _nameAttributeType = typeof(TAttribute); _nameFromAttribute = attr => nameTranslator((TAttribute)attr); return this; } public PropertyMapBuilder<T> UseIgnoreAttribute<TAttribute>() where TAttribute : Attribute { _ignoreAttributeType = typeof(TAttribute); return this; } public PropertyMap<T> Build() { if (typeof(T).IsInterface) throw new ArgumentException($"{nameof(T)} interface type not supported."); LinkedList<PropertyCopyOperation<T>> map = new(); foreach (var property in typeof(T).GetPropertiesReadWrite(canWrite: true)) { if (property.GetAccessors().Any(v => v.IsStatic)) continue; if (_ignoreAttributeType != null && property.GetCustomAttribute(_ignoreAttributeType) != null) continue; var useCoerceConvert = property.GetCustomAttribute<CoerceConvertAttribute>() != null; IDynamicConverter setConverter = _customTypeConverters.TryGetValue(property.Name, out var typeConverter) ? new DynamicConverterByDelegate(typeConverter) : new DynamicConverter(property.PropertyType, useCoerceConvert || property.PropertyType.IsEnumOrNullableEnum()); if (_nameAttributeType != null && property.GetCustomAttribute(_nameAttributeType) != null) { var attribute = property.GetCustomAttribute(_nameAttributeType)!; var name = _nameFromAttribute!(attribute); map.AddLast(new PropertyCopyOperation<T>(name, property, setConverter)); } else { map.AddLast(new PropertyCopyOperation<T>(_nameTranslator(property.Name), property, setConverter)); } } return new PropertyMap<T>(map); } }
Пример готового объекта row-level с кейсами перехода в PostgreSQL
public class RowExample : IPropertyMap<RowExample> { public static PropertyMap<RowExample> Map { get; } = new PropertyMapBuilder<RowExample>() .UseSnakeCase() .AddConverter(nameof(StringEnumSpecial), p => EnumConverter.ToEnum<ExampleEnum, EnumMemberAttribute>((string)p, a => a.Value!)) .AddConverter<Instant>(nameof(DateTimeOffsetInstant), p => Instant.FromDateTimeOffset(p)) .AddConverter<Duration>(nameof(TimeSpanDuration), p => Duration.FromTimeSpan(p)) .UseNamingAttribute<DataMemberAttribute>(n => n.Name!) .UseIgnoreAttribute<IgnoreDataMemberAttribute>() .AddConverter(nameof(NullInstantDemo), p => Instant.FromDateTimeOffset(p)) .Build(); [CoerceConvert] public byte? ShortByte { get; set; } // неявная смена типа на INullable<Int16> public long IntegerLong { get; set; } [CoerceConvert] public int LongInt { get; set; } public string? Varchar { get; set; } [CoerceConvert] public float DecimalFloat { get; set; } public double FloatDouble { get; set; } public ExampleEnum StringEnumSpecial { get; set; } // для этого поля кастомный конвертер [CoerceConvert] public ExampleEnum? ShortEnum { get; set; } [CoerceConvert] public ExampleEnum? StringEnum { get; set; } public Instant? DateTimeOffsetInstant { get; set; } // для этого поля кастомный конвертер public Duration? TimeSpanDuration { get; set; } [DataMember(Name = "map_by_attribute_field")] // изменение имени свойства через атрибут public string? ByAttribute { get; set; } [IgnoreDataMember] public string? IgnoreField { get; set; } public Instant? NullInstantDemo { get; set; } = Instant.FromDateTimeOffset(DateTimeOffset.UtcNow); }
Пример теста
public static class RowFromExample { public static DateTimeOffset DateTimeOffset = new(new DateTime(new DateOnly(2025, 10, 10), new TimeOnly(12, 30, 33, 500)), TimeSpan.FromHours(1)); /// <summary> /// Создаем все необходимые варианты конверсии из динамического объекта. /// </summary> /// <returns></returns> public static ExpandoObject CreateDynamicRow() { dynamic r = new ExpandoObject(); r.short_byte = (short)255; // CoerceConvert r.integer_long = 123; // конверсия по правилам DLR r.long_int = 123L; // CoerceConvert r.varchar = "text"; // поле без конверсии r.decimal_float = 0.25m; // CoerceConvert r.float_double = 3.14f; // конверсия по правилам DLR r.string_enum_special = "X"; // конверсия в ModeEnum по атрибуту r.short_enum = (short)1; // прямая конверсия числа в ModeEnum r.string_enum = nameof(ExampleEnum.Shared);// Dynamic.CoerceConvert преобразует из строки // DateTimeOffset -> Instance r.date_time_offset_instant = DateTimeOffset; // TimeSpan -> Duration r.time_span_duration = TimeSpan.FromMinutes(12); r.map_by_attribute_field = "map_by_attribute_field"; r.ignore_field = "ignore_field"; // демонстрация - кастомный конвертер самостоятельно обрабатывает входные null r.null_instant_demo = null; return r; } } [TestClass] public class RowExamplesTest { [TestMethod] public void RowExample1Test() { dynamic src = RowFromExample.CreateDynamicRow(); RowExample t = RowExample.Map.New(src); Assert.AreEqual((byte)255, t.ShortByte); Assert.AreEqual(123, t.IntegerLong); Assert.AreEqual(123L, t.LongInt); Assert.AreEqual("text", t.Varchar); Assert.AreEqual(0.25f, t.DecimalFloat); Assert.AreEqual(3.14f, t.FloatDouble); Assert.AreEqual(ExampleEnum.Exclusive, t.StringEnumSpecial); Assert.AreEqual(ExampleEnum.Shared, t.ShortEnum); Assert.AreEqual(ExampleEnum.Shared, t.StringEnum); Assert.AreEqual(Instant.FromDateTimeOffset(RowFromExample.DateTimeOffset), t.DateTimeOffsetInstant); Assert.AreEqual(Duration.FromMinutes(12), t.TimeSpanDuration); Assert.AreEqual("map_by_attribute_field", t.ByAttribute); Assert.IsNull(t.IgnoreField); // значение проигнорировано Assert.IsNull(t.NullInstantDemo); // значение поля заменено на null } }
Реализация основной части идеи есть в проекте ProofOfConcept, а в исходниках — тесты с кейсами применения и примеры взаимодействия с PostgreSQL через Dapper.
Для целевого применения исходные классы из PoC пришлось расширить и доработать, а в Dapper добавить SqlMapper.TypeHandler'ов для NodaTime.
Что в итоге
Благодаря такому нестандартному подходу нам удалось:
1. Сохранить набор свойств всех объектов row-level. Добавились только статические поля in/out-маппера, билдер маппера и атрибуты, которые не влияют на старый код. Код уровня Manager’ов не изменился.
2. Сохранить логику старых методов репозиториев и последовательность операций из старого ORM, а также читаемость для сравнения на глаз: «было — стало», потому что текст исходного метода Repository очень близок к тому, что получилось для PostgreSQL.
3. Минимизировать количество ошибок при тестировании. Поскольку разработчики проверяли логику еще на этапе написания метода репозитория, все опечатки, несовпадения параметров и имен были сразу видны. Так 95% ошибок миграции мы сразу отправляли разработчикам БД.
А еще — уложиться в оговоренные сроки и оставить довольным заказчика и остальных участников процесса.
Проект продолжает работать, а мы продолжаем его поддерживать. В планах — отказаться от собственного ORM для MS-SQL и тем самым унифицировать кодовую базу при использовании всех БД. Вывод простой: если заглядывать в дополнительные возможности, искать новое и пробовать — иногда есть шанс придумать быстрое и элегантное решение непростой задачи.

