Привет, Хабр! Меня зовут Сергей Сорокин, я .NET-разработчик с 12-летним стажем. Занимаюсь бэкендом, архитектурой и высокими нагрузками.

Знаю, о чем вы подумали, прочитав заголовок: "О боже, еще одна ORM? В 2025 году? Зачем, если есть Dapper и EF Core?".

Я тоже так думал. Но когда ты работаешь в Enterprise-системах, где производительность критична, а база данных — это не просто хранилище, а мощный инструмент обработки данных, стандартные решения начинают показывать свои слабые места.

Сегодня я хочу рассказать о Visor — ORM, которую я создал, чтобы превратить работу с базой данных в вызов типизированного API, убрать оверхед рефлексии и решить извечную боль с передачей списков (TVP) в SQL Server. А заодно показать, как Source Generators позволяют писать код, который работает быстрее, чем то, что вы пишете руками.

Философия: База данных как API

Давайте сразу расставим точки над «i». Я не призываю перенос��ть бизнес-логику в хранимые процедуры. Это плохая практика, которая ведет к боли при тестировании и масштабировании. Бизнес-логика должна жить в коде приложения.

Но есть нюанс.

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

Я пришёл к концепции, где База данных выступает как сервис (API).

  1. Endpoint — это хранимая процедура или функция.

  2. Contract — это сигнатура процедуры и Table-Valued Parameters (TVP) или композитные типы (Postgres).

При таком подходе происходит четкое разделение ответственности:

  • DBA / SQL Developer отвечает за план запроса, индексы и целостность данных. Он предоставляет нам идеальный "черный ящик" — процедуру.

  • Backend Developer отвечает за бизнес-процессы, оркестрацию и API для фронтенда. Мы не думаем, как база достает пользователей по email. Мы просто вызываем метод GetUserByEmail.

Золотое правило масштабирования

Чтобы не превратить базу в клубок спагетти, я следую строгому правилу: Никакой вложенности. Процедуры должны быть одноуровневыми. Одна процедура не вызывает другую. Это спасает от каскадного рефакторинга и позволяет менять реализацию внутри процедуры, не ломая контракт с бэкендом.

Почему Dapper и EF Core не подошли?

Я искал инструмент, который сочетал бы удобство интерфейсов (как Refit для HTTP) и максимальную произ��одительность (как ручной ADO.NET).

Претензия к Dapper: «Черный ящик» и боль с TVP

Dapper — это стандарт скорости, но:

  1. Runtime Reflection: Вся магия маппинга происходит в рантайме через IL Emit. Это «черный ящик». Вы не видите код маппинга, не можете его отладить. Ошибки (например, несовпадение типов) вылетают только при выполнении.

  2. Table-Valued Parameters (TVP): Это главная боль. Чтобы передать список объектов в процедуру, в Dapper нужно либо создавать DataTable (что дико аллоцирует память и медленно), либо писать вручную кастомные классы-наследники IEnumerable<SqlDataRecord>. Это тонна бойлерплейта.

  3. «Молчаливые» ошибки: Если вы переименовали колонку в базе, а в DTO забыли — Dapper часто просто оставит поле пустым (null или 0). В Enterprise это недопустимо. Мне нужен Strict Mapping — если колонки нет, приложение должно упасть с четкой ошибкой, а не работать с некорректными данными.

Претензия к EF Core: Слишком тяжелый

EF Core — отличный комбайн, но для работы с процедурами он избыточен.

  1. Overhead: ChangeTracker, построение графов, Snapshots. В моих тестах на массовых операциях EF потреблял в 60 раз больше памяти, чем наше решение.

  2. Второй сорт: Процедуры в EF всегда ощущаются как костыль сбоку от LINQ.

Visor: Концепция «White Box» (Прозрачный ящик)

Так родился Visor. Главная идея — Source Generators. Я переношу всю работу по созданию SQL-команд, параметров и маппингу результатов с этапа выполнения (Runtime) на этап компиляции (Compile Time).

Как это выглядит для разработчи��а?

Вы просто описываете интерфейс, как будто это REST-клиент:

[Visor(VisorProvider.SqlServer)]
public interface IUserRepository
{
    // Простой вызов
    [Endpoint("sp_GetUserById")]
    Task<UserDto> GetUserAsync(int id);

    // Массовая вставка (TVP)
    [Endpoint("sp_ImportUsers")]
    Task ImportUsersAsync(List<UserItemDto> users);
}

Во время сборки Visor генерирует класс UserRepository, который реализует этот интерфейс.

Киллер-фича: Zero Allocation TVP Streaming

Самое интересное — как мы реализовали передачу списков. Вместо создания DataTable (как это делают почти все), генератор создает код, который использует IEnumerable<SqlDataRecord> с yield return.

Что это дает? Мы стримим данные из вашего List<UserDto> напрямую в сетевой поток SQL Server. Без промежуточных буферов, без копирования массивов, без лишних аллокаций памяти.

Сгенерированный код выглядит примерно так (упрощенно):

private static IEnumerable<SqlDataRecord> MapToSqlDataRecord(IEnumerable<UserDto> rows)
{
    var record = new SqlDataRecord(metadata);
    foreach (var row in rows)
    {
        record.SetInt32(0, row.Id);
        record.SetString(1, row.Name);
        yield return record; // <--- Магия здесь
    }
}

Бенчмарки: Момент истины

Я сравнил вставку 10 000 записей в MS SQL Server через процедуру с TVP. Соперники:

  1. Visor (TVP Streaming)

  2. EF Core 10 (Bulk Insert / AddRange)

  3. Dapper (Стандартная вставка в цикле / Execute)

Результаты (BenchmarkDotNet):

Method

Time (Mean)

Memory Allocated

GC Gen0/1/2

Visor (TVP)

51.82 ms

1.07 MB

0 / 0 / 0

EF Core 10

517.73 ms

65.04 MB

8 / 3 / 1

Dapper

43,069.73 ms

15.34 MB

1 / 0 / 0

Выводы:

  • Visor быстрее EF Core в 10 раз. И потребляет в 60 раз меньше памяти.

  • Visor быстрее Dapper (loop) в 800 раз. Конечно, Dapper можно ускорить, если вручную реализовать SqlDataRecord, но Visor делает это за вас автоматически.

  • Zero GC: Обратите внимание на колонку GC. Visor не создал ни одного мусорного объекта в поколениях 0/1/2.

Итоги

Я создал инструмент не для того, чтобы «убить» EF Core или Dapper. Я создал его для конкретной ниши: High-Load Enterprise системы, где база данных используется на полную мощность через хранимые процедуры.

Что дает Visor:

  1. Скорость: Работает на уровне ручного ADO.NET.

  2. Надежность: Строгая типизация и валидация схемы на этапе компиляции.

  3. Чистота: Ваш код не зависит от деталей реализации доступа к данным.

  4. Мульти-провайдерность: Сейчас поддерживается MSSQL и PostgreSQL (да, там мы используем массивы и композитные типы, но API для разработчика остается тем же).

Проект полностью Open Source. Если вам близок подход «Database as an API» или вы просто хотите посмотреть, как работают Source Generators в .NET 10 — добро пожаловать в репозиторий.

[AcheronSoft/Visor: A high-performance, source-generated ORM for .NET that treats your Database as an API. Type-safe access to Stored Procedures without runtime reflection.]

Буду рад конструктивной критике и пул-реквестам!