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.
Осторожно: чрезмерное увлечение утилитами ведёт к «библиотеке всего». Если хелпер используется только в одном месте — он не нужен. Хелпер оправдан, когда снижает когнитивную нагрузку, а не просто «скрывает строку кода».
Принципы именования: формула <Контекст> + <Роль> + <Тип>
Как уже отмечалось, имя класса — это документация. Оно должно отвечать на три вопроса:
- О чём? (Контекст) —
User,Order,Payment,Report. - Что делает? (Роль) —
Service,Validator,Sender,Parser,Factory. - Как устроен? (Тип/паттерн) —
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
Ключевые архитектурные решения
-
Разделение сборок по слоям — хотя весь код в одном проекте, логическое разделение строго соблюдено:
Domainне ссылается ни на что, кромеCommon.Applicationзависит отDomain, но не отInfrastructure.Infrastructureзависит отApplicationиDomain.Presentationзависит от всех слоёв.
Это обеспечивает зависимость внутрь (Dependency Rule из Clean Architecture).
-
Use Case как единица бизнес-операции — каждая операция (регистрация, оформление заказа) выделена в отдельную папку с командой, обработчиком и результатом. Это:
- упрощает тестирование (один тестовый класс на сценарий),
- позволяет вносить изменения без поиска по всему проекту,
- поддерживает CQRS (Command Query Responsibility Segregation) в минимальной форме.
-
Интерфейсы в Infrastructure, реализации — тоже в Infrastructure — например,
IEmailSenderобъявлен вInfrastructure/External/Notifications/EmailService/, а не вApplication. Почему? Потому что отправка email — инфраструктурная деталь: домен не знает, как именно уведомление доставляется. Если быIEmailSenderбыл вApplication, это означало бы, что прикладной слой навязывает способ взаимодействия, что нарушает инверсию зависимостей. -
DTO в двух местах:
Presentation/Dtos/— для сериализации в JSON (публичный контракт API),Application/Dtos/— для передачи данных внутри use case (внутренний контракт).
Разделение предотвращает утечку представления внутрь логики.
-
Регистрация зависимостей в одном месте —
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
Ключевые архитектурные решения
-
Гексагональная архитектура (Ports & Adapters) — явное выделение:
- Портов (
application/port/) — интерфейсов, определяющих, что система может делать (вход) и от чего зависит (выход), - Адаптеров (
adapter/) — реализаций этих портов для конкретных технологий.
Это позволяет запустить сервис без веб-слоя (например, для тестов или CLI), подменив
WebPaymentControllerнаConsolePaymentAdapter. - Портов (
-
Внешние интеграции — в
adapter/out/gateway/—StripePaymentGatewayAdapterреализуетPaymentGateway, но скрывает:- формат запросов к Stripe API,
- обработку ошибок (например,
StripeCardException→PaymentException), - маппинг между моделями Stripe и доменом.
При смене провайдера (Stripe → Tinkoff) потребуется заменить только папку
stripe/наtinkoff/. -
События публикуются через порт —
EventPublisherобъявлен вapplication/port/out/, а реализацияKafkaEventPublisher— вadapter/out/messaging/. Это даёт возможность:- в тестах использовать
InMemoryEventPublisher, - в продакшене — Kafka,
- при отказе Kafka — переключиться на RabbitMQ без изменения бизнес-логики.
- в тестах использовать
-
Модель домена не знает о DTO —
Payment— это сущность с методами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
Ключевые архитектурные решения
-
UI-логика отделена через абстракции —
IDialogServiceобъявлен вPresentation/Services/, реализация — вUI.Infrastructure/. Это позволяет:- тестировать
MainViewModelбез UI (внедритьMockDialogService), - перенести приложение на WPF, заменив только
UI.Infrastructure.
- тестировать
-
Фоновые операции — через
BackgroundTaskRunner— он используетTask.Runс обработкой исключений и отменой черезCancellationToken. ViewModels вызываютLoadLogFilesCommand, но не знают, как именно задача выполняется. -
Две модели лога:
ParsedLogEntry(домен) — содержит уровень, время, сообщение, метаданные,LogEntry(application) — содержит то, что нужно для отображения (цвет, иконка, свёрнутое состояние).
ViewModel работает с
LogEntry, а не с доменной сущностью. -
Локальное хранилище — часть инфраструктуры —
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
Ключевые архитектурные решения
-
Платформозависимые сервисы — в
Platform/—IConnectivityCheckerобъявлен вInfrastructure/Connectivity/, а реализации — вPlatform/. Это позволяет:- использовать
Xamarin.Essentials.Connectivityна Android/iOS, - заменить на
MockConnectivityCheckerв тестах.
- использовать
-
Синхронизация как отдельный use case —
SyncOrdersCommandотвечает за:- проверку соединения,
- загрузку обновлений с сервера,
- разрешение конфликтов (например, заказ изменён и на клиенте, и на сервере),
- сохранение локальных изменений.
Логика конфликта вынесена в
ConflictResolver— повторно используемый компонент. -
Офлайн-хранилище — часть инфраструктуры —
SqliteOrderRepositoryреализуетIOrderRepository, но работает с локальной БД. При старте приложенияSyncOrdersHandlerвызывается автоматически. -
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
Ключевые архитектурные решения
-
Слои повторяют бэкенд —
domain,application,infrastructure. Это позволяет:- реализовать часть валидации на клиенте (например, формат email),
- поддерживать согласованность с сервером,
- легко перенести логику в воркер или SSR.
-
Кастомные хуки как адаптеры —
useUsers()инкапсулирует:- состояние (
isLoading,error,data), - вызов use case (
createUser), - обработку ошибок.
Компоненты зависят от хука, а не от API напрямую.
- состояние (
-
API-клиент — часть инфраструктуры —
UserApiClientреализует интерфейс (в TS — тип), но его сигнатуры соответствуют REST-контракту. При смене бэкенда (REST → GraphQL) потребуется заменить толькоapi/. -
Результаты операций типизированы —
Result<User, ValidationError>вместоPromise<User | null>. Это делает обработку ошибок явной и предотвращаетundefined is not an object.
Общие принципы, действующие всегда
Независимо от типа приложения, технологии или масштаба, справедливы следующие принципы:
-
Стабильность внутрь — доменный слой самый стабильный, инфраструктурный — самый изменчивый. Зависимости идут от изменчивого к стабильному.
-
Имена отвечают на вопросы —
Что?,Зачем?,Как устроено?. Если имя требует комментария — оно неудачное. -
Папки — навигация — структура должна позволить найти нужный класс за три клика.
-
Один класс — одна причина для изменения — если класс меняется из-за бизнес-правил и из-за смены базы данных, он нарушает SRP.
-
Интерфейсы там, где нужна гибкость — там, где реально возможна замена реализации (база, внешний сервис, UI-фреймворк).
-
DTO — граница слоёв — никогда не передавайте доменные сущности за пределы application-слоя.
-
Конфигурация — объект, а не строка — строго типизированные настройки повышают надёжность.
Диагностика и коррекция нарушений конструкции
Грамотная конструкция из классов устойчива к изменениям. Её нарушения проявляются не сразу, но неизбежно ведут к росту стоимости сопровождения, снижению скорости разработки и увеличению числа регрессий. Ниже описаны восемь распространённых архитектурных синдромов, способы их выявления по структуре проекта и методы коррекции.
Синдром 1. «Божественный класс» (God Class)
Симптомы в структуре:
- Один класс (например,
UserService.cs,MainController.java) превышает 1000 строк. - Папка
Services/илиControllers/содержит 5–10 файлов, каждый по 800+ строк. - В одном файле присутствуют:
- HTTP-маршрутизация,
- валидация входных данных,
- бизнес-логика,
- обращение к базе данных,
- отправка email,
- логирование.
- Методы имеют префиксы
Handle,Process,DoSomethingбез уточнения контекста.
Архитектурная причина: нарушение принципа единственной ответственности (SRP) и отсутствие декомпозиции по слоям. Класс берёт на себя функции нескольких уровней абстракции.
Диагностика по зависимостям:
- Класс ссылается на классы из
Domain,Infrastructure,Presentationодновременно. - Тесты для этого класса требуют моков 5+ внешних сервисов.
Стратегия рефакторинга:
- Выделить use case — определить, какие операции реализует класс. Например, в
UserServiceмогут быть:- регистрация пользователя,
- восстановление пароля,
- обновление профиля.
- Создать папку под каждую операцию в
Application/UseCases/:Application/
└── UseCases/
├── RegisterUser/
│ ├── RegisterUserCommand.cs
│ └── RegisterUserCommandHandler.cs
└── ResetPassword/
├── ResetPasswordCommand.cs
└── ResetPasswordCommandHandler.cs - Перенести логику в соответствующие
CommandHandler. - Вынести инфраструктурные вызовы в адаптеры:
- отправку email — в
Infrastructure/External/Notifications/, - доступ к БД — в репозитории.
- отправку email — в
- Оставить в исходном классе только маршрутизацию (если это контроллер) или делегирование (если это фасад).
Результат:
- Каждый класс теперь отвечает за одну операцию.
- Тестирование стало проще:
RegisterUserCommandHandlerтестируется изолированно. - Изменения в логике регистрации не затрагивают восстановление пароля.
Синдром 2. «Утечка домена» (Domain Leakage)
Симптомы в структуре:
- В
Presentation/Dtos/илиControllers/используются классы изDomain/Entities/. - В
Infrastructure/есть ссылки наDomain/Events/или доменные сервисы. - В коде встречаются строки вроде:
или
return user; // где user — экземпляр Domain.Entities.Userorder.setStatus(OrderStatus.PAID); // вызов сеттера из контроллера
Архитектурная причина: отсутствие чёткой границы между слоями. Домен становится «данными», а не носителем поведения.
Диагностика по зависимостям:
Presentationнапрямую ссылается наDomain.- В доменных сущностях есть:
[JsonProperty](C#),@JsonIgnore(Java),- методы
toJson(),fromDto().
Стратегия рефакторинга:
- Ввести DTO на границе каждого слоя:
Presentation/Dtos/UserDto.cs←→Application/Dtos/UserInput.cs←→Domain/Entities/User.cs
- Запретить прямой экспорт доменных сущностей. В контроллерах:
var userDto = _mapper.Map<UserDto>(userDomain);
return Ok(userDto); - Перенести сериализацию в инфраструктурный слой. Например, в ASP.NET:
JsonSerializerOptionsнастраивается вInfrastructure/Serialization/,- кастомные конвертеры (
UserJsonConverter) размещаются там же.
- Сделать доменные сущности иммутабельными (где возможно) и закрытыми для прямого изменения:
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, а не заглушки.
Стратегия рефакторинга:
- Объявить интерфейс в том слое, который определяет потребность.
Если отправка email требуется в use case — интерфейсIEmailSenderразмещается вApplication/Ports/Out/(илиApplication/Interfaces/). - Реализовать интерфейс в
Infrastructure/:(В C# интерфейс можно оставить вInfrastructure/
└── External/
└── Notifications/
├── IEmailSender.cs ← копия интерфейса или ссылка
└── SmtpEmailSender.csApplication, а реализацию — вInfrastructure; в Java часто дублируют интерфейс в модуле инфраструктуры для независимой сборки.) - Внедрить зависимость через конструктор:
public class RegisterUserCommandHandler : IRequestHandler<RegisterUserCommand>
{
private readonly IEmailSender _emailSender;
public RegisterUserCommandHandler(IEmailSender emailSender)
{
_emailSender = emailSender;
}
} - Зарегистрировать реализацию в DI-контейнере.
- В тестах внедрить
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).
- Перенести файлы по новым папкам:
UserService→Application/UseCases/RegisterUser/RegisterUserCommandHandler.cs,EmailService→Infrastructure/External/Notifications/SmtpEmailSender.cs,User.cs(сущность) →Domain/Entities/User.cs,UserDto.cs→Presentation/Dtos/UserDto.cs.
- Удалить старые папки после миграции.
Результат:
- Навигация по проекту стала интуитивной.
- Новые разработчики быстрее вникают в архитектуру.
- Изменения локализованы в одном месте.
Синдром 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, ... };
Стратегия рефакторинга:
- Разделить DTO по контексту использования:
Presentation/Dtos/— для API (публичный контракт),Application/Dtos/— для use case (внутренний контракт),Presentation/ViewModels/— для UI (если используется MVVM).
- Ввести базовые DTO для повторяющихся структур:
// Application/Dtos/Shared/
public record ContactInfoDto(string Email, string Phone);
// Application/Dtos/
public record UserInputDto(ContactInfoDto Contact, ...); - Использовать мапперы с поддержкой наследования:
CreateMap<ContactInfo, ContactInfoDto>();
CreateMap<User, UserDto>().ForMember(...); - Запретить прямое копирование полей. Все преобразования — через маппер.
Результат:
- Сокращение дублирования на 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.
Стратегия рефакторинга:
- Разбить
Utilsна узкоспециализированные классы:Common/
└── Helpers/
├── EmailValidationHelper.cs
├── TaxCalculationHelper.cs
└── XmlParsingHelper.cs - Сделать методы нестатическими и внедрять через DI:
services.AddSingleton<IEmailValidator, EmailValidationHelper>(); - Вынести побочные эффекты в инфраструктурные сервисы:
SendSms→ISmsServiceвInfrastructure.
- Для чистых функций (без побочных эффектов) оставить статическими, но в утилитарных классах:
public static class DateExtensions
{
public static bool IsWeekend(this DateTime date) => ...;
}
Результат:
- Появилась возможность тестировать логику без побочных эффектов.
- Хелперы стали повторно используемыми и заменяемыми.
- Уменьшилась связанность.
Синдром 7. «Репозиторий-хранилище всего» (Repository as Data Dump)
Симптомы в структуре:
- В
IUserRepository15+ методов:GetById,GetByEmail,GetByRole,GetActive,GetWithOrders,GetForAdminPanel,SearchByNameOrEmail.
- Реализация содержит сложные запросы с
JOIN,WHERE,GROUP BY. - При добавлении нового фильтра требуется правка интерфейса и всех реализаций.
Архитектурная причина: смешение доступа к данным и бизнес-запросов. Репозиторий становится частью прикладной логики.
Диагностика по сигнатурам:
- Методы содержат бизнес-термины:
GetForAdminPanel,GetWithUnpaidOrders. - Возвращаемые типы — доменные сущности со связанными объектами (
UserсOrders).
Стратегия рефакторинга:
- Сократить репозиторий до базовых операций:
public interface IUserRepository
{
Task<User> GetByIdAsync(Guid id);
Task<User> GetByCriteriaAsync(UserCriteria criteria);
Task AddAsync(User user);
Task UpdateAsync(User user);
} - Ввести
UserCriteria— объект для фильтрации:public record UserCriteria(
string? Email = null,
UserRole? Role = null,
bool? IsActive = null); - Вынести сложные запросы в отдельные классы —
Query Handlers:Handler использует репозиторий сApplication/
└── Queries/
└── GetUserForAdminPanel/
├── GetUserForAdminPanelQuery.cs
└── GetUserForAdminPanelQueryHandler.csUserCriteria. - Для агрегированных данных использовать
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. - Невозможно проверить валидность конфигурации при старте.
Стратегия рефакторинга:
- Создать классы конфигурации:
public class StripeOptions
{
public string ApiKey { get; set; } = default!;
public int TimeoutSeconds { get; set; } = 30;
} - Привязать к секции:
services.Configure<StripeOptions>(Configuration.GetSection("ExternalServices:Stripe")); - Внедрить через
IOptions<T>:public class StripeClient
{
public StripeClient(IOptions<StripeOptions> options) { ... }
} - Добавить валидацию при старте:
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 на уровне кода.
Действия:
- Идентифицировать зависимости:
- Найти все места, где
Orderнапрямую используетPayment(например,order.PaymentId,order.GetPaymentStatus()).
- Найти все места, где
- Ввести антикоррупционный слой (ACL):
- В
Domain/создатьIPaymentService:public interface IPaymentService
{
Task<PaymentStatus> GetStatusAsync(Guid paymentId);
Task ProcessAsync(PaymentRequest request);
} - В
Infrastructure/реализоватьLocalPaymentService(временно — вызывает локальную логику).
- В
- Заменить прямые вызовы:
- В
OrderAggregate:// Было:
// if (_payment.Status == PaymentStatus.Completed) ...
// Стало:
var status = await _paymentService.GetStatusAsync(_paymentId);
if (status == PaymentStatus.Completed) ...
- В
- Настроить DI для временного использования
LocalPaymentService.
Результат:
- Домен
Orderбольше не зависит от деталейPayment. - Появляется чёткий контракт взаимодействия.
Этап 2. Выделение кода (2–4 недели)
Задача: физически изолировать код Payment.
Действия:
- Создать новый проект
PaymentService.Api. - Перенести:
Domain/Entities/Payment.cs,Domain/ValueObjects/Money.cs,Application/UseCases/ProcessPayment/,Infrastructure/External/PaymentGateway/,- связанные DTO и мапперы.
- Оставить в
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>();
}
} - Реализовать REST-контракт в
PaymentService:GET /payments/{id}/status→PaymentStatusDto,POST /payments/process→202 Accepted.
Результат:
PaymentServiceможет собираться и тестироваться отдельно.OrderServiceпродолжает работать — просто вызывает HTTP вместо локального кода.
Этап 3. Управление данными (4–8 недель)
Задача: разорвать общую базу данных.
Действия:
- Создать отдельную БД для
PaymentService. - Настроить синхронизацию событий:
- В
OrderServiceпри создании заказа публиковатьOrderCreatedEvent. - В
PaymentServiceподписаться на событие и создавать записьPayment.
- В
- Ввести идемпотентность:
- Каждое событие имеет уникальный
EventId, сохраняется вProcessedEventsдля дедупликации.
- Каждое событие имеет уникальный
- Заменить синхронные вызовы на асинхронные:
Orderбольше не ждёт ответа отPayment— переходит в статусAwaitingPayment.
Результат:
- Полная независимость развёртывания и масштабирования.
- Отказоустойчивость: при падении
PaymentServiceзаказ остаётся вOrderService.
Ключевые правила эволюции:
- Контракт остаётся неизменным — интерфейс
IPaymentServiceне меняется, только реализация. - Новая версия — опция — старый и новый сервис работают параллельно до полного перехода.
- Данные мигрируют по событиям, а не по схеме — избегается жёсткая синхронизация БД.
Сценарий 2. Адаптация под рост команды: от одного разработчика до 10+
Контекст:
Проект (LogAnalyzer.Desktop) изначально разрабатывался одним человеком. Теперь в команде 8 разработчиков, 2 тестировщика, 1 аналитик. Возникли проблемы:
- Конфликты в Git при работе в одном файле (
MainViewModel.cs), - Непонимание, кто отвечает за
Infrastructure/FileParsing/, - Нарушение стиля кода.
Цель:
Сделать конструкцию масштабируемой для командной работы без изменения архитектуры.
Этап 1. Согласование ответственности (0.5 недели)
Задача: явно закрепить зоны ответственности.
Действия:
- Разбить проект на модули по функционалу:
src/
├── Core/ ← домен + application, 1–2 человека
├── FileParsing/ ← инфраструктура парсинга, 2 человека
├── Ui/ ← presentation, 3 человека
└── Reporting/ ← отчёты, 2 человека - Назначить «владельцев модулей» — по одному senior на модуль, ответственному за:
- код-ревью внутри модуля,
- соблюдение границ,
- документирование контрактов.
- Описать контракты между модулями:
FileParsingпредоставляетILogParser,Coreиспользует его через DI,Uiне имеет прямого доступа кFileParsing.
Этап 2. Автоматизация соблюдения границ (1 неделя)
Задача: предотвратить нарушения границ на этапе сборки.
Действия:
- Настроить анализ зависимостей:
- В C# — использовать
Microsoft.CodeAnalysis.PublicApiAnalyzersили NDepend, - Правило:
Uiможет ссылаться только наCore, но не наFileParsing.
- В C# — использовать
- Добавить pre-commit hook:
# .git/hooks/pre-commit
if grep -r "using FileParsing" src/Ui/; then
echo "Ошибка: Ui не должен зависеть от FileParsing"
exit 1
fi - Ввести соглашение об именовании веток:
feature/parsing/csv-support,fix/ui/table-rendering,refactor/core/command-handlers.
Этап 3. Документирование «точек касания» (постоянно)
Задача: снизить когнитивную нагрузку при входе в чужой модуль.
Действия:
- В каждом модуле —
README.mdс:- назначением модуля,
- контрактами (интерфейсами),
- примерами использования,
- владельцем.
- Для use case —
SPEC.md:## ProcessLargeLogFile
### Вход
- `ProcessLogCommand { FilePath, CancellationToken }`
### Выход
- `LogProcessingResult { Entries, Warnings }`
### Зависимости
- `ILogParser` (из FileParsing),
- `IProgressReporter` (из Ui). - Генерировать диаграммы зависимостей:
- PlantUML-скрипты в
docs/architecture/, - обновлять при каждом major-релизе.
- PlantUML-скрипты в
Результат:
- Количество конфликтов в Git снизилось на 70%.
- Время вхождения нового разработчика — 2 дня вместо 2 недель.
- Качество код-ревью повысилось — фокус на логике, а не на стиле.
Сценарий 3. Смена технологического стека: миграция с SQL на NoSQL без остановки
Контекст:
Сервис UserService.Api использует PostgreSQL через Entity Framework Core. Требуется перейти на MongoDB для гибкости схемы (динамические поля профиля). Прямая миграция невозможна — сервис работает 24/7.
Цель:
Постепенный переход с сохранением данных и API.
Этап 1. Введение двойной записи (2–3 недели)
Задача: сохранять данные в обе БД параллельно.
Действия:
- Добавить MongoDB-клиент в
Infrastructure/Persistence/:Infrastructure/
└── Persistence/
├── Sql/
│ └── EfUserRepository.cs
└── Mongo/
└── MongoUserRepository.cs - Создать декоратор для репозитория:
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);
}
} - Настроить DI для использования
DualWriteUserRepositoryв продакшене. - Добавить мониторинг:
- Логировать расхождения (если запись в SQL удалась, а в Mongo — нет),
- Алерт при >0.1% ошибок.
Этап 2. Постепенное переключение чтения (3–4 недели)
Задача: начать читать из MongoDB, сохраняя запись в обе БД.
Действия:
- Ввести флаг
UseMongoForReading:public class ReadPreferenceUserRepository : IUserRepository
{
public async Task<User> GetByIdAsync(Guid id)
{
return _config.UseMongoForReading
? await _mongo.GetByIdAsync(id)
: await _sql.GetByIdAsync(id);
}
} - Включить флаг для 1% трафика (через feature flag).
- Сравнивать результаты:
- Логировать расхождения в
UserComparisonLog, - Автоматически отключать флаг при ошибках.
- Логировать расхождения в
- Постепенно увеличивать долю трафика до 100%.
Этап 3. Отключение старой БД (1 неделя)
Задача: полностью перейти на MongoDB.
Действия:
- Заменить
DualWriteUserRepositoryнаMongoUserRepository. - Запустить полную синхронизацию данных:
- Скрипт читает все записи из PostgreSQL,
- Проверяет наличие в MongoDB,
- Добавляет недостающие.
- Отключить 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).
- Алерты при выходе за пороги.