Перейти к содержимому

Архитектурные стили

Как раскладывать код и сервисы: слои и чистая архитектура, монолит против микросервисов, DDD, CQRS и event sourcing, перенос legacy без большого переписывания. На собеседовании здесь проверяют не определения, а умение назвать цену каждого решения и условия, при которых оно окупается.

Перед этой темой: Внедрение зависимостей.

Junior+

Чем слоёная архитектура отличается от чистой (гексагональной, onion)?

Заголовок раздела «Чем слоёная архитектура отличается от чистой (гексагональной, onion)?»
Ответ

Что это

Слоёная архитектура, сверху вниз; слой вызывает только нижний:

  1. Представление — контроллеры.
  2. Бизнес-логика — сервисы.
  3. Доступ к данным — репозитории, EF Core.

Чистая архитектура, от центра наружу; внешний слой зависит от внутреннего:

  1. Domain — сущности и правила, без зависимостей.
  2. Application — сценарии и интерфейсы-порты: IOrderRepository.
  3. Infrastructure — реализации портов: EF Core, SMTP.
  4. 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, обратных ссылок нет.

Middle+

Монолит, модульный монолит или микросервисы: что когда выбрать?

Заголовок раздела «Монолит, модульный монолит или микросервисы: что когда выбрать?»
Ответ

Что это

Стиль Как устроен
Монолит одно приложение, одна база, один деплой
Модульный монолит один деплой, код разбит на модули с границами
Микросервисы много сервисов, у каждого своя база и свой деплой

Зачем микросервисы

  • Части системы выпускаются и масштабируются независимо.
  • Команды не ждут друг друга: каждая отвечает за свои сервисы.

Чем платят

Вызов соседнего модуля превращается в сетевой запрос. Он может упасть, зависнуть или вернуть устаревшие данные, а одной транзакции на два сервиса уже нет.

Частая ошибка

Делить небольшое приложение одной команды на микросервисы «на вырост». Получается та же система, только медленнее и сложнее в отладке.

Как устроено

Модульный монолит:

  • Модуль — вертикальная часть по бизнес-возможности: заказы, склад, оплата. Не технический слой.
  • Наружу модуль открывает только контракт: интерфейс, 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>();
}
}
Middle+

Что такое DDD и какие его идеи нужны на практике?

Заголовок раздела «Что такое DDD и какие его идеи нужны на практике?»
Ответ

Что это

DDD — подход из книги Эрика Эванса (2003): код описывает бизнес теми же словами и правилами, что и эксперты.

Главные идеи

  • Единый язык. Если склад говорит «резерв» и «отгрузка», в коде те же Reserve и Shipment, а не UpdateStatus(3).
  • Ограниченный контекст. Граница, где у слова одно значение. «Товар» на складе — место и вес, в каталоге — описание и цена.
  • Value object. Значение без идентичности: Money, Address. Два объекта равны, если равны поля.
  • Агрегат. Группа объектов, которую меняют целиком через корень: Order и его строки.

Частая ошибка

Считать DDD набором папок Entities, ValueObjects, Repositories. Без единого языка и границ это просто структура проекта.

Как устроено

Уровень Что решает Инструменты
Стратегический как разрезать систему поддомены, контексты, карта контекстов
Тактический как моделировать внутри сущности, value objects, агрегаты, события

Агрегат:

  1. Корень — единственная точка входа: order.AddLine(...), а не order.Lines.Add(...).
  2. Инварианты («сумма не больше кредитного лимита») проверяются в методах корня.
  3. Репозиторий — на агрегат, а не на таблицу: агрегат загружают и сохраняют целиком.
  4. Доменное событие (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)); // доменное событие
}
Senior+

Что такое 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 по шагам:

  1. Команда загружает поток событий агрегата и восстанавливает его состояние.
  2. Агрегат проверяет инварианты и создаёт новые события.
  3. События дописываются в хранилище событий с проверкой ожидаемой версии потока — это оптимистичная конкурентность.
  4. Проекции читают новые события и обновляют модели чтения.

Ловушки

  • 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
};
}
Senior+

Как постепенно мигрировать legacy-систему без большого переписывания?

Заголовок раздела «Как постепенно мигрировать legacy-систему без большого переписывания?»
Ответ

Что это

Большое переписывание — сделать новую систему с нуля и переключиться разом. Риск: годы без пользы для бизнеса, а старая система тем временем продолжает меняться.

Постепенная миграция (strangler fig, Мартин Фаулер, 2004) — новое растёт вокруг старого, пока старое не станет ненужным.

Как пользоваться

  1. Выбрать небольшую ценную функцию: отчёт, один экран.
  2. Описать её текущее поведение тестами.
  3. Реализовать её в новом сервисе.
  4. Переключить вызовы: в вебе — через прокси, в толстом клиенте — вызовом API вместо прямого SQL.
  5. Убедиться, что старым путём никто не пользуется, и удалить его.

Частая ошибка

Менять поведение одновременно с переносом. Сначала перенести как есть, потом улучшать, иначе непонятно, что сломала миграция.

Как устроено

  • Фасад. 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-таблицы напрямую. Старая модель протекает в новую.
  • Вечные флаги. Каждый флаг удваивает число путей в коде; у флага должен быть срок удаления.

Правило

Каждый шаг обратим, у каждой таблицы — один пишущий владелец.

Данные в переходный период

  1. Общая база: новый сервис читает и пишет старые таблицы через ACL. Изменения схемы — только совместимые (изменение схемы без простоя).
  2. Владение переходит по таблицам: писатель один, другая сторона читает через представление, API или поток изменений.
  3. Отдельная база нового сервиса — когда старых писателей не осталось.

Цена

  • Две системы работают и поддерживаются одновременно, исправления иногда делают в обеих.
  • Синхронизация данных и общая аутентификация — отдельная инфраструктура на всё время миграции.

Компромиссы

  • Параллельный запуск. Новая реализация считает рядом со старой, результаты сравниваются, а ответ отдаёт старая. Дорого, но снимает риск для расчётов: цен, остатков, начислений.
  • Branch by abstraction. Когда фасад в сети поставить негде, абстракцию вводят внутри кода, переключают реализацию за ней и удаляют старую.

Как проверить в проде: готово к отключению

  • По логам прокси на старые маршруты нет запросов дольше полного бизнес-цикла, включая месячные и годовые отчёты.
  • Старых версий толстого клиента нет в телеметрии.
  • Данные перенесены и сверены, старые писатели отключены.
  • Флаги убраны, старый код и маршруты удалены.

Итог: миграция — управляемая последовательность обратимых шагов с одним владельцем данных и заранее заданными критериями отключения.

Пример
Yarp.ReverseProxy
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpForwarder();
builder.Services.AddScoped<IOrderReport, NewOrderReport>();
var app = builder.Build();
// Перенесённая функция: её обслуживает новый код на ASP.NET Core
app.MapGet("/reports/orders", (IOrderReport report) => report.Build());
// Всё, что ещё не перенесено, уходит в старое приложение на ASP.NET
app.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: прокси сам перестанет отправлять этот маршрут в старое приложение.