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

6.11. Конструкция из классов

Разработчику Архитектору Аналитику

Конструкция из классов

Проектирование программного обеспечения начинается с формирования мысленной и графической модели системы — её структуры, связей, ответственностей и границ. Класс в объектно-ориентированном программировании — это носитель поведения и состояния, инкапсулирующий определённую область ответственности. Совокупность классов, правильно организованных, образует конструкцию — устойчивую, поддерживаемую, масштабируемую систему. Построение такой конструкции требует системного подхода: от общего к частному, от целей к деталям, от архитектурного замысла к конкретному имени файла.

Конструкция из классов — это совокупность логически связанных типов, распределённых по иерархии пакетов (папок), объединённых общими принципами именования, назначения и взаимодействия. Она отражает функциональные и нефункциональные требования: поддерживаемость, тестируемость, расширяемость, безопасность. При этом важно понимать: никакой фреймворк, никакая технология не заменяют грамотного проектирования — они лишь обеспечивают среду, в которой эта конструкция может быть реализована.

Ниже рассматривается процесс конструирования на уровне кода — как из абстрактных требований и доменных понятий получаются конкретные классы, распределённые по структуре проекта, с обоснованием их назначения, именования и взаимного расположения. Акцент сделан на мышление проектировщика: на то, как он принимает решения, как структурирует знание о системе и как выражает его в коде.


Этап 1. Формирование высокоуровневой структуры: от системы к приложению

Прежде чем определять классы, необходимо чётко отделить систему от приложения. Система — это совокупность взаимодействующих программных продуктов (микросервисов, веб-интерфейсов, мобильных клиентов, фоновых обработчиков, интеграционных шлюзов), объединённых единой бизнес-целью. Приложение — один исполняемый артефакт в рамках этой системы: например, бэкенд-сервис, десктопное приложение или мобильное приложение.

Для одного приложения проектирование начинается с выделения уровней абстракции и слоёв ответственности. Несмотря на различия в технологиях (ASP.NET Core, Spring Boot, Electron, Flutter), общая структура слоёв сохраняется:

  • Точка входа — инициализация среды выполнения, регистрация зависимостей, запуск основного цикла.
  • Представление (Presentation) — интерфейс взаимодействия с внешним миром: HTTP-контроллеры, UI-компоненты, CLI-парсеры, WebSocket-хендлеры.
  • Прикладная логика (Application) — координация операций, управление транзакциями, реализация сценариев использования (use cases).
  • Доменная логика (Domain) — ядро бизнес-правил, сущности, агрегаты, события, спецификации.
  • Инфраструктура (Infrastructure) — реализация внешних зависимостей: работа с базой данных, отправка почты, вызов внешних API, кэширование, логирование.

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

Файловая система проекта — это внешняя проекция внутренней архитектуры. Распределение по папкам должно отражать назначение. Например, папка Services может содержать как UserService, так и EmailNotificationService, но если EmailNotificationService зависит от внешнего SMTP-провайдера, логичнее разместить его в Infrastructure/Notifications, а не в Application/Services. Структура папок — это система навигации для разработчика. Она должна отвечать на вопрос: «Где найти код, отвечающий за X?» без необходимости просматривать весь проект.


Этап 2. Определение точек входа и контекста выполнения

Каждое приложение имеет одну или несколько точек входа — мест, где начинается управление программой. Их реализация варьируется по технологиям, но смысл остаётся неизменным:

  • В консольном приложении на C# — это метод Main в классе Program.
  • В Spring Boot — это класс с аннотацией @SpringBootApplication, содержащий main.
  • В Electron-приложении — main.ts (главный процесс) и preload.ts (предзагрузка для рендер-процесса).
  • В мобильном приложении на Android — Application и Activity; на iOS — UIApplicationDelegate.

Точка входа — не просто «запускатор». Она отвечает за:

  • Инициализацию среды выполнения (настройка CLR/JVM, загрузка конфигурации).
  • Регистрацию компонентов в контейнере внедрения зависимостей (DI).
  • Запуск жизненного цикла приложения (сервер, UI-цикл, фоновые задачи).
  • Обработку сигналов завершения (graceful shutdown).

Поэтому, хотя класс точки входа часто минимален по объёму, его роль фундаментальна. Его размещают в корне проекта (src/, app/) или в отдельной папке Bootstrap, Startup, Entrypoint. Название должно быть однозначным: Program, Application, Main, Launcher. Избегают нейтральных имён вроде Start или Init — они не передают контекста.

Точка входа — это единственный класс, который может содержать побочные эффекты на глобальном уровне (например, чтение переменных окружения, подключение к БД на старте). Все остальные классы должны быть чистыми в том смысле, что их поведение зависит только от переданных зависимостей и параметров.


Этап 3. Идентификация и классификация типов классов

После выделения точки входа и слоёв переходят к идентификации типов классов, необходимых для реализации системы. Здесь важно не путать роль класса с его названием и не использовать термины как «шаблоны имён», а как средства выявления намерения проектировщика.

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

1. Интерфейсы и контракты

Интерфейс — это формализованное описание поведения, без привязки к реализации. Он определяет, что может делать компонент, но не как. Интерфейсы — основа гибкости и тестируемости.

Выделяют следующие виды интерфейсов:

  • Контракты поведения — описывают, какие операции поддерживает компонент: IUserService, IOrderRepository, IAuthorizationService. Их имена часто совпадают с именами реализаций, но с префиксом I (C#), Interface (Java — редко), или без префикса, если используется конвенция типа UserService (интерфейс) и DefaultUserService (реализация).

  • Фабрики и строители — отвечают за создание объектов с контролем процесса: IUserFactory, IReportBuilder. Позволяют изолировать логику конструирования от логики использования.

  • Стратегии — инкапсулируют взаимозаменяемые алгоритмы: ISortingStrategy, IPaymentProcessingStrategy. Позволяют выбирать поведение во время выполнения.

  • Наблюдатели и слушатели — определяют реакцию на события: IOrderPlacedListener, IUserRegisteredObserver. Обеспечивают слабую связанность между компонентами.

  • Команды — представляют операцию как объект: ICreateOrderCommand, ISendNotificationCommand. Позволяют параметризовать запросы, откладывать их выполнение, логировать, отменять.

  • Спецификации и валидаторы — выражают бизнес-правила в виде объектов: IUserRegistrationSpec, IOrderValidationRule. Поддерживают композицию правил («и», «или», «не»).

  • Мапперы и преобразователи — отвечают за преобразование данных между слоями: IUserToDtoMapper, IXmlToJsonConverter. Исключают прямую зависимость между моделями разных слоёв.

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

2. Реализации сервисов и логики

Если интерфейс — это обещание, то реализация — это исполнение. Классы этой группы содержат конкретную логику, но по-прежнему должны быть сфокусированы на одной ответственности.

  • Сервисы прикладного уровня — реализуют сценарии использования: OrderProcessingService, UserRegistrationUseCase. Они координируют работу нескольких доменных объектов, но не содержат бизнес-правил напрямую.

  • Репозитории и DAO — реализуют доступ к данным: SqlUserRepository, MongoOrderDao. Обычно работают с конкретной технологией (ORM, raw SQL), но скрывают её за интерфейсом.

  • Валидаторы — проверяют корректность входных данных: EmailFormatValidator, OrderAmountValidator. Могут быть простыми (одно правило) или составными (цепочка валидаторов).

  • Сервисы безопасностиJwtTokenService, RoleBasedAuthorizer. Отвечают за проверку подлинности и полномочий.

  • Логгеры и аудиторыFileAuditLogger, StructuredEventLogger. Генерируют записи для мониторинга и анализа.

  • Обработчики ошибокGlobalExceptionHandler, DomainErrorNotifier. Централизуют реакцию на исключения, преобразуют их в понятные клиенту сообщения.

Важно: реализация не должна зависеть от конкретных классов — только от интерфейсов. Это обеспечивает заменяемость (например, InMemoryUserRepository для тестов вместо SqlUserRepository).


Этап 4. Классы обработки событий и сообщений

Современные системы редко строятся как строго последовательные цепочки вызовов. Вместо этого они всё чаще используют асинхронное взаимодействие через события и сообщения. Это повышает отказоустойчивость, масштабируемость и слабую связанность. Соответственно, в конструкции появляются классы, отвечающие за публикацию, маршрутизацию и обработку таких сигналов.

Обработчики событий

Классы, реализующие реакцию на внутрисистемные события (например, «пользователь зарегистрирован», «заказ оплачен»), размещаются по принципу ответственности, а не источника события. Например:

  • UserRegisteredEmailHandler — отправляет приветственное письмо после регистрации.
  • OrderPaidInventoryUpdater — уменьшает остатки на складе при оплате заказа.
  • AuditLogEventListener — фиксирует факт события в журнал аудита.

Такие классы носят суффиксы Handler, Listener, Processor. Их размещают в папках, соответствующих слою:

  • Application/EventHandlers/ — если обработка включает бизнес-логику и координацию.
  • Infrastructure/EventHandlers/ — если действие заключается во внешнем взаимодействии (отправка письма, запись в лог).

Важно: один класс — одна реакция. Не следует создавать UserEventHandler, внутри которого ветвится логика по типу события. Это нарушает принцип единственной ответственности и затрудняет тестирование и эволюцию.

Диспетчеры и провайдеры событий

Классы, отвечающие за публикацию и маршрутизацию, такие как DomainEventDispatcher, MessageBus, EventPublisher, помещают в инфраструктурный слой. Они не знают, что происходит, но знают, кому уведомить. Часто реализуют паттерн Mediator или Publish-Subscribe.

Обработка внешних сообщений

Для интеграции с очередями (Kafka, RabbitMQ, Azure Service Bus) требуются:

  • OrderCreatedMessageConsumer — получает сообщение из очереди и преобразует его во внутреннее событие или команду.
  • NotificationEventProducer — отправляет данные во внешнюю систему.

Такие классы размещают в Infrastructure/Messaging/, с подпапками Consumers/, Producers/, Serializers/. Это подчёркивает, что работа с транспортом — это инфраструктурная деталь, а не часть бизнес-логики.

Ключевое правило: доменный слой не должен зависеть от инфраструктурных классов сообщений. Преобразование из/в доменные события происходит на границе — в адаптерах.


Этап 5. Классы взаимодействия с внешними системами

Интеграция с внешними сервисами (платёжные шлюзы, SMS-провайдеры, CRM, государственные системы) требует чёткой изоляции. Прямые вызовы HttpClient.PostAsync(...) внутри бизнес-логики — признак нарушенной архитектуры.

Вместо этого вводят:

  • Адаптеры внешних сервисовStripePaymentAdapter, SmscRuSmsSender, GosuslugiClient. Они реализуют единый интерфейс (например, IPaymentProvider, ISmsService) и инкапсулируют:

    • сериализацию/десериализацию,
    • обработку ошибок провайдера,
    • повторные попытки (retry),
    • логирование запросов/ответов.
  • Шлюзы — если интеграция сложна (например, агрегация данных из нескольких API), выделяют ExternalDataServiceGateway, который координирует работу нескольких адаптеров.

Такие классы размещают в Infrastructure/External/, с подпапками по направлениям: Payments/, Notifications/, Integrations/Gosuslugi/. Это позволяет:

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

Адаптер не должен возвращать DTO внешнего API напрямую. Он преобразует их во внутренние модели или исключения, понятные системе. Это предотвращает «утечку» внешней семантики внутрь домена.


Этап 6. Доменные модели: сущности, объекты-значения, агрегаты

Сердце любой системы — её предметная область. Именно здесь сосредоточены бизнес-правила, инварианты, жизненные циклы. Классы этой группы не являются «контейнерами данных» — они живут, меняют состояние, защищают целостность.

Сущности (Entities)

Объекты с уникальной идентичностью, существующие во времени: User, Order, Product.

  • Идентичность определяется идентификатором (ID).
  • Могут менять состояние, но сохраняют идентичность.
  • Ответственны за поддержание инвариантов внутри себя (например, «статус заказа может перейти из «Создан» только в «Оплачен» или «Отменён»»).

Размещают в Domain/Entities/. Имя — существительное, отражающее понятие предметной области.

Объекты-значения (Value Objects)

Объекты, идентифицируемые по значению: Money, Address, Email, FullName.

  • Иммутабельны: при изменении создаётся новый экземпляр.
  • Не имеют ID.
  • Сравнение — по содержимому, а не по ссылке.

Размещают в Domain/ValueObjects/. Имя — сочетание существительного и контекста: MonetaryAmount, PhysicalAddress. Избегают обобщённых имён вроде Data или Info.

Корни агрегатов (Aggregate Roots)

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

  • Пример: Order — корень агрегата, включающего OrderLine, Discount, ShippingAddress.
  • Все изменения в агрегате должны проходить через корень.
  • Границы агрегата определяют транзакционную область: одна транзакция — одна операция над корнем.

Размещают в Domain/Aggregates/, либо в Domain/ напрямую, если проект небольшой. Класс корня часто совпадает с именем агрегата (Order, а не OrderAggregate), если это не вызывает путаницы.

Представления (UI Models)

Классы для передачи данных в интерфейс: UserViewModel, OrderSummaryDto, DashboardMetrics.

  • Не содержат поведения — только данные.
  • Специфичны для сценария использования (не переиспользуются между разными экранами).
  • Преобразуются из доменных моделей через мапперы.

Размещают в Application/Dtos/ или Presentation/Models/, в зависимости от того, используется ли DTO в application-слое (например, в use case) или только в presentation (в контроллере).

Критически важно: никогда не возвращайте доменные сущности напрямую во внешний мир. Это нарушает инкапсуляцию и привязывает клиентов к внутренней структуре домена.


Этап 7. Классы работы с данными: парсинг, преобразование, загрузка

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

  • ПарсерыCsvUserImportParser, XmlInvoiceParser. Отвечают за чтение сырого текста и преобразование в структурированные объекты. Размещают в Application/Parsing/ или Infrastructure/Parsing/, в зависимости от сложности: если парсинг включает бизнес-валидацию — в application, если это техническая деталь (например, работа с библиотекой CsvHelper) — в infrastructure.

  • ФорматтерыHumanReadableDateFormatter, CurrencyAmountFormatter. Обратная операция к парсерам — превращение объектов в человекочитаемые строки. Часто зависят от локали, поэтому используют IFormatProvider или внедрённые сервисы локализации.

  • МапперыUserDomainToDtoMapper, OrderJsonToDomainConverter. Реализуют стратегию преобразования. В простых случаях — статические методы в утилитарных классах (UserMapper.MapToDto()); в сложных — отдельные классы с DI зависимостями (например, IUrlGenerator для формирования ссылок в DTO).

  • Загрузчики и сохранителиUserBatchImporter, ReportExporterToPdf. Выполняют операции ввода-вывода, часто с прогрессом, отменой, повторными попытками. Размещают в Application/ImportExport/, так как они реализуют сценарии использования.

  • АгрегаторыSalesReportAggregator, UserActivityAnalyzer. Выполняют аналитическую обработку: суммирование, группировка, фильтрация. Важно: они не обязаны использовать OLAP или BI-инструменты — могут работать с объектами в памяти, если объём данных небольшой.

Принцип: операции над данными должны быть явными и именованными. Не следует смешивать парсинг, валидацию и сохранение в одном методе ProcessFile(). Это затрудняет понимание, тестирование и повторное использование.


Этап 8. Инфраструктурные классы: управление ресурсами

Инфраструктурный слой — это «почва», на которой стоит приложение. Он обеспечивает взаимодействие с внешним миром, но не принимает бизнес-решений. Классы здесь технические, но критически важные.

  • Менеджеры соединенийDatabaseConnectionPool, HttpClientFactoryWrapper. Управляют жизненным циклом ресурсов: создание, повторное использование, закрытие. В современных фреймворках (например, IHttpClientFactory в ASP.NET) многое уже реализовано, но обёртки позволяют добавить логирование, метрики, политики.

  • МиграцииDatabaseSchemaMigrator, DataFixerV2. Отвечают за эволюцию схемы и данных во времени. Обычно реализуются через инструменты (EF Core Migrations, Flyway), но логика применения миграций (например, проверка версии, откат) может быть инкапсулирована в MigrationRunner.

  • КэшированиеInMemoryUserCache, RedisOrderCacheAdapter. Важно разделять стратегию (ICache<T>) и реализацию (RedisCache). Классы кэширования часто декорируют другие сервисы: CachedUserService : IUserService, делегируя вызовы и кэшируя результат.

  • Планировщики задачBackgroundJobScheduler, RecurringTaskRunner. Организуют выполнение фоновых операций. В зависимости от масштаба — от System.Threading.Timer до Quartz.NET или Hangfire.

Все эти классы размещают в Infrastructure/, с подпапками: Persistence/, Caching/, Scheduling/, Connections/. Это позволяет легко заменить, например, Redis на Memcached — изменения затронут только одну папку.


Этап 9. Классы для тестирования

Тесты — неотъемлемая часть кода. Конструкция должна поддерживать тестирование на всех уровнях.

  • Тестовые классыUserRegistrationSpec, OrderProcessingUnitTest. Имена отражают поведение: не UserServiceTest, а WhenUserRegisters_ThenWelcomeEmailIsSent. Размещают в отдельном тестовом проекте, с зеркальной структурой: Tests/Application/UseCases/, Tests/Domain/Entities/.

  • Фикстуры и заглушкиTestUserFactory, InMemoryOrderRepositoryStub, FakeEmailService. Позволяют изолировать тестируемый компонент. Важно: заглушка (Stub) отдаёт фиксированный ответ, шпион (Spy) записывает факты вызова, мок (Mock) проверяет взаимодействие. Каждый инструмент — для своей цели.

  • Запуск тестовTestSuiteRunner, IntegrationTestBase. Базовые классы для настройки окружения (база данных в памяти, DI-контейнер) выносят в общие утилиты.

Ключевое правило: если класс нельзя протестировать изолированно, он спроектирован неправильно. Слишком много зависимостей, побочные эффекты, глобальное состояние — всё это препятствует тестированию и указывает на необходимость рефакторинга.


Этап 10. Утилиты и вспомогательные классы

Здесь сосредоточены «помоники» — классы, которые не несут бизнес-смысла, но упрощают разработку.

  • ХелперыStringExtensions, DateTimeHelper. Должны быть:

    • идемпотентными (одинаковый вход → одинаковый выход),
    • чистыми (без побочных эффектов),
    • общими (применимы в нескольких местах).

    Размещают в Common/Helpers/ или Infrastructure/Utils/. Избегают классов вроде GeneralHelper — каждая утилита должна иметь узкую специализацию.

  • КонфигурацияAppSettings, DatabaseConfig, FeatureFlags. Представляют настройки как объекты, а не как строки из appsettings.json. Это позволяет:

    • валидировать конфигурацию при старте,
    • внедрять зависимости по интерфейсам (IDatabaseConfig),
    • использовать строго типизированные параметры.
  • ИнициализаторыServiceCollectionExtensions, ApplicationBuilderExtensions. В ASP.NET Core — это методы AddMyServices(), UseRequestLogging(). Они группируют регистрацию зависимостей и middleware, повышая читаемость Program.cs.

Осторожно: чрезмерное увлечение утилитами ведёт к «библиотеке всего». Если хелпер используется только в одном месте — он не нужен. Хелпер оправдан, когда снижает когнитивную нагрузку, а не просто «скрывает строку кода».


Принципы именования: формула <Контекст> + <Роль> + <Тип>

Как уже отмечалось, имя класса — это документация. Оно должно отвечать на три вопроса:

  1. О чём? (Контекст) — User, Order, Payment, Report.
  2. Что делает? (Роль) — Service, Validator, Sender, Parser, Factory.
  3. Как устроен? (Тип/паттерн) — Impl, Adapter, Decorator, Stub, Handler.

Примеры корректных имён:

  • PaymentValidationRule — правило валидации для оплаты (контекст + роль).
  • EmailNotificationAdapter — адаптер для отправки email (роль + тип уточняет паттерн).
  • UserRegistrationCommandHandler — обработчик команды регистрации (контекст + роль + тип).
  • InMemoryOrderRepository — реализация репозитория в памяти (тип + контекст + роль).

Недопустимые практики:

  • UsrSrv — сокращение до нечитаемости.
  • Manager, Processor, Handler без контекста — OrderManager может означать что угодно.
  • Helper в конце без уточнения — UserHelper неясен; UserPasswordHasher — понятен.

Порядок слов: Контекст → Роль → Тип.
UserService — правильно.
ServiceUser — воспринимается как «сервис пользователя» (например, выделенный сервер), а не «сервис для работы с пользователями».


Структурирование по папкам: логика, а не технология

Файловая структура — это архитектурная карта. Она должна отражать намерения проектировщика, а не особенности фреймворка.

Правильный подход:

src/
├── Domain/
│ ├── Entities/
│ ├── ValueObjects/
│ ├── Aggregates/
│ └── Events/
├── Application/
│ ├── UseCases/
│ ├── Dtos/
│ └── EventHandlers/
├── Infrastructure/
│ ├── Persistence/
│ ├── External/
│ │ ├── Payments/
│ │ └── Notifications/
│ ├── Caching/
│ └── Messaging/
├── Presentation/
│ ├── WebApi/
│ ├── Web/
│ └── Mobile/
└── Common/
├── Helpers/
└── Extensions/

Неправильный подход (технологический):

src/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
└── Utils/

Во втором случае невозможно определить, к какому слою относится UserService — он может содержать и доменную логику, и инфраструктурные вызовы. В первом — границы чёткие.

Для многоуровневых систем (например, микросервисов) верхний уровень — граница системы:

services/
├── user-service/
├── order-service/
└── notification-service/

Внутри каждого — своя конструкция из классов, по той же схеме.


Практические сценарии построения конструкции

Сценарий 1. Бэкенд-сервис на ASP.NET Core (C#)

Рассмотрим типичное веб-API для управления пользователями и заказами. Система состоит из одного исполняемого приложения, реализующего REST-интерфейс, бизнес-логику и доступ к базе данных PostgreSQL.

Файловая структура

src/
└── UserService.Api/ ← точка входа, Presentation
├── Program.cs
├── appsettings.json
└── Properties/launchSettings.json

└── Presentation/
└── WebApi/
├── Controllers/
│ ├── UsersController.cs
│ └── OrdersController.cs
└── Dtos/
├── UserDto.cs
└── OrderSummaryDto.cs

└── Application/
├── UseCases/
│ ├── RegisterUser/
│ │ ├── RegisterUserCommand.cs
│ │ ├── RegisterUserCommandHandler.cs
│ │ └── RegisterUserResult.cs
│ └── PlaceOrder/
│ ├── PlaceOrderCommand.cs
│ └── PlaceOrderCommandHandler.cs
├── EventHandlers/
│ └── UserRegistered/
│ ├── SendWelcomeEmailHandler.cs
│ └── CreateUserProfileHandler.cs
└── Dtos/ ← внутренние DTO для use case
└── OrderItemInput.cs

└── Domain/
├── Entities/
│ ├── User.cs
│ └── Order.cs
├── ValueObjects/
│ ├── Email.cs
│ └── FullName.cs
├── Aggregates/
│ └── OrderAggregate.cs ← корень агрегата
└── Events/
└── UserRegisteredEvent.cs

└── Infrastructure/
├── Persistence/
│ ├── ApplicationDbContext.cs
│ ├── Repositories/
│ │ ├── EfUserRepository.cs
│ │ └── EfOrderRepository.cs
│ └── Migrations/
├── External/
│ └── Notifications/
│ ├── EmailService/
│ │ ├── SmtpEmailSender.cs
│ │ └── IEmailSender.cs
│ └── SmsService/
│ ├── SmscRuClient.cs
│ └── ISmsService.cs
├── Caching/
│ └── InMemoryUserCache.cs
└── DependencyInjection/
└── ServiceCollectionExtensions.cs

└── Common/
├── Helpers/
│ └── PasswordHasher.cs
└── Exceptions/
└── DomainException.cs

Ключевые архитектурные решения

  1. Разделение сборок по слоям — хотя весь код в одном проекте, логическое разделение строго соблюдено:

    • Domain не ссылается ни на что, кроме Common.
    • Application зависит от Domain, но не от Infrastructure.
    • Infrastructure зависит от Application и Domain.
    • Presentation зависит от всех слоёв.

    Это обеспечивает зависимость внутрь (Dependency Rule из Clean Architecture).

  2. Use Case как единица бизнес-операции — каждая операция (регистрация, оформление заказа) выделена в отдельную папку с командой, обработчиком и результатом. Это:

    • упрощает тестирование (один тестовый класс на сценарий),
    • позволяет вносить изменения без поиска по всему проекту,
    • поддерживает CQRS (Command Query Responsibility Segregation) в минимальной форме.
  3. Интерфейсы в Infrastructure, реализации — тоже в Infrastructure — например, IEmailSender объявлен в Infrastructure/External/Notifications/EmailService/, а не в Application. Почему? Потому что отправка email — инфраструктурная деталь: домен не знает, как именно уведомление доставляется. Если бы IEmailSender был в Application, это означало бы, что прикладной слой навязывает способ взаимодействия, что нарушает инверсию зависимостей.

  4. DTO в двух местах:

    • Presentation/Dtos/ — для сериализации в JSON (публичный контракт API),
    • Application/Dtos/ — для передачи данных внутри use case (внутренний контракт).

    Разделение предотвращает утечку представления внутрь логики.

  5. Регистрация зависимостей в одном местеServiceCollectionExtensions.cs содержит методы:

    public static IServiceCollection AddApplicationServices(this IServiceCollection services)
    public static IServiceCollection AddInfrastructureServices(this IServiceCollection services, IConfiguration config)

    Это делает Program.cs компактным и повторно используемым.


Сценарий 2. Микросервис на Spring Boot (Java)

Теперь рассмотрим микросервис обработки платежей, интегрирующийся с внешним платёжным шлюзом и публикующий события в Kafka. Здесь важно подчеркнуть границы контекста и изоляцию интеграций.

Файловая структура

src/main/java/com/company/paymentservice/
├── PaymentServiceApplication.java ← точка входа

├── domain/
│ ├── model/
│ │ ├── Payment.java
│ │ └── PaymentStatus.java
│ ├── event/
│ │ └── PaymentProcessedEvent.java
│ └── service/
│ └── PaymentProcessingService.java ← чистая бизнес-логика

├── application/
│ ├── port/
│ │ ├── in/ ← входные порты (use cases)
│ │ │ └── ProcessPaymentUseCase.java
│ │ └── out/ ← выходные порты (интерфейсы)
│ │ ├── PaymentRepository.java
│ │ ├── PaymentGateway.java
│ │ └── EventPublisher.java
│ └── service/
│ └── ProcessPaymentService.java ← реализация use case

├── adapter/
│ ├── in/
│ │ └── web/
│ │ ├── PaymentController.java
│ │ └── dto/
│ │ └── ProcessPaymentRequest.java
│ └── out/
│ ├── persistence/
│ │ └── JpaPaymentRepository.java
│ ├── gateway/
│ │ └── stripe/
│ │ ├── StripePaymentGatewayAdapter.java
│ │ └── StripeClient.java
│ └── messaging/
│ └── KafkaEventPublisher.java

├── config/
│ ├── BeanConfiguration.java
│ └── KafkaConfiguration.java

└── common/
├── exception/
│ └── PaymentException.java
└── util/
└── IdGenerator.java

Ключевые архитектурные решения

  1. Гексагональная архитектура (Ports & Adapters) — явное выделение:

    • Портов (application/port/) — интерфейсов, определяющих, что система может делать (вход) и от чего зависит (выход),
    • Адаптеров (adapter/) — реализаций этих портов для конкретных технологий.

    Это позволяет запустить сервис без веб-слоя (например, для тестов или CLI), подменив WebPaymentController на ConsolePaymentAdapter.

  2. Внешние интеграции — в adapter/out/gateway/StripePaymentGatewayAdapter реализует PaymentGateway, но скрывает:

    • формат запросов к Stripe API,
    • обработку ошибок (например, StripeCardExceptionPaymentException),
    • маппинг между моделями Stripe и доменом.

    При смене провайдера (Stripe → Tinkoff) потребуется заменить только папку stripe/ на tinkoff/.

  3. События публикуются через портEventPublisher объявлен в application/port/out/, а реализация KafkaEventPublisher — в adapter/out/messaging/. Это даёт возможность:

    • в тестах использовать InMemoryEventPublisher,
    • в продакшене — Kafka,
    • при отказе Kafka — переключиться на RabbitMQ без изменения бизнес-логики.
  4. Модель домена не знает о DTOPayment — это сущность с методами process(), refund(), getStatus(). ProcessPaymentRequest существует только на границе и преобразуется в команду внутри контроллера.


Сценарий 3. Десктопное приложение на Avalonia (C#)

Рассмотрим приложение для анализа логов — десктопный клиент с UI, локальной базой и фоновыми задачами. Здесь критична реактивность интерфейса и изоляция UI-логики.

Файловая структура

src/
└── LogAnalyzer.Desktop/
├── Program.cs
└── App.axaml.cs

└── Presentation/
├── Views/
│ ├── MainWindow.axaml
│ └── LogDetailView.axaml
├── ViewModels/
│ ├── MainViewModel.cs
│ └── LogDetailViewModel.cs
└── Services/
└── IDialogService.cs ← абстракция UI-сервисов

└── Application/
├── UseCases/
│ ├── LoadLogFiles/
│ │ ├── LoadLogFilesCommand.cs
│ │ └── LoadLogFilesHandler.cs
│ └── AnalyzeLog/
│ └── AnalyzeLogHandler.cs
└── Models/
└── LogEntry.cs ← модель для ViewModel

└── Domain/
├── Entities/
│ └── ParsedLogEntry.cs ← доменная сущность
└── Services/
└── LogParsingService.cs

└── Infrastructure/
├── FileParsing/
│ ├── TextLogParser.cs
│ └── ILogParser.cs
├── Persistence/
│ └── LiteDbLogRepository.cs ← локальная БД
└── Threading/
└── BackgroundTaskRunner.cs ← запуск задач без блокировки UI

└── UI.Infrastructure/ ← реализации UI-абстракций
└── AvaloniaDialogService.cs

Ключевые архитектурные решения

  1. UI-логика отделена через абстракцииIDialogService объявлен в Presentation/Services/, реализация — в UI.Infrastructure/. Это позволяет:

    • тестировать MainViewModel без UI (внедрить MockDialogService),
    • перенести приложение на WPF, заменив только UI.Infrastructure.
  2. Фоновые операции — через BackgroundTaskRunner — он использует Task.Run с обработкой исключений и отменой через CancellationToken. ViewModels вызывают LoadLogFilesCommand, но не знают, как именно задача выполняется.

  3. Две модели лога:

    • ParsedLogEntry (домен) — содержит уровень, время, сообщение, метаданные,
    • LogEntry (application) — содержит то, что нужно для отображения (цвет, иконка, свёрнутое состояние).

    ViewModel работает с LogEntry, а не с доменной сущностью.

  4. Локальное хранилище — часть инфраструктурыLiteDbLogRepository реализует ILogRepository, но не экспортируется наружу. При переходе на SQLite — изменения только в Infrastructure/Persistence/.


Сценарий 4. Мобильное приложение на .NET MAUI (C#)

Приложение для отслеживания заказов — мобильный клиент, работающий офлайн, с синхронизацией. Здесь важны состояние жизненного цикла, офлайн-хранилище и гибкость UI под платформы.

Файловая структура

src/
└── OrderTracker.Mobile/
├── App.xaml.cs
└── MauiProgram.cs

└── Presentation/
├── Pages/
│ ├── OrderListPage.xaml
│ └── OrderDetailPage.xaml
├── ViewModels/
│ ├── OrderListViewModel.cs
│ └── OrderDetailViewModel.cs
└── Controls/
└── OrderStatusBadge.xaml

└── Application/
├── UseCases/
│ ├── SyncOrders/
│ │ ├── SyncOrdersCommand.cs
│ │ └── SyncOrdersHandler.cs
│ └── PlaceOrder/
│ └── PlaceOrderHandler.cs
└── Models/
└── DisplayOrder.cs

└── Domain/
├── Entities/
│ └── Order.cs
└── Events/
└── OrderStatusChangedEvent.cs

└── Infrastructure/
├── Connectivity/
│ └── IConnectivityChecker.cs
├── Data/
│ ├── Local/
│ │ └── SqliteOrderRepository.cs
│ └── Remote/
│ └── RestOrderApiClient.cs
└── Sync/
└── ConflictResolver.cs

└── Platform/
├── Android/
│ └── Services/
│ └── AndroidConnectivityChecker.cs
└── iOS/
└── Services/
└── IosConnectivityChecker.cs

Ключевые архитектурные решения

  1. Платформозависимые сервисы — в Platform/IConnectivityChecker объявлен в Infrastructure/Connectivity/, а реализации — в Platform/. Это позволяет:

    • использовать Xamarin.Essentials.Connectivity на Android/iOS,
    • заменить на MockConnectivityChecker в тестах.
  2. Синхронизация как отдельный use caseSyncOrdersCommand отвечает за:

    • проверку соединения,
    • загрузку обновлений с сервера,
    • разрешение конфликтов (например, заказ изменён и на клиенте, и на сервере),
    • сохранение локальных изменений.

    Логика конфликта вынесена в ConflictResolver — повторно используемый компонент.

  3. Офлайн-хранилище — часть инфраструктурыSqliteOrderRepository реализует IOrderRepository, но работает с локальной БД. При старте приложения SyncOrdersHandler вызывается автоматически.

  4. UI не зависит от платформыOrderListPage.xaml использует стандартные MAUI-контролы, а не нативные. Платформенные различия (например, цвет статус-бара) выносятся в AppShell.xaml или через DependencyService.


Сценарий 5. Веб-фронтенд на React + TypeScript

Хотя веб-фронтенд часто считают «просто UI», грамотная конструкция здесь не менее важна. Рассмотрим SPA для администрирования пользователей.

Файловая структура

src/
├── main.tsx
├── App.tsx

├── presentation/
│ ├── pages/
│ │ ├── UserListPage.tsx
│ │ └── UserEditPage.tsx
│ ├── components/
│ │ ├── UserTable.tsx
│ │ └── UserForm.tsx
│ └── hooks/
│ └── useUsers.ts ← кастомный хук

├── application/
│ ├── useCases/
│ │ ├── createUser.ts
│ │ └── updateUser.ts
│ └── dtos/
│ └── UserDto.ts

├── domain/
│ ├── entities/
│ │ └── User.ts
│ └── services/
│ └── UserValidationService.ts

├── infrastructure/
│ ├── api/
│ │ ├── UserApiClient.ts
│ │ └── ApiClient.ts
│ ├── storage/
│ │ └── LocalStorageTokenManager.ts
│ └── notifications/
│ └── ToastNotificationService.ts

└── common/
├── types/
│ └── Result.ts ← Success<T>/Failure<E>
└── utils/
└── dateUtils.ts

Ключевые архитектурные решения

  1. Слои повторяют бэкендdomain, application, infrastructure. Это позволяет:

    • реализовать часть валидации на клиенте (например, формат email),
    • поддерживать согласованность с сервером,
    • легко перенести логику в воркер или SSR.
  2. Кастомные хуки как адаптерыuseUsers() инкапсулирует:

    • состояние (isLoading, error, data),
    • вызов use case (createUser),
    • обработку ошибок.

    Компоненты зависят от хука, а не от API напрямую.

  3. API-клиент — часть инфраструктурыUserApiClient реализует интерфейс (в TS — тип), но его сигнатуры соответствуют REST-контракту. При смене бэкенда (REST → GraphQL) потребуется заменить только api/.

  4. Результаты операций типизированыResult<User, ValidationError> вместо Promise<User | null>. Это делает обработку ошибок явной и предотвращает undefined is not an object.


Общие принципы, действующие всегда

Независимо от типа приложения, технологии или масштаба, справедливы следующие принципы:

  1. Стабильность внутрь — доменный слой самый стабильный, инфраструктурный — самый изменчивый. Зависимости идут от изменчивого к стабильному.

  2. Имена отвечают на вопросыЧто?, Зачем?, Как устроено?. Если имя требует комментария — оно неудачное.

  3. Папки — навигация — структура должна позволить найти нужный класс за три клика.

  4. Один класс — одна причина для изменения — если класс меняется из-за бизнес-правил и из-за смены базы данных, он нарушает SRP.

  5. Интерфейсы там, где нужна гибкость — там, где реально возможна замена реализации (база, внешний сервис, UI-фреймворк).

  6. DTO — граница слоёв — никогда не передавайте доменные сущности за пределы application-слоя.

  7. Конфигурация — объект, а не строка — строго типизированные настройки повышают надёжность.


Диагностика и коррекция нарушений конструкции

Грамотная конструкция из классов устойчива к изменениям. Её нарушения проявляются не сразу, но неизбежно ведут к росту стоимости сопровождения, снижению скорости разработки и увеличению числа регрессий. Ниже описаны восемь распространённых архитектурных синдромов, способы их выявления по структуре проекта и методы коррекции.


Синдром 1. «Божественный класс» (God Class)

Симптомы в структуре:

  • Один класс (например, UserService.cs, MainController.java) превышает 1000 строк.
  • Папка Services/ или Controllers/ содержит 5–10 файлов, каждый по 800+ строк.
  • В одном файле присутствуют:
    • HTTP-маршрутизация,
    • валидация входных данных,
    • бизнес-логика,
    • обращение к базе данных,
    • отправка email,
    • логирование.
  • Методы имеют префиксы Handle, Process, DoSomething без уточнения контекста.

Архитектурная причина: нарушение принципа единственной ответственности (SRP) и отсутствие декомпозиции по слоям. Класс берёт на себя функции нескольких уровней абстракции.

Диагностика по зависимостям:

  • Класс ссылается на классы из Domain, Infrastructure, Presentation одновременно.
  • Тесты для этого класса требуют моков 5+ внешних сервисов.

Стратегия рефакторинга:

  1. Выделить use case — определить, какие операции реализует класс. Например, в UserService могут быть:
    • регистрация пользователя,
    • восстановление пароля,
    • обновление профиля.
  2. Создать папку под каждую операцию в Application/UseCases/:
    Application/
    └── UseCases/
    ├── RegisterUser/
    │ ├── RegisterUserCommand.cs
    │ └── RegisterUserCommandHandler.cs
    └── ResetPassword/
    ├── ResetPasswordCommand.cs
    └── ResetPasswordCommandHandler.cs
  3. Перенести логику в соответствующие CommandHandler.
  4. Вынести инфраструктурные вызовы в адаптеры:
    • отправку email — в Infrastructure/External/Notifications/,
    • доступ к БД — в репозитории.
  5. Оставить в исходном классе только маршрутизацию (если это контроллер) или делегирование (если это фасад).

Результат:

  • Каждый класс теперь отвечает за одну операцию.
  • Тестирование стало проще: RegisterUserCommandHandler тестируется изолированно.
  • Изменения в логике регистрации не затрагивают восстановление пароля.

Синдром 2. «Утечка домена» (Domain Leakage)

Симптомы в структуре:

  • В Presentation/Dtos/ или Controllers/ используются классы из Domain/Entities/.
  • В Infrastructure/ есть ссылки на Domain/Events/ или доменные сервисы.
  • В коде встречаются строки вроде:
    return user; // где user — экземпляр Domain.Entities.User
    или
    order.setStatus(OrderStatus.PAID); // вызов сеттера из контроллера

Архитектурная причина: отсутствие чёткой границы между слоями. Домен становится «данными», а не носителем поведения.

Диагностика по зависимостям:

  • Presentation напрямую ссылается на Domain.
  • В доменных сущностях есть:
    • [JsonProperty] (C#),
    • @JsonIgnore (Java),
    • методы toJson(), fromDto().

Стратегия рефакторинга:

  1. Ввести DTO на границе каждого слоя:
    • Presentation/Dtos/UserDto.cs ←→ Application/Dtos/UserInput.cs ←→ Domain/Entities/User.cs
  2. Запретить прямой экспорт доменных сущностей. В контроллерах:
    var userDto = _mapper.Map<UserDto>(userDomain);
    return Ok(userDto);
  3. Перенести сериализацию в инфраструктурный слой. Например, в ASP.NET:
    • JsonSerializerOptions настраивается в Infrastructure/Serialization/,
    • кастомные конвертеры (UserJsonConverter) размещаются там же.
  4. Сделать доменные сущности иммутабельными (где возможно) и закрытыми для прямого изменения:
    public class User
    {
    public User ChangeEmail(Email newEmail) => new User(Id, newEmail, ...);
    // нет публичного сеттера Email
    }

Результат:

  • Домен защищён от внешних изменений.
  • Изменение формата API не требует правки сущностей.
  • Нарушение инвариантов (например, user.Email = null) становится невозможным.

Синдром 3. «Адаптер без интерфейса» (Adapter Without Abstraction)

Симптомы в структуре:

  • В Infrastructure/External/ есть только реализации: SmtpEmailSender.cs, StripeClient.cs.
  • В Application/ или Domain/ прямые вызовы:
    var sender = new SmtpEmailSender();
    sender.Send(...);
  • При смене провайдера (SMTP → SendGrid) требуется правка нескольких классов.

Архитектурная причина: нарушение принципа инверсии зависимостей (DIP). Верхние уровни зависят от деталей реализации.

Диагностика по зависимостям:

  • Application ссылается на Infrastructure.
  • В тестах используются реальные SmtpEmailSender, а не заглушки.

Стратегия рефакторинга:

  1. Объявить интерфейс в том слое, который определяет потребность.
    Если отправка email требуется в use case — интерфейс IEmailSender размещается в Application/Ports/Out/ (или Application/Interfaces/).
  2. Реализовать интерфейс в Infrastructure/:
    Infrastructure/
    └── External/
    └── Notifications/
    ├── IEmailSender.cs ← копия интерфейса или ссылка
    └── SmtpEmailSender.cs
    (В C# интерфейс можно оставить в Application, а реализацию — в Infrastructure; в Java часто дублируют интерфейс в модуле инфраструктуры для независимой сборки.)
  3. Внедрить зависимость через конструктор:
    public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand>
    {
    private readonly IEmailSender _emailSender;
    public RegisterUserCommandHandler(IEmailSender emailSender)
    {
    _emailSender = emailSender;
    }
    }
  4. Зарегистрировать реализацию в DI-контейнере.
  5. В тестах внедрить FakeEmailSender : IEmailSender.

Результат:

  • Смена провайдера требует изменения только в Infrastructure.
  • Тесты не зависят от сети.
  • Контракт явно задан и документирован.

Синдром 4. «Слой по технологиям» (Technology-Layered Structure)

Симптомы в структуре:

src/
├── Controllers/
├── Services/
├── Repositories/
├── Models/
└── Utils/
  • Папка Services/ содержит:
    • UserService (бизнес-логика),
    • EmailService (интеграция),
    • ValidationService (валидация).
  • Невозможно определить, к какому архитектурному слою относится класс без изучения кода.
  • При добавлении нового функционала (например, SMS-уведомлений) требуется правка в 3–4 папках.

Архитектурная причина: путаница между технологической ролью («это сервис») и архитектурной ответственностью («это прикладная логика»).

Диагностика по содержимому:

  • В Services/UserService.cs есть вызов httpClient.PostAsync(...).
  • В Models/ лежат как доменные сущности, так и DTO.

Стратегия рефакторинга:

  1. Провести аудит по принципу «Что делает?», а не «Как называется?».
  2. Создать целевую структуру слоёв (см. Сценарий 1).
  3. Перенести файлы по новым папкам:
    • UserServiceApplication/UseCases/RegisterUser/RegisterUserCommandHandler.cs,
    • EmailServiceInfrastructure/External/Notifications/SmtpEmailSender.cs,
    • User.cs (сущность) → Domain/Entities/User.cs,
    • UserDto.csPresentation/Dtos/UserDto.cs.
  4. Удалить старые папки после миграции.

Результат:

  • Навигация по проекту стала интуитивной.
  • Новые разработчики быстрее вникают в архитектуру.
  • Изменения локализованы в одном месте.

Синдром 5. «DTO-спагетти» (DTO Spaghetti)

Симптомы в структуре:

  • В Dtos/ 20+ файлов с именами:
    • UserDto,
    • UserDtoV2,
    • UserResponse,
    • UserViewModel,
    • UserProfileDto,
    • UserInput,
    • UserForAdminDto.
  • Один и тот же набор полей дублируется в 5 DTO.
  • При изменении поля email требуется правка во всех DTO.

Архитектурная причина: отсутствие иерархии DTO и смешение контекстов (API, UI, внутренние операции).

Диагностика по использованию:

  • DTO используются в нескольких слоях без преобразования.
  • В коде:
    var userDto = new UserDto { Email = request.Email, ... };
    var userVm = new UserViewModel { Email = userDto.Email, ... };

Стратегия рефакторинга:

  1. Разделить DTO по контексту использования:
    • Presentation/Dtos/ — для API (публичный контракт),
    • Application/Dtos/ — для use case (внутренний контракт),
    • Presentation/ViewModels/ — для UI (если используется MVVM).
  2. Ввести базовые DTO для повторяющихся структур:
    // Application/Dtos/Shared/
    public record ContactInfoDto(string Email, string Phone);

    // Application/Dtos/
    public record UserInputDto(ContactInfoDto Contact, ...);
  3. Использовать мапперы с поддержкой наследования:
    CreateMap<ContactInfo, ContactInfoDto>();
    CreateMap<User, UserDto>().ForMember(...);
  4. Запретить прямое копирование полей. Все преобразования — через маппер.

Результат:

  • Сокращение дублирования на 60–80%.
  • Изменение структуры данных требует правки в одном месте.
  • Появляется чёткое понимание потока данных.

Синдром 6. «Статический хелпер-монолит» (Static Helper Monolith)

Симптомы в структуре:

  • Файл Utils.cs или Helper.java размером 2000+ строк.
  • Содержит методы:
    • ValidateEmail(string),
    • CalculateTax(decimal),
    • SendSms(string, string),
    • ParseXml(string),
    • GetConnectionString().
  • Вызывается через Utils.ValidateEmail(...) из любого места.

Архитектурная причина: нарушение инкапсуляции и тестируемости. Статические зависимости не подменяются.

Диагностика по тестам:

  • Тесты падают при отсутствии сети (из-за SendSms).
  • Невозможно проверить, сколько раз вызван SendSms.

Стратегия рефакторинга:

  1. Разбить Utils на узкоспециализированные классы:
    Common/
    └── Helpers/
    ├── EmailValidationHelper.cs
    ├── TaxCalculationHelper.cs
    └── XmlParsingHelper.cs
  2. Сделать методы нестатическими и внедрять через DI:
    services.AddSingleton<IEmailValidator, EmailValidationHelper>();
  3. Вынести побочные эффекты в инфраструктурные сервисы:
    • SendSmsISmsService в Infrastructure.
  4. Для чистых функций (без побочных эффектов) оставить статическими, но в утилитарных классах:
    public static class DateExtensions
    {
    public static bool IsWeekend(this DateTime date) => ...;
    }

Результат:

  • Появилась возможность тестировать логику без побочных эффектов.
  • Хелперы стали повторно используемыми и заменяемыми.
  • Уменьшилась связанность.

Синдром 7. «Репозиторий-хранилище всего» (Repository as Data Dump)

Симптомы в структуре:

  • В IUserRepository 15+ методов:
    • GetById,
    • GetByEmail,
    • GetByRole,
    • GetActive,
    • GetWithOrders,
    • GetForAdminPanel,
    • SearchByNameOrEmail.
  • Реализация содержит сложные запросы с JOIN, WHERE, GROUP BY.
  • При добавлении нового фильтра требуется правка интерфейса и всех реализаций.

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

Диагностика по сигнатурам:

  • Методы содержат бизнес-термины: GetForAdminPanel, GetWithUnpaidOrders.
  • Возвращаемые типы — доменные сущности со связанными объектами (User с Orders).

Стратегия рефакторинга:

  1. Сократить репозиторий до базовых операций:
    public interface IUserRepository
    {
    Task<User> GetByIdAsync(Guid id);
    Task<User> GetByCriteriaAsync(UserCriteria criteria);
    Task AddAsync(User user);
    Task UpdateAsync(User user);
    }
  2. Ввести UserCriteria — объект для фильтрации:
    public record UserCriteria(
    string? Email = null,
    UserRole? Role = null,
    bool? IsActive = null);
  3. Вынести сложные запросы в отдельные классы — Query Handlers:
    Application/
    └── Queries/
    └── GetUserForAdminPanel/
    ├── GetUserForAdminPanelQuery.cs
    └── GetUserForAdminPanelQueryHandler.cs
    Handler использует репозиторий с UserCriteria.
  4. Для агрегированных данных использовать Read Models (подход CQRS):
    • UserSummaryReadModel — плоская структура для списка,
    • отдельный IUserSummaryRepository.

Результат:

  • Репозиторий стал стабильным.
  • Запросы локализованы и тестируемы.
  • Появилась возможность использовать разные источники для чтения и записи.

Синдром 8. «Конфигурация-строки» (String-Based Configuration)

Симптомы в структуре:

  • В коде:
    var apiKey = Configuration["ExternalServices:Stripe:ApiKey"];
    var timeout = int.Parse(Configuration["HttpClient:Timeout"]);
  • appsettings.json содержит вложенные секции без типизации.
  • При ошибке в названии ключа (Stipe вместо Stripe) ошибка проявляется только в runtime.

Архитектурная причина: отсутствие тизации конфигурации и её инкапсуляции.

Диагностика по тестам:

  • Тесты требуют appsettings.test.json.
  • Невозможно проверить валидность конфигурации при старте.

Стратегия рефакторинга:

  1. Создать классы конфигурации:
    public class StripeOptions
    {
    public string ApiKey { get; set; } = default!;
    public int TimeoutSeconds { get; set; } = 30;
    }
  2. Привязать к секции:
    services.Configure<StripeOptions>(Configuration.GetSection("ExternalServices:Stripe"));
  3. Внедрить через IOptions<T>:
    public class StripeClient
    {
    public StripeClient(IOptions<StripeOptions> options) { ... }
    }
  4. Добавить валидацию при старте:
    var stripeOptions = app.Services.GetRequiredService<IOptions<StripeOptions>>().Value;
    if (string.IsNullOrEmpty(stripeOptions.ApiKey))
    throw new InvalidOperationException("Stripe:ApiKey is required");

Результат:

  • Конфигурация проверяется при старте.
  • Ошибки в именах ключей обнаруживаются на этапе сборки (если использовать IConfigureOptions).
  • Документирование параметров — через XML-комментарии к свойствам.

Механизмы эволюции конструкции

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


Сценарий 1. От монолита к микросервисам: стратегия стратифицированного выделения

Контекст:
Единый сервис (OrderService.Api) вырос до 50+ use case, 200+ классов, 3 доменных агрегатов (Order, Payment, Inventory). Появились:

  • Разные требования к масштабируемости (платежи — высоконагруженные, инвентарь — редко меняется),
  • Независимые команды хотят развивать функционал отдельно,
  • Время сборки превысило 5 минут.

Цель:
Выделить PaymentService как отдельный микросервис, сохранив работоспособность OrderService.

Этап 1. Подготовка границы (0–2 недели)

Задача: сделать Payment независимым от Order на уровне кода.

Действия:

  1. Идентифицировать зависимости:
    • Найти все места, где Order напрямую использует Payment (например, order.PaymentId, order.GetPaymentStatus()).
  2. Ввести антикоррупционный слой (ACL):
    • В Domain/ создать IPaymentService:
      public interface IPaymentService
      {
      Task<PaymentStatus> GetStatusAsync(Guid paymentId);
      Task ProcessAsync(PaymentRequest request);
      }
    • В Infrastructure/ реализовать LocalPaymentService (временно — вызывает локальную логику).
  3. Заменить прямые вызовы:
    • В OrderAggregate:
      // Было:
      // if (_payment.Status == PaymentStatus.Completed) ...

      // Стало:
      var status = await _paymentService.GetStatusAsync(_paymentId);
      if (status == PaymentStatus.Completed) ...
  4. Настроить DI для временного использования LocalPaymentService.

Результат:

  • Домен Order больше не зависит от деталей Payment.
  • Появляется чёткий контракт взаимодействия.

Этап 2. Выделение кода (2–4 недели)

Задача: физически изолировать код Payment.

Действия:

  1. Создать новый проект PaymentService.Api.
  2. Перенести:
    • Domain/Entities/Payment.cs,
    • Domain/ValueObjects/Money.cs,
    • Application/UseCases/ProcessPayment/,
    • Infrastructure/External/PaymentGateway/,
    • связанные DTO и мапперы.
  3. Оставить в OrderService только интерфейс IPaymentService и реализацию-адаптер:
    // OrderService.Infrastructure/
    public class RemotePaymentService : IPaymentService
    {
    private readonly HttpClient _client;
    public async Task<PaymentStatus> GetStatusAsync(Guid id)
    {
    var response = await _client.GetAsync($"/payments/{id}/status");
    return await response.Content.ReadAsAsync<PaymentStatus>();
    }
    }
  4. Реализовать REST-контракт в PaymentService:
    • GET /payments/{id}/statusPaymentStatusDto,
    • POST /payments/process202 Accepted.

Результат:

  • PaymentService может собираться и тестироваться отдельно.
  • OrderService продолжает работать — просто вызывает HTTP вместо локального кода.

Этап 3. Управление данными (4–8 недель)

Задача: разорвать общую базу данных.

Действия:

  1. Создать отдельную БД для PaymentService.
  2. Настроить синхронизацию событий:
    • В OrderService при создании заказа публиковать OrderCreatedEvent.
    • В PaymentService подписаться на событие и создавать запись Payment.
  3. Ввести идемпотентность:
    • Каждое событие имеет уникальный EventId, сохраняется в ProcessedEvents для дедупликации.
  4. Заменить синхронные вызовы на асинхронные:
    • Order больше не ждёт ответа от Payment — переходит в статус AwaitingPayment.

Результат:

  • Полная независимость развёртывания и масштабирования.
  • Отказоустойчивость: при падении PaymentService заказ остаётся в OrderService.

Ключевые правила эволюции:

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

Сценарий 2. Адаптация под рост команды: от одного разработчика до 10+

Контекст:
Проект (LogAnalyzer.Desktop) изначально разрабатывался одним человеком. Теперь в команде 8 разработчиков, 2 тестировщика, 1 аналитик. Возникли проблемы:

  • Конфликты в Git при работе в одном файле (MainViewModel.cs),
  • Непонимание, кто отвечает за Infrastructure/FileParsing/,
  • Нарушение стиля кода.

Цель:
Сделать конструкцию масштабируемой для командной работы без изменения архитектуры.

Этап 1. Согласование ответственности (0.5 недели)

Задача: явно закрепить зоны ответственности.

Действия:

  1. Разбить проект на модули по функционалу:
    src/
    ├── Core/ ← домен + application, 1–2 человека
    ├── FileParsing/ ← инфраструктура парсинга, 2 человека
    ├── Ui/ ← presentation, 3 человека
    └── Reporting/ ← отчёты, 2 человека
  2. Назначить «владельцев модулей» — по одному senior на модуль, ответственному за:
    • код-ревью внутри модуля,
    • соблюдение границ,
    • документирование контрактов.
  3. Описать контракты между модулями:
    • FileParsing предоставляет ILogParser,
    • Core использует его через DI,
    • Ui не имеет прямого доступа к FileParsing.

Этап 2. Автоматизация соблюдения границ (1 неделя)

Задача: предотвратить нарушения границ на этапе сборки.

Действия:

  1. Настроить анализ зависимостей:
    • В C# — использовать Microsoft.CodeAnalysis.PublicApiAnalyzers или NDepend,
    • Правило: Ui может ссылаться только на Core, но не на FileParsing.
  2. Добавить pre-commit hook:
    # .git/hooks/pre-commit
    if grep -r "using FileParsing" src/Ui/; then
    echo "Ошибка: Ui не должен зависеть от FileParsing"
    exit 1
    fi
  3. Ввести соглашение об именовании веток:
    • feature/parsing/csv-support,
    • fix/ui/table-rendering,
    • refactor/core/command-handlers.

Этап 3. Документирование «точек касания» (постоянно)

Задача: снизить когнитивную нагрузку при входе в чужой модуль.

Действия:

  1. В каждом модуле — README.md с:
    • назначением модуля,
    • контрактами (интерфейсами),
    • примерами использования,
    • владельцем.
  2. Для use case — SPEC.md:
    ## ProcessLargeLogFile

    ### Вход
    - `ProcessLogCommand { FilePath, CancellationToken }`

    ### Выход
    - `LogProcessingResult { Entries, Warnings }`

    ### Зависимости
    - `ILogParser` (из FileParsing),
    - `IProgressReporter` (из Ui).
  3. Генерировать диаграммы зависимостей:
    • PlantUML-скрипты в docs/architecture/,
    • обновлять при каждом major-релизе.

Результат:

  • Количество конфликтов в Git снизилось на 70%.
  • Время вхождения нового разработчика — 2 дня вместо 2 недель.
  • Качество код-ревью повысилось — фокус на логике, а не на стиле.

Сценарий 3. Смена технологического стека: миграция с SQL на NoSQL без остановки

Контекст:
Сервис UserService.Api использует PostgreSQL через Entity Framework Core. Требуется перейти на MongoDB для гибкости схемы (динамические поля профиля). Прямая миграция невозможна — сервис работает 24/7.

Цель:
Постепенный переход с сохранением данных и API.

Этап 1. Введение двойной записи (2–3 недели)

Задача: сохранять данные в обе БД параллельно.

Действия:

  1. Добавить MongoDB-клиент в Infrastructure/Persistence/:
    Infrastructure/
    └── Persistence/
    ├── Sql/
    │ └── EfUserRepository.cs
    └── Mongo/
    └── MongoUserRepository.cs
  2. Создать декоратор для репозитория:
    public class DualWriteUserRepository : IUserRepository
    {
    private readonly IUserRepository _sql;
    private readonly IUserRepository _mongo;

    public async Task AddAsync(User user)
    {
    await _sql.AddAsync(user);
    await _mongo.AddAsync(user);
    }
    }
  3. Настроить DI для использования DualWriteUserRepository в продакшене.
  4. Добавить мониторинг:
    • Логировать расхождения (если запись в SQL удалась, а в Mongo — нет),
    • Алерт при >0.1% ошибок.

Этап 2. Постепенное переключение чтения (3–4 недели)

Задача: начать читать из MongoDB, сохраняя запись в обе БД.

Действия:

  1. Ввести флаг UseMongoForReading:
    public class ReadPreferenceUserRepository : IUserRepository
    {
    public async Task<User> GetByIdAsync(Guid id)
    {
    return _config.UseMongoForReading
    ? await _mongo.GetByIdAsync(id)
    : await _sql.GetByIdAsync(id);
    }
    }
  2. Включить флаг для 1% трафика (через feature flag).
  3. Сравнивать результаты:
    • Логировать расхождения в UserComparisonLog,
    • Автоматически отключать флаг при ошибках.
  4. Постепенно увеличивать долю трафика до 100%.

Этап 3. Отключение старой БД (1 неделя)

Задача: полностью перейти на MongoDB.

Действия:

  1. Заменить DualWriteUserRepository на MongoUserRepository.
  2. Запустить полную синхронизацию данных:
    • Скрипт читает все записи из PostgreSQL,
    • Проверяет наличие в MongoDB,
    • Добавляет недостающие.
  3. Отключить PostgreSQL, оставить только для архива.

Ключевые принципы миграции:

  • Ни один шаг не требует downtime.
  • Каждое изменение обратимо — при ошибке откатывается флаг.
  • Данные проверяются в runtime, а не только в тестах.

Общие механизмы устойчивой эволюции

Независимо от сценария, работают следующие принципы:

1. Контракт как точка стабильности

  • Интерфейсы (IUserRepository, IPaymentService) меняются реже, чем реализации.
  • Версионирование API (v1, v2) — только при нарушении контракта; иначе — расширение.

2. Поэтапность без «большого взрыва»

  • Каждый этап:
    • имеет измеримый результат («снизили количество конфликтов на 50%»),
    • длится не более 2–4 недель,
    • не нарушает работоспособность системы.

3. Автоматическая проверка границ

  • Статический анализ (NDepend, SonarQube) в CI/CD:
    - name: Check layer dependencies
    run: dotnet nunit --where "cat == 'Architecture'"
  • Запрет прямых зависимостей между слоями на уровне сборки.

4. Документирование как часть кода

  • SPEC.md в папке use case,
  • README.md в каждом модуле,
  • Диаграммы в формате PlantUML (версионируются в Git).

5. Мониторинг эволюции

  • Метрики конструкции:
    • количество классов на use case (цель: ≤5),
    • глубина наследования (цель: ≤3),
    • цикломатическая сложность метода (цель: ≤10).
  • Алерты при выходе за пороги.