
Привет! Я Дмитрий, старший Angular-разработчик в команде Klecks — платформы для разметки данных AI-Центра Т-Банка.
Последние годы мы жили с NSwag: один api.ts на 13 000 строк, Docker-сборка бэкенда и ожидание по 15—17 минут каждый раз, когда менялся контракт. В рамках одной из задач мы решили перейти к модели, где OpenAPI-файлы лежат в репозитории, а клиент генерируется через ng-openapi-gen. Расскажу, какие проблемы мы пытались решить, что пробовали на стороне ASP.NET Core и какой workflow получился в итоге.
Почему старый подход перестал работать
По мере роста проекта фронтенд и бэкенд начинают все сильнее зависеть друг от друга. Контракты API меняются, появляются новые DTO, переименовываются поля и enum. В какой-то момент команда сталкивается с неприятной ситуацией: фронт узнает об изменениях слишком поздно. Бэкендер переименовал поле, изменил enum или URL — а проблема всплывает уже в QA или вообще в несвязанной задаче. Даже автоматически сгенерированный TypeScript-клиент не всегда спасает: его изменения сложно просматривать, а сам процесс генерации может превратиться в отдельный ритуал.
Для решения проблемы многие команды используют OpenAPI. Если коротко, OpenAPI — открытый стандарт описания HTTP API. Он позволяет хранить контракт между фронтендом и бэкендом в виде JSON- или YAML-файла, где описаны эндпоинты, параметры, модели и ответы.
Появление стандарта породило множество инструментов для работы с ним: генераторы документации, проверки совместимости и codegen-клиенты. В .NET-мире одно из самых популярных решений — NSwag, который умеет генерировать TypeScript-клиент для фронтенда.
Последние годы наш workflow был неизменным: один гигантский api.ts, томная сборка в Docker и ожидание по 15–17 минут при смене контракта. В какой-то момент стало понятно, что проблема уже не в скорости генерации, а в самом процессе работы с API.
Зачем трогать то, что работает
NSwag у нас был давно и формально справлялся: скрипт собирал весь бэкенд в Docker, NSwag читал DLL (скомпилированный код) и генерировал монолитный api.ts на 13 000 строк. Но по мере роста платформы этот процесс перестал быть комфортным.
Случайные поломки фронта. Бэкендер переименовал enum, DTO (Data Transfer Object) или URL, и фронт падал в несвязанной задаче. Контракт менялся незаметно, потому что diff в C# и diff в сгенерированном api.ts на ревью читать тяжело.
Долгая генерация. Чтобы обновить клиент, нужно было:
собрать бэкенд;
поднять Docker-контейнер с образом dotnet-node-nswag;
прогнать NSwag по скомпилированной DLL.
На Colima это легко превращалось в 15—17 минут ожидания. Команда сначала собирала бэкенд, потом поднимала customer_nswag и operator_nswag.
Размытый процесс. Контракт иногда описывали в Jira, иногда — нет. Даже когда описывали, реализация на бэке могла отличаться. Уточнения происходили в личных сообщениях, а фронт узнавал о некоторых изменениях уже постфактум.
Монолитный клиент. Customer api.ts занимал больше 13 000 строк. Один файл, десятки классов {Controller}Client, общий ClientBase с обработкой blob-ответов. Ревью, merge conflicts и навигация по коду постепенно превращались в боль.
Когда команда перешла на TBD, в день в мастер могли попадать контракты под разные задачи. Сама сборка отнимала много времени, и мы сформулировали цель:
контракт должен быть отдельным артефактом;
его должно быть удобно просматривать в MR;
фронтенд должен иметь возможность ревьюить изменения без знания C#;
генерация клиента должна быть быстрой локальной операцией.
Что пробовали на бэкенде и почему отказались от build-time
Гипотеза была простая: ASP.NET Core умеет в OpenAPI, значит, можно генерировать .json на этапе билда и коммитить в git.
Мы изучили встроенную генерацию OpenAPI в ASP.NET Core и генерацию документов при сборке. На практике build-time-подход не зашел:
Генератор пытается поднимать приложение в специальном режиме, а у нас в startup (запуск приложения) много зависимостей: секреты, Mongo, Kafka, PostrgeSQL. Пришлось бы перестраивать инициализацию.
Время сборки заметно вырастало: сравнили build-time-генерацию с выбранным компромиссным решением.
Инструмент ненадежен: зависания на mock-server, открытые баги в dotnet и aspnetcore.
Build-time-генерацию мы отвергли: слишком хрупкая и тяжелая для нашего стартапа. Вместо нее выбрали компромисс: OpenAPI отдается по HTTP-роуту в рантайме, а в dev-режиме hosted-сервис OpenApiContractExporter при старте приложения скачивает JSON с этого endpoint и кладет в open-api/ рядом с проектом.
На практике процесс выглядит так:
Бэкендер запускает приложение локально.
OpenAPI-файлы автоматически обновляются после старта сервиса.
Изменения попадают в git вместе с кодом контроллеров.
Регистрация выглядит так:
services.AddKlecksOpenApi("web-api", relativePath: [ "klecks-customer/web-api/", "klecks-customer/instruction/" ]); services.AddOpenApiContractExporter(config => { config.OutputDirectory = customerSettings.OpenApi.OutputDirectory; config.Locations = [ OpenApiExtensions.GetAbsoluteLocation( Ports.Customer, ApplicationPath, "customer-api" ), OpenApiExtensions.GetAbsoluteLocation( Ports.Customer, ApplicationPath, "web-api" ) ]; });
У Customer получилось два документа:
customer-api.json (~5 300 строк) — внешнее API для интеграций;
web-api.json (~7 900 строк) — API пользовательского интерфейса.
Swagger UI при этом никуда не делся и по-прежнему используется для ручного тестирования. OpenAPI же стал отдельным контрактом и источником генерации клиентского кода.
Трансформеры: делаем схему пригодной для codegen
Стандартный вывод Microsoft.AspNetCore.OpenApi оказался не совсем удобным для генерации TypeScript-клиента, поэтому мы добавили несколько трансформеров.
OperationIdTransformer. Для каждой операции нужен уникальный operationId, иначе ng-openapi-gen не сможет корректно именовать функции. Мы генерируем идентификаторы в формате: {Controller}-{Action}-{HttpMethod}
Например: Order-GetOrderDetails-GET
EnumAsStringSchemaTransformer. По умолчанию enum сериализуются как числа.
В результате diff становится практически нечитаемым: непонятно, что именно изменилось. Поэтому мы переводим enum в строки и используем camelCase-значения.
ContentTypeTransformer. OpenAPI по умолчанию добавляет несколько вариантов JSON-контента:
application/json
text/json
/+json
Процесс генерации приводит к раздуванию клиентского кода, поэтому мы оставляем только application/json.
ParameterNameTransformer. Приводим названия параметров к привычному для TypeScript виду: OrderId → orderId
MultipartFormTransformer. Здесь пришлось добавить workaround для известной проблемы ASP.NET Core. Multipart-запросы описывались как application/x-www-form-urlencoded, поэтому дополнительно патчить схему пришлось уже перед генерацией клиента.
Фронтенд: ng-openapi-gen вместо NSwag
На фронтенде мы тоже уперлись в ограничения старого подхода. NSwag генерировал один огромный файл, внутри которого находились десятки классов {Controller}Client, модели и общий ClientBase.
Типичный клиент выглядел так:
@Injectable() export class OrderClient extends ClientBase { getOrderDetails(orderId: string): Observable<OrderDetailsDto> { let url_ = this.baseUrl + "/klecks-customer/web-api/order/" + encodeURIComponent("" + orderId); // ... еще 20 строк на каждый метод } }
Старый подход на nswag работал, но со временем возникали проблемы:
один файл на тысячи строк;
сложные diff;
постоянные merge conflicts;
неудобная навигация по коду;
тяжелые импорты.
Мы решили попробовать ng-openapi-gen. Вместо монолитного клиента он генерирует отдельные модели и функции. В результате вместо одного файла получилось около 300 небольших файлов. Каждая операция превратилась в чистую функцию:
export function orderGetOrderDetailsGet( http: HttpClient, rootUrl: string, params: OrderGetOrderDetailsGet$Params, context?: HttpContext ): Observable<StrictHttpResponse<OrderDetailsDto>> { const rb = new RequestBuilder(rootUrl, orderGetOrderDetailsGet.PATH, 'get'); if (params) { rb.path('orderId', params.orderId, {}); } return http.request(rb.build({ responseType: 'json', accept: 'application/json', context })) // ... } orderGetOrderDetailsGet.PATH = '/klecks-customer/web-api/order/{orderId}';
Сначала подход с ng-openapi-gen показался непривычным, но довольно быстро стало понятно, что он хорошо масштабируется.
Раньше сервисы напрямую зависели от классов, которые генерировал NSwag.

ClientService — кастомный Handlebars-шаблон поверх стандартного ng-openapi-gen. Главное отличие от дефолта — единая обработка ошибок через наш ApiException, совместимый с тем, что был в NSwag-клиенте.
Из коробки ng-openapi-gen генерирует набор функций и сервисов, но нам хотелось сохранить единый способ обработки ошибок, который уже существовал в NSwag-клиенте. Поэтому поверх стандартного генератора мы сделали собственный Handlebars-шаблон и добавили ClientService.
В итоге получилась единая точка вызова API: return this.clientService.invoke(fn, params)
Через единую точку вызова проходят:
создание запросов;
обработка ошибок;
преобразование ответов;
работа с нашим ApiException.
Для остального приложения миграция оказалась почти прозрачной. Генерация клиента теперь занимает секунды.

Теперь все свелось к одной команде:
npm run generate:all:api # customer + operator, несколько секунд
Единственная проблема, которую мы не смогли решить средствами ASP.NET Core, связана с multipart-запросами. Из-за бага в OpenAPI-генерации в схемы попадали лишние свойства IFormFile: ContentType, FileName, Header, Length, Name и другие служебные поля.
В результате ng-openapi-gen создавал некорректные модели, поэтому перед запуском генератора мы добавили промежуточный шаг.
Скрипт generate-api-with-patch.js, при котором патч происходит автоматически и не влияет на сам контракт:
Читает OpenAPI-файлы из src/backend/.../open-api.
Исправляет проблемные multipart-схемы.
Создает временную копию JSON.
Запускает ng-openapi-gen.
Новый workflow с контрактами
После перехода на OpenAPI-файлы в git пришлось договориться о процессе работы. Сейчас он выглядит так.
Согласование контракта. На этапе проработки задачи бэкендер заводит отдельную подзадачу на контракт. Фронтенд и бэкенд заранее обсуждают:
новые поля;
enum;
breaking changes;
ожидаемые ответы.
Изменение API. После реализации бэкендер:
обновляет контроллеры;
запускает приложение;
получает обновленные OpenAPI-файлы;
коммитит JSON вместе с кодом.
Ревью фронтендом. В MR фронтендер назначается обязательным ревьюером через правило approvers.yml. Теперь он смотрит не diff в C# и не гигантский api.ts, а обычный diff в web-api.json. Если нужно, локально можно запустить npm run generate:customer:api и сразу проверить, как изменения повлияют на клиент.
Любые изменения контракта проходят тот же путь. Если API меняется уже в процессе разработки, создается отдельный MR. Фронтенд снова участвует в ревью и видит изменения до того, как они попадут в QA или production.
Со временем у нас появились несколько правил, которые оказались особенно важными:
исправление контракта — приоритетная задача;
для web-api стараемся сохранять обратную совместимость;
несогласованное переименование поля считается поломкой UI;
все breaking changes обсуждаются до мержа.
Правила добавили немного нагрузки на процесс, зато количество неожиданных поломок заметно уменьшилось.
Что получилось: цифры и trade-offs.
NSwag | ng-openapi-gen + OpenAPI в git | |
Время генерации клиента | 15—17 мин (Docker + сборка бэка) | Несколько секунд |
Размер артефакта на примере одного приложения (customer) | 1 файл, ~13 000 строк | ~300 файлов, diff по операциям |
Ревью контракта фронтом | diff в C# или гигантский api.ts | diff в web-api.json |
Зависимость фронта от бэка при codegen | Нужна скомпилированная DLL | Достаточно JSON из репозитория |
Плюсы, которые мы почувствовали:
фронт участвует в ревью контракта до мержа, а не узнает о поломке в QA-среде;
генерация клиента — часть фронтового workflow, без Docker;
OpenAPI-файл — живая документация, ее могут смотреть все участники процесса;
tree-shaking дружелюбнее: импортируешь только нужные функции из functions.ts.
Минусы и компромиссы, о которых стоит знать:
Multipart до сих пор требует патча — баг на стороне ASP.NET Core, не ng-openapi-gen.
Экспорт контракта в dev требует поднятого бэкенда — build-time мы отвергли сознательно. Но у этого компромисса есть обратная сторона: OpenAPI-файл не обновляется сам по факту правки в коде. Если бэкенд-разработчик отрефакторил DTO или переименовал поле, но не запустил приложение локально, в open-api/*.json ничего не изменится. Поломка всплывет позже: на ревью фронта, в CI или уже в несвязанной задаче.
Фронту нужно ревьюить MR бэкенда — это дополнительная нагрузка, но она окупается меньшим количеством «сюрпризов».
Миграция с {Controller}Client на ClientService.invoke(fn, params) — много однотипных правок по кодовой базе.
Заключение
Мы не искали лучший генератор ради генератора. У нас были другие цели:
сделать контракт прозрачным;
ускорить цикл взаимодействия между фронтендом и бэкендом;
перестать ловить случайные поломки после переименований на стороне API.
Переход от NSwag к OpenAPI-файлам в git и ng-openapi-gen позволил решить эти проблемы. Самый заметный эффект: генерация клиента перестала быть отдельным ритуалом с Docker и ожиданием по 15—17 минут. Но со временем стало понятно, что главный результат вообще не связан со скоростью. Мы изменили точку опоры.
Раньше фронтенд был привязан к реализации бэкенда: DLL, Docker, NSwag и внутренней структуре проекта. Теперь фронтенд работает с контрактом. Нам больше неважно, на чем написан бэкенд. Достаточно актуального OpenAPI-файла, и это открыло несколько интересных возможностей.
Контракт больше не зависит от структуры репозитория. Монорепозиторий или несколько репозиториев — не так важно. Достаточно получить web-api.json, выполнить npm run generate:customer:api — и можно работать дальше.
В будущем контракт вообще может храниться:
в отдельном репозитории;
S3;
Artifactory;
CI-артефактах.
Контракт можно вынести в отдельный процесс. OpenAPI-файлы могут стать единой точкой согласования между командами. Это позволяет делать отдельные MR на изменения API, автоматически назначать ревьюеров, проверять breaking changes и уведомлять заинтересованные команды. То есть API становится полноценным продуктом, а не побочным результатом сборки.
Документация перестает зависеть от Swagger UI. Один и тот же контракт можно показывать через Swagger UI, Redoc, генераторы документации и собственные внутренние порталы.
Контракт один, а способ его представления может быть любым. Конечно, компромиссы никуда не исчезли. Мы все еще обновляем OpenAPI при изменении DTO, патчим multipart-схемы и ревьюим изменения API с обеих сторон. Но для нас это оказалась разумная цена за то, что контракт стал явным артефактом, а не побочным продуктом сборки. И пожалуй, именно это изменение оказалось важнее, чем замена одного генератора на другой.
Если у вас похожий стек (Angular + ASP.NET Core) и NSwag или swagger-codegen, как у вас устроен процесс согласования контрактов? Генерируете на билде, коммитите OpenAPI в репозиторий или уже вынесли контракты отдельно? Поделитесь опытом в комментариях.
Материалы для дополнительного чтения

