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

Построение систем на основе классов и объектов

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

Построение систем на основе классов и объектов

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

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

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


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

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

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

  • Точка входа — инициализация среды выполнения, регистрация зависимостей, запуск основного цикла.
  • Представление (Presentation) — интерфейс взаимодействия с внешним миром: HTTP-контроллеры, UI-компоненты, CLI-парсеры, WebSocket-хендлеры.
  • Прикладная логика (Application) — координация операций, управление транзакциями, реализация сценариев использования (use Кейсы).
  • Доменная логика (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. Избегают обобщённых имён вроде Данные или 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. Организуют выполнение фоновых операций. В зависимости от масштаба — от Система.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 Кейсы)
│ │ │ └── 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
├── Данные/
│ ├── 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, Данные),
    • вызов 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 Данные 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).
  • Алерты при выходе за пороги.

См. также

Другие статьи этого же раздела в боковом меню (как на странице «О разделе»).

Освоение главы0%