Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт. В результате мы не просто мигрировали, а создали универсальный шаблон, который позволяет добавлять новые эндпоинты для справочников буквально за 5 минут, обеспечивая им из коробки пагинацию, фильтрацию, сортировку и валидацию

? Предисловие

Всем привет! Сегодня хочу поделиться нашим опытом масштабной миграции API для работы со справочными данными с устаревшего Loopback 3 на современный стэк — .NET WEB API на .Net Core 8.0.

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

? Часть 1: Основные цели миграции

? Программа-минимум

  • ? Уход с Loopback — полная замена устаревшего фреймворка на современный .NET стэк

? Желаемые характеристики решения

? Экономическая эффективность

  • ✅ Создание оптимального, поддерживаемого и удобного решения

  • ⚖️ Баланс между стоимостью разработки и качеством

  • ? При значительном удорожании — возможность переноса без оптимизаций

?️ Архитектурные требования

  • ? Единообразие API: единый принцип работы всех эндпоинтов

  • ? Универсальность клиентов: поддержка "тонких" и "толстых" клиентов

  • ? Серверная логика: вынос операций фильтрации и сортировки на сервер

? Технические требования

  • ? Минимизация эндпоинтов: один эндпоинт на сущность

  • ?️ Статус записей: явное указание isDeleted для каждой записи

  • Динамические операции: сортировка и фильтрация как бонус

? Стратегия развития

  • ? Эволюционный подход: возможность улучшения эндпоинтов

  • ? Масштабируемость: постепенное добавление функциональности

? Часть 2: Диагностика проблемы — что не так со старым Loopback API?

«Работало, но...» — классическая история технического долга

? Ключевые проблемы:

  1. ? Сложность кастомизации — для отличных от коробочных решений приходилось использовать довольно рискованные приемы с оверрайдом и хуками

  2. ? Ограниченная функциональность — отсутствие курсорной пагинации для больших наборов данных

  3. ?️ Устаревание стэка — слабеющая поддержка Loopback 3 сообществом

? Цель: Создать решение, где создание нового справочника сводилось бы к минимуму кода, а основные механики были вынесены в переиспользуемую базу.

? Часть 3: Поиск решения — от требований к архитектуре

? Ключевые требования и ограничения:

  • ? Единообразие — один контракт для всех эндпоинтов

  • ? Поддержка двух клиентов — для "тонких" и "толстых" клиентов

  • ? Экономия на поддержке — идеал: один эндпоинт на справочник

  • Производительность — трансляция запросов в эффективный SQL

  • ? Очевидность статуса — явное поле isDeleted в каждой записи

?️ Технологический стэк:

  • ASP.NET Core — основа для Web API

  • Entity Framework Core — работа с БД

  • FluentValidation — валидация параметров

  • System.Linq.Dynamic.Core — парсинг строковых фильтров и сортировок

  • MediatR.IMediator - посредничество между контроллером, запросом, валидатором, обработчиком и генератором результата

Исходя из предложенных требований и ограничений, был разработан подход на основе паттерна проектирования "Шаблонный метод" ("Template Method"). Его достоинство в том, что он предоставляет стандартную последовательность действий в рамках алгоритма выборки данных:

  • Валидация входных параметров

  • Конструирование запроса к базе данных

  • Обработка запроса, применение фильтров и генерация ответа

Для создания нового эндопойнта разработчику по большому счету нужно всего лишь подготовить структуру выходных данных (Data Transfer Object - DTO) и сконструировать запрос к данным. Всем остальным займется шаблон.

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

?️ Часть 4: Реализация — сердце универсального API

1. ? API Контракт: Один для всех

? Параметры запроса:

  • ? Пагинация: pageNumber, pageSize

  • ? Курсоры: after, before

  • ? Сортировка и фильтрация: sort, filter (в формате Dynamic LINQ)

> ⚠️ Важное правило: Пагинация и курсоры не могут быть использованы одновременно!

✅ * Пример запроса:

GET /api/cropsV2?pageNumber=1&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"

✅ Пример ответа:

{
  "meta": {
    "totalCount": 1234,
    "totalPages": 25,
    "currentPage": 1,
    "pageSize": 50
  },
  "links": {
    "self": "https://api.example.com/api/cropsV2?pageNumber=1&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"",
    "next": "https://api.example.com/api/cropsV2?pageNumber=2&pageSize=50&sort=updatedAt desc&filter=name=="Пшеница озимая"",
    "prev": null
  },
  "data": [
    {
      "id": "dc0e4f96-feba-11e4-9412-78acc0f90ffe",
      "name": "Пшеница озимая",
      "isDeleted": false,
      "updatedAt": 1650835441002
    },
    {
      "id": "ba4e4d78-feba-5faa-b317-92acd0f9078a",
      "name": "Пшеница озимая",
      "isDeleted": false,
      "updatedAt": 1650835440035
    },
    ...
  ]
}

2. ✅ *Базовые классы — фундамент системы

Четыре основных абстрактных класса:

  • GetPagesQuery - параметры запроса

  • GetPagesQueryValidator - валидация

  • GetPagesResponse - формат ответа

  • GetPagesQueryHandler - обработчик (шаблон "Template Method")

  • Cursor where TId : IComparable - базовый класс для DTO, поддерживающих курсоры

3. ✅ *Обработчик (Handler) — где происходит обработка

Алгоритм работы:

public virtual async Task> Handle(TQuery query, CancellationToken cancellationToken)
{
    Func<TQuery, CancellationToken, Task<Result<TDto>>> handlingMethod = query.IsPaging
        ? HandlePagingAsync
        : HandleBatchingAsync;

    return await handlingMethod(query, cancellationToken);
}

? HandlePagingAsync:

  • Применяет динамические Where и OrderBy

  • Выполняет пагинацию через Skip и Take

  • Рассчитывает totalCount и totalPages

  • Генерирует навигационные ссылки

? HandleBatchingAsync:

  • Использует курсоры для эффективной загрузки больших данных

  • Применяет сортировку по UpdatedAt, а потом - по Id

  • Выполняет пагинацию через Skip и Take

  • Рассчитывает totalCount и totalPages

  • Рассчитывает курсор. Формат курсора: Base64(updatedAt + "_" + id)

  • Генерирует навигационные ссылки

4. ✅ *Процесс добавления нового справочника

Чтобы применить подход к новой сущности, необходимо:

  • Создать DTO, унаследовав класс Cursor

  • Создать query-класс, унаследовав GetPagesQuery

  • Создать response-класс, унаследовав GetPagesResponse

  • Создать validator, унаследовав GetPagesQueryValidator

  • Создать handler, унаследовав GetPagesQueryHandler

? Минимальная реализация для нового справочника (пример):

public sealed record CropDto : Cursor
{ 
    public required string Name { get; init; }
    public required bool? IsDeleted { get; init; }
}    
public sealed record GetCropsQuery : GetPagesQuery
{
    public GetCropsQuery(
        int? PageNumber,
        string? After,
        string? Before,
        string? Sort,
        string? Filter,
        int PageSize)
        : base(
            PageNumber,
            After,
            Before,
            Sort,
            Filter,
            PageSize
        ) { }
}
public class GetCropsQueryValidator : GetPagesQueryValidator;
public sealed record GetCropsResponse : GetPagesResponse
{
    public GetCropsResponse(Meta meta, Links links, CropDto[] data)
        : base(meta, links, data) { }

public class GetCropsQueryHandler : GetPagesQueryHandler<CropDto, Guid, GetCropsResponse, GetCropsQuery>
{
    public GetCropsQueryHandler(EkocropDbContext context, KestrelConfiguration kestrelConfiguration, IHttpContextAccessor httpContextAccessor)
        : base(context, kestrelConfiguration, httpContextAccessor) { }

    protected override IQueryable<CropDto> GetBaseQuery(GetCropsQuery request)
    {
        return _context.Crops.Select(
            crop => new CropDto
                    {
                       Id = c.Id, // inherited from base class Cursor<T>
                       Name = c.Name,
                       IsDeleted = c.IsDeleted,
                       UpdatedAt = c.UpdatedAt.ToUnixTimeMilliseconds()  // inherited from base class Cursor<T>
                    });
    }
}

? Для сложных кейсов можно переопределить методы обработки:

protected override async Task<Result<TDto>> HandlePagingAsync(TQuery request, CancellationToken cancellationToken)
{
    var query = GetBaseQuery();
    
    // Особая фильтрация для конкретного справочника
    query = query.Where(c => c.Name.StartsWith("A"));
    
    return await base.HandlePagingAsync(request, cancellationToken);
}

? Часть 5: Подводные камни

Само собой, не обошлось без трудностей и выявленных ограничений.

? Производительность LINQ

  • Проблема: Не все LINQ-запросы эффективно транслируются в SQL, особенно при сложных проекциях

  • Решение: В крайних случаях выносили логику фильтрации на сторону БД через хранимки, но обычно хватало оптимизации через Select

? Ограничения фильтрации

  • Проблема: Dynamic LINQ хорошо работает только со свойствами верхнего уровня

  • Решение: Для фильтрации по вложенным объектам добавляли кастомные параметры в запрос

? Жесткие требования к модели

  • Проблема: Курсорная пагинация требует Id и UpdatedAt в каждой сущности

  • Решение: Пока принимаем это ограничение, но планируем кастомизацию курсоров

?️ Архитектурные компромиссы

  • Перегруженность DTO: Метаданные пагинации нерелевантны для курсоров и наоборот

  • Зависимость от MediatR: Пока не видим простого пути отказаться от него

? Часть 6: Дальнейшее развитие

Необходимо как следует проработать выявленные ограничения и добавить разработке гибкости:

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

  • Кастомизация курора. Отвзязаться от необходимости наличия свойств Id и UpdatedAt в доменной медели.

  • Отказ от Mediatr.

  • Динамический DTO в зависимости от типа запроса (курсор или пагинация).

? Часть 7: Результаты и выводы

? Что мы получили в итоге:

  • Скорость разработки - добавление нового справочника теперь занимает минуты вместо часов

  • ? Единообразие - все эндпоинты ведут себя предсказуемо по единому контракту

  • ? Гибкость - поддержка двух типов пагинации для разных сценариев использования

  • ? Производительность - вся фильтрация и сортировка выполняется на стороне СУБД

  • Снижение количества ошибок - централизованная валидация предотвращает некорректные запросы

  • ? Экономия на поддержке - один эндпоинт на сущность вместо 2-3

  • ? Легкость масштабирования - добавление нового фильтра/параметра тривиально и вписывается в базовый паттерн

  • ? Простота кастомизации - любую составляющую шаблонного метода можно кастомизировать под специфический кейс без нарушения логики основного алгоритма

? Сравнительная таблица: Loopback 3 vs Наше решение

Критерий

? Loopback 3

? Новый .NET API

Типы пагинации

skip/limit

pageNumber/pageSize + курсоры

Динамическая фильтрация

where (своя специфика)

filter (стандартный Dynamic LINQ)

Расширяемость

Миксины, часто хрупкие

Четкое наследование, шаблон "Template Method"

Поддержка

Сложная

Простая, за счет переиспользования кода

Кастомизация

Оверрайдинг, хуки

Переопределение составляющих "Template Method"

Валидация

Валидация для фильтров

Валидация входящих параметров и кастомных параметров через FluentValidation

? Примеры использования фильтров в реальных сценариях:

? Фильтрация по строковому свойству

GET /api/cropsV2?filter=name.Contains("Пшеница")

Комбинированный фильтр

GET /api/cropsV2?filter=name.Contains("Пшеница") &amp;&amp; updatedAt &gt; 1726652785811

Сортировка с фильтрацией

GET /api/cropsV2?sort=name asc,updatedAt desc&amp;filter=isDeleted==false

? Ключевые метрики успеха:

  • 90% сокращение времени на добавление нового справочника

  • Единый код валидации для всех эндпоинтов

  • Прямая трансляция фильтров в SQL-запросы

  • Нулевое дублирование бизнес-логики между справочниками

  • Предсказуемое поведение для фронтенд-разработчиков

Заключение

Миграция с устаревшего фреймворка — это не только боль, но и возможность «переизобрести» свой продукт, исправив старые архитектурные ошибки. Наш подход с универсальным обработчиком отлично масштабируется и доказывает свою эффективность.

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


  1. iamkisly
    07.11.2025 07:49

    Ощущение, что поток сознания пропустили через deepseek.chat ..у меня он так же засирает текст эмодзи, если его явно не просить не делать этого.