Архитектурные стили
Как раскладывать код и сервисы: слои и чистая архитектура, монолит против микросервисов, DDD, CQRS и event sourcing, перенос legacy без большого переписывания. На собеседовании здесь проверяют не определения, а умение назвать цену каждого решения и условия, при которых оно окупается.
Перед этой темой: Внедрение зависимостей.
Чем слоёная архитектура отличается от чистой (гексагональной, onion)?
Заголовок раздела «Чем слоёная архитектура отличается от чистой (гексагональной, onion)?»Что это
Слоёная архитектура, сверху вниз; слой вызывает только нижний:
- Представление — контроллеры.
- Бизнес-логика — сервисы.
- Доступ к данным — репозитории, EF Core.
Чистая архитектура, от центра наружу; внешний слой зависит от внутреннего:
- Domain — сущности и правила, без зависимостей.
- Application — сценарии и интерфейсы-порты:
IOrderRepository. - Infrastructure — реализации портов: EF Core, SMTP.
- Web API — вызывает сценарии и связывает порты с реализациями.
Где живёт интерфейс репозитория
В слоёной — в слое данных, рядом с реализацией. В чистой — в ядре, рядом с тем, кто его вызывает.
Частая ошибка
Разложить код по папкам Domain и Infrastructure, но оставить в домене атрибуты EF Core. Зависимость всё равно направлена наружу.
Как устроено
- Порт — интерфейс, который объявляет ядро. Входной порт — сценарий, который вызывают снаружи. Выходной — то, что нужно ядру: хранилище, почта, внешний API.
- Адаптер — реализация порта под технологию. Контроллер — входной адаптер, репозиторий на EF Core — выходной.
- Направление. Ссылки между проектами идут внутрь, а вызовы во время работы — в обе стороны: ядро вызывает репозиторий через интерфейс. Связывает всё composition root в
Program.cs.
Когда что выбирать
| Ситуация | Стиль |
|---|---|
| CRUD, мало правил | слоёная или вертикальные срезы по фичам |
| Сложные правила, много внешних систем | clean или hexagonal |
| Инфраструктура будет меняться | порты и адаптеры на этой границе |
Ловушки
- Протекающий порт. Репозиторий возвращает
IQueryable<T>— ядро снова зависит от EF Core и от того, какие запросы он умеет переводить в SQL. - Интерфейс на каждый класс. Порт нужен на границе с внешним миром, а не между двумя классами домена.
- Generic-репозиторий поверх EF Core.
DbContextуже реализует unit of work, аDbSet<T>— репозиторий; обёрткаIRepository<T>сGetAllничего не добавляет.
Правило
Интерфейс объявляет тот, кто его использует; ссылки между проектами направлены к домену.
Под капотом
- Hexagonal — Алистер Кокберн, 2005: у приложения нет «верха» и «низа», есть ведущие порты (UI, тесты) и ведомые (база, очереди).
- Onion — Джеффри Палермо, 2008: концентрические слои, домен в центре.
- Clean — Роберт Мартин, 2012: Dependency Rule, зависимости в коде указывают только внутрь.
- Анемичная модель ортогональна стилю. Фаулер описывает богатую модель предметной области как часть доменного слоя слоёной архитектуры.
Цена
- Маппинг: доменная сущность, сущность EF Core и DTO API — три модели. Компромисс Microsoft в eShopOnWeb: доменные сущности маппятся через Fluent API в Infrastructure, без атрибутов в домене.
- Косвенность: путь от эндпоинта до SQL проходит через несколько проектов, отладка и навигация медленнее.
Компромиссы
- Небольшой сервис без сложных правил дешевле писать вертикальными срезами: эндпоинт, обработчик и запрос к
DbContextрядом. - Отдельный проект Domain без ссылок на пакеты стоит почти ничего и даёт главное — компилятор не пустит EF Core в домен.
Как проверить в проде
- В
Domain.csprojнетPackageReferenceна EF Core и ASP.NET Core. - Тесты архитектуры (NetArchTest, ArchUnitNET) в CI падают, если домен ссылается на инфраструктуру.
Итог: суть чистой архитектуры — направление зависимостей к домену; число слоёв и форма модели — отдельные решения со своей ценой.
using Shop.Application;using Shop.Infrastructure;
// Composition root: порт связывается с адаптером снаружи ядраvar handler = new PlaceOrderHandler(new ConsoleOrderRepository());handler.Handle(42, 3); // Saved Order { Id = 42, Quantity = 3 }
namespace Shop.Domain{ // Ядро: не ссылается ни на EF Core, ни на ASP.NET Core public record Order(int Id, int Quantity);}
namespace Shop.Application{ using Shop.Domain;
// Порт объявляет тот, кто его использует public interface IOrderRepository { void Save(Order order); }
public class PlaceOrderHandler(IOrderRepository repository) { public void Handle(int id, int quantity) => repository.Save(new Order(id, quantity)); }}
namespace Shop.Infrastructure{ using Shop.Application; using Shop.Domain;
// Адаптер реализует порт; в реальном коде здесь EF Core public class ConsoleOrderRepository : IOrderRepository { public void Save(Order order) => Console.WriteLine($"Saved {order}"); }}В решении каждое пространство имён — отдельный проект: Infrastructure ссылается на Application, Application — на Domain, обратных ссылок нет.
- Чем hexagonal отличается от onion и clean? — По сути ничем: одна идея «зависимости к домену», разная терминология и рисунок.
- Где должен лежать интерфейс репозитория? — В ядре, рядом с тем, кто его использует; реализация — в Infrastructure.
- Нужен ли репозиторий поверх EF Core? —
DbContextуже unit of work, аDbSet<T>— репозиторий; свой порт оправдан, если он описывает запросы домена, а не повторяетDbSet<T>. - Анемичная модель — признак слоёной архитектуры? — Нет, это свойство модели; богатый домен бывает в слоях, анемичный — в «чистом» решении.
- Как не дать домену зависеть от инфраструктуры? — Отдельный проект без ссылок на пакеты и тест архитектуры в CI.
Монолит, модульный монолит или микросервисы: что когда выбрать?
Заголовок раздела «Монолит, модульный монолит или микросервисы: что когда выбрать?»Что это
| Стиль | Как устроен |
|---|---|
| Монолит | одно приложение, одна база, один деплой |
| Модульный монолит | один деплой, код разбит на модули с границами |
| Микросервисы | много сервисов, у каждого своя база и свой деплой |
Зачем микросервисы
- Части системы выпускаются и масштабируются независимо.
- Команды не ждут друг друга: каждая отвечает за свои сервисы.
Чем платят
Вызов соседнего модуля превращается в сетевой запрос. Он может упасть, зависнуть или вернуть устаревшие данные, а одной транзакции на два сервиса уже нет.
Частая ошибка
Делить небольшое приложение одной команды на микросервисы «на вырост». Получается та же система, только медленнее и сложнее в отладке.
Как устроено
Модульный монолит:
- Модуль — вертикальная часть по бизнес-возможности: заказы, склад, оплата. Не технический слой.
- Наружу модуль открывает только контракт: интерфейс, DTO, события. Реализация —
internalв отдельной сборке. - Данные модуля — в своей схеме БД; чужие таблицы не читают и не джойнят.
- Границы проверяют тесты архитектуры, а не договорённости.
Когда что выбирать
| Признак | Модульный монолит | Микросервисы |
|---|---|---|
| Деплой | один на всё | независимый у сервиса |
| Вызов соседа | вызов метода | сеть: таймауты, повторы |
| Согласованность | одна транзакция БД | eventual consistency |
| Масштабирование | всё приложение | отдельный сервис |
| Отладка | один процесс | распределённая трассировка |
Ловушки
- Общая база у сервисов. Сервисы связаны схемой: изменить таблицу — значит выпустить всех её читателей.
- Границы по технике. Сервис «валидации» или сервис «доступа к БД» участвует в каждом запросе.
- Синхронные цепочки. A ждёт B, B ждёт C: задержки складываются, а отказ любого роняет всю цепочку.
Правило
Сервис выделяют, когда у части системы своя причина: нагрузка, цикл релизов, команда, требования к надёжности или безопасности.
Цена
- Сеть ненадёжна: нужны таймауты, повторы, идемпотентность.
- Доступность цепочки — произведение доступностей: три сервиса по 99,9% дают около 99,7%.
- Согласованность между базами — eventual: outbox, саги и компенсации (тема «Очереди и Kafka»).
- Инфраструктура на каждый сервис: конвейер выпуска, мониторинг, трассировка, версии контрактов.
Компромиссы
- Закон Конвея. Мелвин Конвей, 1968: структура системы копирует структуру коммуникаций. Сервисы без своей команды-владельца распадаются на распределённый монолит; поэтому иногда сначала меняют команды, а потом архитектуру.
- Monolith first. Фаулер советует начинать с монолита: верные границы видны только после того, как домен понят. Перенести границу между модулями — рефакторинг, между сервисами — миграция данных.
- Независимый деплой работает, только если контракты обратно совместимы и версионируются.
Как проверить в проде
- Сервисы всегда выпускаются вместе — это распределённый монолит.
- В трассировке видна глубина синхронных цепочек на запрос.
- Тесты архитектуры в CI ловят обращения модуля к внутренностям другого.
Итог: микросервисы — обмен простоты на независимость команд; без реальной потребности в этой независимости модульный монолит дешевле и проще в изменении.
using Orders;
var builder = WebApplication.CreateBuilder(args);
// Модуль регистрирует себя сам; другим модулям виден только контрактbuilder.Services.AddOrdersModule();
var app = builder.Build();app.MapGet("/orders/{id:int}", (int id, IOrdersApi orders) => orders.GetStatus(id));app.Run(); // GET /orders/42 → "Order 42: shipped"
namespace Orders{ // Публичный контракт модуля public interface IOrdersApi { string GetStatus(int id); }
// В реальном решении модуль — отдельный проект, // и internal закрывает реализацию от других модулей internal sealed class OrdersService : IOrdersApi { public string GetStatus(int id) => $"Order {id}: shipped"; }
public static class OrdersModule { public static IServiceCollection AddOrdersModule(this IServiceCollection services) => services.AddScoped<IOrdersApi, OrdersService>(); }}- Как проверить границы модулей в монолите? — Отдельные сборки с
internal, своя схема БД на модуль и тесты архитектуры в CI. - Что такое распределённый монолит? — Сервисы, которые нельзя выпускать по отдельности: общая база, синхронные цепочки, совместные релизы.
- Можно ли микросервисам делить одну базу? — Как переходное состояние — да, но тогда схема становится общим контрактом и независимый деплой теряется.
- Как закон Конвея влияет на архитектуру? — Границы сервисов повторяют границы команд; без владельца сервис расползается.
- Когда выделять модуль в сервис? — Когда у него своя нагрузка, свой цикл релизов, своя команда или свои требования к надёжности.
Что такое DDD и какие его идеи нужны на практике?
Заголовок раздела «Что такое DDD и какие его идеи нужны на практике?»Что это
DDD — подход из книги Эрика Эванса (2003): код описывает бизнес теми же словами и правилами, что и эксперты.
Главные идеи
- Единый язык. Если склад говорит «резерв» и «отгрузка», в коде те же
ReserveиShipment, а неUpdateStatus(3). - Ограниченный контекст. Граница, где у слова одно значение. «Товар» на складе — место и вес, в каталоге — описание и цена.
- Value object. Значение без идентичности:
Money,Address. Два объекта равны, если равны поля. - Агрегат. Группа объектов, которую меняют целиком через корень:
Orderи его строки.
Частая ошибка
Считать DDD набором папок Entities, ValueObjects, Repositories. Без единого языка и границ это просто структура проекта.
Как устроено
| Уровень | Что решает | Инструменты |
|---|---|---|
| Стратегический | как разрезать систему | поддомены, контексты, карта контекстов |
| Тактический | как моделировать внутри | сущности, value objects, агрегаты, события |
Агрегат:
- Корень — единственная точка входа:
order.AddLine(...), а неorder.Lines.Add(...). - Инварианты («сумма не больше кредитного лимита») проверяются в методах корня.
- Репозиторий — на агрегат, а не на таблицу: агрегат загружают и сохраняют целиком.
- Доменное событие (
OrderSubmitted) сообщает о факте, и другие агрегаты реагируют на него в своих транзакциях.
Ловушки
- Огромный агрегат. «Клиент со всеми заказами» грузится долго и постоянно ловит конфликты конкурентности.
- Публичные сеттеры. Инварианты расползаются по сервисам, и модель становится анемичной.
- Одна модель на всю компанию. Единый
Customerдля продаж, доставки и бухгалтерии обрастает полями, которые нужны только одному контексту.
Правило
Одна транзакция меняет один агрегат; другие агрегаты — по идентификатору и через события.
Карта контекстов
Карта описывает отношения между контекстами и командами:
| Отношение | Смысл |
|---|---|
| Customer/Supplier | поставщик учитывает нужды потребителя |
| Conformist | потребитель принимает чужую модель как есть |
| Anti-corruption layer | потребитель переводит чужую модель в свою |
| Open host service, published language | поставщик даёт стабильный публичный протокол |
| Shared kernel | две команды делят часть модели и меняют её вместе |
Цена
- Моделирование требует времени экспертов; без их участия получается модель разработчика, а не бизнеса.
- Value objects и закрытые коллекции требуют настройки EF Core: owned types, complex types (EF Core 8+), маппинг полей.
- Согласование агрегатов через события — eventual consistency. Интеграционные события в другие сервисы надёжно отправляют через outbox (тема «Очереди и Kafka»).
Компромиссы
- Эванс делит поддомены на core, supporting и generic. Полный тактический DDD — только для core; generic, например авторизацию, покупают.
- Вернон (Effective Aggregate Design): маленькие агрегаты, ссылки по Id, eventual consistency за границей агрегата. Если бизнес требует немедленной согласованности двух агрегатов, это повод пересмотреть границы.
Как проверить в проде
- Частые
DbUpdateConcurrencyExceptionна одном агрегате — он слишком большой или горячий. - На ревью видна транзакция, которая сохраняет несколько агрегатов сразу.
Итог: главное в DDD — границы контекстов и маленькие агрегаты с явными инвариантами; тактические паттерны применяют только там, где сложность домена их окупает.
var order = new Order(creditLimit: new Money(100m, "UAH"));order.AddLine("SKU-1", new Money(60m, "UAH"));
try { order.AddLine("SKU-2", new Money(50m, "UAH")); }catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); } // Credit limit exceeded
order.Submit();Console.WriteLine(order.Events[0]); // OrderSubmitted { OrderId = ... }Console.WriteLine(new Money(5m, "UAH") == new Money(5m, "UAH")); // True: равенство по значению
// Value object: неизменяемый record без идентичностиpublic record Money(decimal Amount, string Currency){ public Money Add(Money other) => other.Currency == Currency ? this with { Amount = Amount + other.Amount } : throw new InvalidOperationException("Currency mismatch");}
public record OrderSubmitted(Guid OrderId);
// Корень агрегата: состояние меняется только через методы, инварианты внутриpublic class Order(Money creditLimit){ private readonly List<Money> _lines = []; private readonly List<object> _events = [];
public Guid Id { get; } = Guid.NewGuid(); public IReadOnlyList<object> Events => _events;
public void AddLine(string sku, Money price) { var total = _lines.Aggregate(price, (sum, line) => sum.Add(line)); if (total.Amount > creditLimit.Amount) throw new InvalidOperationException("Credit limit exceeded"); _lines.Add(price); }
public void Submit() => _events.Add(new OrderSubmitted(Id)); // доменное событие}- Чем сущность отличается от value object? — У сущности есть идентичность, которая сохраняется при изменении полей; value object определяется только значениями.
- Почему агрегат должен быть маленьким? — Он грузится целиком и блокируется целиком; большой агрегат — медленная загрузка и конфликты конкурентности.
- Как связать два агрегата? — Ссылкой по Id и доменным событием; изменение второго — в своей транзакции.
- Что такое ограниченный контекст на практике? — Граница модуля или сервиса со своей моделью и своим языком.
- Чем доменное событие отличается от интеграционного? — Доменное живёт внутри контекста и процесса, интеграционное — публичный контракт для других сервисов.
Что такое CQRS и event sourcing и когда они оправданы?
Заголовок раздела «Что такое CQRS и event sourcing и когда они оправданы?»Что это
- Команда меняет состояние и почти ничего не возвращает:
PlaceOrder. - Запрос возвращает данные и ничего не меняет:
GetOrderSummary. - CQRS — у команд и запросов разные модели: для записи — доменная модель с правилами, для чтения — плоские DTO под экран.
- Event sourcing — вместо строки «баланс = 70» хранятся события
Deposited 100иWithdrawn 30; баланс получают, проигрывая их.
Зачем
- Чтение не тянет сложную модель записи и пишется простыми запросами.
- Event sourcing хранит историю каждого изменения.
Частая ошибка
Думать, что CQRS требует двух баз и очереди. Простейший CQRS — разные классы для команд и запросов над одной базой.
Как устроено
| Вариант | Чтение | Согласованность |
|---|---|---|
| Одна база, разные модели | проекции в DTO, AsNoTracking, Dapper |
сразу |
| Отдельная модель чтения | свои таблицы или база, обновляются по событиям | eventual |
| Event sourcing | проекции из потока событий | eventual |
Event sourcing по шагам:
- Команда загружает поток событий агрегата и восстанавливает его состояние.
- Агрегат проверяет инварианты и создаёт новые события.
- События дописываются в хранилище событий с проверкой ожидаемой версии потока — это оптимистичная конкурентность.
- Проекции читают новые события и обновляют модели чтения.
Ловушки
- Read-your-writes. Пользователь сохранил и не видит изменения в списке. Помогают ответ из самой команды, ожидание нужной версии проекции или оптимистичный UI.
- CRUD-события.
OrderUpdatedсо всеми полями теряет смысл; события называют бизнес-фактами в прошедшем времени:OrderShipped. - ES на всю систему. Справочники и настройки в событиях только усложняют код.
Правило
Начинайте с CQRS в одной базе. Отдельная модель чтения — при реальной разнице нагрузки или формы данных, ES — когда история нужна бизнесу.
Под капотом
- Хранилище событий — журнал потоков только на дозапись: KurrentDB (бывшая EventStoreDB), Marten поверх PostgreSQL или своя таблица с уникальным ключом (поток, версия).
- Снапшот — состояние на версии N; агрегат грузится из него плюс хвост событий. Нужда в снапшотах часто означает, что поток слишком длинный и его стоит закрывать по периодам.
- Новая модель чтения строится проигрыванием всех событий с начала; на больших объёмах это часы.
Цена
- Версионирование. События не меняют задним числом. Новые поля добавляют необязательными; несовместимое изменение — новый тип события или upcasting: старое событие при чтении превращают в новое.
- Персональные данные. Удаление по требованию закона конфликтует с неизменяемым журналом; такие данные хранят вне событий или шифруют ключом на человека и удаляют ключ.
Компромиссы
- MediatR не равен CQRS. CQRS — про раздельные модели, MediatR — паттерн Mediator: он доставляет сообщение обработчику внутри процесса. CQRS делают и обычными классами-обработчиками.
- Лицензия MediatR. 12.5.0 — последняя версия под Apache 2.0. С 13.0 (2 июля 2025) — двойная лицензия RPL 1.5 и коммерческая; Community-редакция бесплатна при выручке до 5 млн долларов в год и вне продакшена. Для новых проектов это повод взять свой тонкий диспетчер.
Как проверить в проде
- Отставание проекций: позиция последнего обработанного события против конца журнала — метрика с алертом.
- Длина потоков и время загрузки агрегата показывают, где нужны снапшоты.
- Частые конфликты ожидаемой версии — горячий поток.
Итог: CQRS и ES — инструменты для конкретного контекста с явной выгодой; их цена — eventual consistency, версионирование событий и эксплуатация проекций.
// Поток событий счёта — единственный источник правдыList<object> stream = [new Deposited(100m), new Withdrawn(30m)];
var account = new Account(stream); // состояние = проигранные событияaccount.Withdraw(50m);stream.AddRange(account.Changes); // в реальности — дозапись с ожидаемой версией
Console.WriteLine(account.Balance); // 20
// Проекция: модель чтения из тех же событийvar history = stream.Select(e => e switch{ Deposited d => $"+{d.Amount}", Withdrawn w => $"-{w.Amount}", _ => "?"});Console.WriteLine(string.Join(" ", history)); // +100 -30 -50
public record Deposited(decimal Amount);public record Withdrawn(decimal Amount);
public class Account{ public decimal Balance { get; private set; } public List<object> Changes { get; } = [];
public Account(IEnumerable<object> history) { foreach (var e in history) Apply(e); }
public void Withdraw(decimal amount) { if (amount > Balance) throw new InvalidOperationException("Insufficient funds"); var e = new Withdrawn(amount); Apply(e); Changes.Add(e); }
private void Apply(object e) => Balance += e switch { Deposited d => d.Amount, Withdrawn w => -w.Amount, _ => 0m };}- Обязательна ли отдельная база для CQRS? — Нет, первый уровень — разные модели и запросы над одной базой.
- Как пользователь увидит свои изменения, если модель чтения отстаёт? — Ответ из команды, ожидание версии проекции или оптимистичный UI.
- Как изменить формат события, которое уже записано? — Не менять: новые поля необязательные, несовместимое — новый тип события или upcasting при чтении.
- Зачем снапшоты? — Чтобы не проигрывать длинный поток при каждой загрузке агрегата.
- MediatR — это CQRS? — Нет, это внутрипроцессный диспетчер сообщений; с версии 13 ещё и с коммерческой лицензией.
Как постепенно мигрировать legacy-систему без большого переписывания?
Заголовок раздела «Как постепенно мигрировать legacy-систему без большого переписывания?»Что это
Большое переписывание — сделать новую систему с нуля и переключиться разом. Риск: годы без пользы для бизнеса, а старая система тем временем продолжает меняться.
Постепенная миграция (strangler fig, Мартин Фаулер, 2004) — новое растёт вокруг старого, пока старое не станет ненужным.
Как пользоваться
- Выбрать небольшую ценную функцию: отчёт, один экран.
- Описать её текущее поведение тестами.
- Реализовать её в новом сервисе.
- Переключить вызовы: в вебе — через прокси, в толстом клиенте — вызовом API вместо прямого SQL.
- Убедиться, что старым путём никто не пользуется, и удалить его.
Частая ошибка
Менять поведение одновременно с переносом. Сначала перенести как есть, потом улучшать, иначе непонятно, что сломала миграция.
Как устроено
- Фасад. YARP перед ASP.NET: перенесённые маршруты обслуживает ASP.NET Core, остальные
MapForwarderпересылает в старое приложение. Общие сессию и аутентификацию дают System.Web adapters. - Anti-corruption layer. Слой перевода между моделью legacy и новой: новый код не знает о таблице
T_ORD_HDRи флагах'Y'/'N'. - Характеризующие тесты (Майкл Фэзерс). Фиксируют, что код делает сейчас, а не что должен: реальные входные данные прогоняют через старый код и сохраняют результат как эталон.
- Feature toggles. Переключение на новую реализацию по пользователю, филиалу или проценту, с мгновенным откатом. В .NET —
Microsoft.FeatureManagement.
Толстый клиент
Функции настольного клиента переносят в сервисы: клиент вызывает HTTP API вместо SQL в базу. Обычно сначала переносят чтение, потом запись, и старую реализацию в клиенте отключают флагом.
Ловушки
- Синхронизация в обе стороны между старой и новой базой: конфликты и бесконечные циклы обновлений.
- Новый код читает legacy-таблицы напрямую. Старая модель протекает в новую.
- Вечные флаги. Каждый флаг удваивает число путей в коде; у флага должен быть срок удаления.
Правило
Каждый шаг обратим, у каждой таблицы — один пишущий владелец.
Данные в переходный период
- Общая база: новый сервис читает и пишет старые таблицы через ACL. Изменения схемы — только совместимые (изменение схемы без простоя).
- Владение переходит по таблицам: писатель один, другая сторона читает через представление, API или поток изменений.
- Отдельная база нового сервиса — когда старых писателей не осталось.
Цена
- Две системы работают и поддерживаются одновременно, исправления иногда делают в обеих.
- Синхронизация данных и общая аутентификация — отдельная инфраструктура на всё время миграции.
Компромиссы
- Параллельный запуск. Новая реализация считает рядом со старой, результаты сравниваются, а ответ отдаёт старая. Дорого, но снимает риск для расчётов: цен, остатков, начислений.
- Branch by abstraction. Когда фасад в сети поставить негде, абстракцию вводят внутри кода, переключают реализацию за ней и удаляют старую.
Как проверить в проде: готово к отключению
- По логам прокси на старые маршруты нет запросов дольше полного бизнес-цикла, включая месячные и годовые отчёты.
- Старых версий толстого клиента нет в телеметрии.
- Данные перенесены и сверены, старые писатели отключены.
- Флаги убраны, старый код и маршруты удалены.
Итог: миграция — управляемая последовательность обратимых шагов с одним владельцем данных и заранее заданными критериями отключения.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpForwarder();builder.Services.AddScoped<IOrderReport, NewOrderReport>();
var app = builder.Build();
// Перенесённая функция: её обслуживает новый код на ASP.NET Coreapp.MapGet("/reports/orders", (IOrderReport report) => report.Build());
// Всё, что ещё не перенесено, уходит в старое приложение на ASP.NETapp.MapForwarder("/{**catch-all}", "https://legacy.internal") .WithOrder(int.MaxValue) // самый низкий приоритет: сначала свои маршруты .ShortCircuit(); // пересылаемый запрос не идёт через остальной конвейер
app.Run();
public interface IOrderReport { string Build(); }
public class NewOrderReport : IOrderReport{ public string Build() => "Orders report from ASP.NET Core";}Перенос следующей функции — новый эндпоинт в ASP.NET Core: прокси сам перестанет отправлять этот маршрут в старое приложение.
- Почему не переписать всё с нуля? — Годы без пользы, старая система продолжает меняться, а забытое поведение всплывает после переключения.
- Что такое характеризующий тест? — Тест, фиксирующий фактическое поведение legacy, даже ошибочное; страховка при переносе.
- Можно ли новому сервису работать со старой базой? — Как переходное состояние — да, через ACL и с одним пишущим владельцем каждой таблицы.
- Как откатить перенесённую функцию? — Выключить флаг или вернуть маршрут в прокси; поэтому старый путь удаляют только после критериев отключения.
- Когда можно отключать старую часть? — Нет трафика дольше бизнес-цикла, нет старых клиентов, данные сверены, флаги и код удалены.