В данном документе описаны ключевые архитектурные решения и диаграммы проекта. Сравнение различных подходов к написанию кода (классический Yii2 MVC, MVC с сервисным слоем и Clean Architecture) подробно описано в docs/COMPARISON.md. Также мы задокументировали все осознанные компромиссы - те архитектурные решения и ограничения, которые мы приняли ради гармоничной и прагматичной работы с фреймворком, в файле docs/DECISIONS.md.
- 🎯 Главное правило Clean Architecture
- 🏗 Архитектура C4 model
- 🧭 Архитектурные решения
- 1. Слой приложения (Use Cases, CQS, Ports)
- 2. Слой домена (Rich Domain Model)
- 3. Слой представления (Yii2)
- 4. Разделение ответственности: Use Cases vs сервисы представления
- 5. Инфраструктура и окружение
- 6. DTO и формы для валидации
- 7. Инфраструктурный слой
- 8. Качество кода и стандарты
- 9. Гибридный поиск (Specification)
- 10. Асинхронные операции (fan-out)
- 11. Пагинация и кеширование
- 12. Внедрение зависимостей
- 13. Наблюдаемость и логирование
- 14. Хранилище файлов (CAS)
- 15. Инфраструктурное ядро
- 16. Маппинг данных (AutoMapper и Hydrator)
- 17. Pull-модель доменных событий (event flow)
- 📁 Структура проекта
Бизнес-логика не знает, как её вызывают и куда сохраняют данные.
Внешние слои (зависят от Yii2):
┌────────────────────────────────────────────────────────────┐
│ PRESENTATION │ Контроллеры, формы, представления │
├────────────────────────────────────────────────────────────┤
│ INFRASTRUCTURE │ ActiveRecord, Queue, Repositories │
└────────────────────────────────────────────────────────────┘
↓ зависят от ↓
Внутренние слои (чистый PHP, без Yii):
┌────────────────────────────────────────────────────────────┐
│ APPLICATION │ UseCases, Commands, Queries, Ports │
├────────────────────────────────────────────────────────────┤
│ DOMAIN │ Entities, Value Objects, Events │
└────────────────────────────────────────────────────────────┘
application/ и domain/ не используют Yii2 (в этих слоях нет ссылок на Yii), а presentation/ и infrastructure/ содержат интеграцию с фреймворком.
Для визуализации архитектуры на разных уровнях абстракции используется модель C4.
Схема взаимодействия системы с внешним миром.
graph TD
User((User/Admin))
System[Book Catalog System]
SMS["SMS Provider (External)"]
User -- "Browses & Manages Books" --> System
System -- "Sends Notifications" --> SMS
style System fill:#1168bd,stroke:#0b4884,color:#ffffff
style SMS fill:#999999,stroke:#666666,color:#ffffff
Инфраструктура и контейнеры (Docker).
graph TD
User((User))
subgraph DockerHost ["Docker Host"]
Nginx["Nginx (Web Server)"]
PHP["PHP-FPM (Application)"]
Worker["Queue Worker (PHP CLI)"]
DB[("Database (MySQL / PgSQL)")]
Redis[("Redis (Cache)")]
end
SMS["SMS Provider"]
User -- HTTPS --> Nginx
Nginx -- FastCGI --> PHP
PHP -- Read/Write --> DB
PHP -- Push Jobs --> DB
PHP -- Cache --> Redis
Worker -- Pop Jobs --> DB
Worker -- Read/Write --> DB
Worker -- API Calls --> SMS
style PHP fill:#1168bd,stroke:#0b4884,color:#ffffff
style Worker fill:#1168bd,stroke:#0b4884,color:#ffffff
style DB fill:#2f95c4,stroke:#206a8c,color:#ffffff
style Redis fill:#2f95c4,stroke:#206a8c,color:#ffffff
Очередь работает через yii\queue\db\Queue (задания хранятся в базе данных MySQL или PostgreSQL). Redis используется как кэш.
Внутреннее устройство слоя приложения (Clean Architecture).
graph TD
subgraph Presentation ["Слой представления (Yii2)"]
Controller[Web Controller]
Handler[Command Handler]
Mapper[Mapper]
Filter[Idempotency Filter]
end
subgraph Application ["Слой приложения (Pure PHP)"]
UseCase[Use Case]
Port["Outbound Port (Interface)"]
end
subgraph Domain ["Слой домена (Pure PHP)"]
Entity[Domain Entity]
VO[Value Object]
Event[Domain Event]
end
subgraph Infrastructure ["Инфраструктурный слой"]
RepoImpl[Repository/QueryService Impl]
Adapter[Adapter Impl]
AR[ActiveRecord]
Job[Queue Job]
end
DB[(Database)]
%% Request Flow
Controller -- "1. Form DTO" --> Handler
Handler -- "2. Map to Command" --> Mapper
Handler -- "3. Execute Command" --> UseCase
Filter -- "Mutex Lock" --> Handler
%% Logic Flow
UseCase -- "4. Business Logic" --> Entity
Entity -- "5. Rules" --> VO
UseCase -- "6. Publish Event" --> Port
UseCase -- "7. Save" --> Port
%% Infra Implementation
RepoImpl -.->|"Implements"| Port
Adapter -.->|"Implements"| Port
RepoImpl -- "8. Map to AR" --> AR
AR -- "9. SQL" --> DB
Adapter -- "Async" --> Job
style UseCase fill:#1168bd,stroke:#0b4884,color:#ffffff
style Entity fill:#1168bd,stroke:#0b4884,color:#ffffff
style VO fill:#1168bd,stroke:#0b4884,color:#ffffff
Чтение и запись разделены по CQS, а внешние зависимости вынесены в порты:
- Запись (команды): любое изменение в системе (создание книги, подписка) - это отдельный Use Case. Данные поступают через строго типизированные Command DTO.
- Чтение (запросы): read-side реализован через порты (
BookFinderInterface,BookSearcherInterface). Реализации портов (Query Services) - только вinfrastructure/queries/. Read DTO (BookReadDto,ReportDtoи т.п.) - вapplication/*/queries. - Контракт DTO-only для
application/*/queries: папка содержит только read DTO и критерии поиска. Запрещены: сервисы, Use Cases, зависимости отinfrastructure, бизнес-логика. Разрешены:final readonlyклассы с данными, простые геттеры,with*()-методы,JsonSerializable. Проверка: phparkitect (final, readonly, NotDependsOn infra). - Порты: интерфейсы в
application/ports(checkers, query services и др.) иdomain/repositories(репозитории сущностей) позволяют менять реализацию без изменений бизнес-логики.
Здесь находится бизнес-суть приложения без привязки к вебу и базе данных:
- Rich Entities: сущность
Bookуправляет статусом и авторами, соблюдая бизнес-правила. Конструктор приватный - создание черезBook::create(), восстановление из БД черезBook::reconstitute(). - Контроль изменяемости: доменные сущности используют
private(set)и меняются через методы. - Value Objects:
Isbn,BookYear,StoredFileReference,Phone,AuthorIdгарантируют валидность данных при создании. - Status FSM: статус книги моделируется через
BookStatusenum (черновик / опубликована / в архиве) с переходами черезtransitionTo(target, policy). - Domain Events:
BookStatusChangedEvent,BookUpdatedEvent,BookDeletedEventнакапливаются в сущности при мутации и связывают части системы без прямых зависимостей. - Domain Guards:
replaceAuthors()запрещает убирать всех авторов у опубликованных/архивных книг. - Specifications: поиск формализован через
domain/specifications(FullTextSpecification,IsbnPrefixSpecification,AuthorSpecification,StatusSpecification,YearSpecification,CompositeAndSpecification,CompositeOrSpecification).
Слой отвечает за UI, HTTP и сценарии пользователя:
- Контроллеры: максимально тонкие, ошибки обрабатываются через try/catch
ApplicationException. - Формы: валидация HTTP-ввода в
presentation/*/forms. - Handlers & view factories: обработка команд и подготовка данных для UI.
- Read DTO: чтение отделено от отображения через
BookReadDto. - Фильтры: идемпотентность и rate limit оформлены отдельными фильтрами.
- WebOperationRunner: централизованный запуск Use Cases, работа с pipeline и обработка ошибок.
- Command Pipeline: транзакции, идемпотентность и маппинг ошибок вынесены в middleware.
- HTMX: фронт использует HTMX для бесшовной подгрузки и фильтрации.
Use Cases (слой приложения) - бизнес-логика:
- Работают с Command/DTO объектами;
- Не знают о формах, HTTP и формате ответа;
- Независимы от способа представления.
Слой представления разделен на Handlers и view factories:
- Command Handlers: маппят форму в команду через
CommandMapperи вызывают Use Case черезWebOperationRunner. - View factories: подготавливают данные для отображения.
- Контроллер: координирует HTTP и делегирует работу.
Пример разделения:
// presentation/controllers/BookController.php
public function actionCreate(): string|Response
{
$form = $this->itemViewFactory->createForm();
if (!$this->request->isPost || !$form->loadFromRequest($this->request)) {
return $this->renderCreateForm($form);
}
if ($this->request->isAjax) {
return $this->asJson(ActiveForm::validate($form));
}
if (!$form->validate()) {
return $this->renderCreateForm($form);
}
try {
$bookId = $this->commandHandler->createBook($form);
return $this->redirect(['view', 'id' => $bookId]);
} catch (ApplicationException $e) {
$this->addFormError($form, $e);
return $this->renderCreateForm($form);
}
}// presentation/books/handlers/BookCommandHandler.php
public function createBook(BookForm $form): int
{
$cover = $this->operationRunner->runStep(
fn(): ?string => $this->processCoverUpload($form),
'Failed to upload book cover',
);
if ($form->cover instanceof UploadedFile && $cover === null) {
throw new OperationFailedException(StorageErrorCode::OperationFailed->value, field: 'cover');
}
$command = $this->commandMapper->toCreateCommand($form, $cover);
$result = $this->operationRunner->executeAndPropagate(
$command,
$this->useCases->create,
Yii::t('app', 'book.success.created'),
);
assert(is_int($result));
return $result;
}- Переключение между MySQL и PostgreSQL управляется
DB_DRIVERи конфигамиconfig/db.php. - Очередь реализована через
HandlerAwareQueue, задания хранятся в базе. - Время инкапсулировано через
Psr\Clock\ClockInterfaceиSystemClock. - Интерактивная отладка доступна через
make shell.
- Формы валидируют HTTP-ввод в
presentation/*/forms. - Команды (
CreateBookCommand,UpdateBookCommand) живут вapplication/*/commands. - Read-side DTO (
BookReadDto,ReportDtoи др.) - вapplication/*/queries. Контракт DTO-only: толькоfinal readonlyклассы-контейнеры данных, без сервисов и инфраструктурных зависимостей. Реализации Query Services - только вinfrastructure/queries. - Пагинация оформлена через
PaginationDtoиPagedResultInterface.
- ActiveRecord модели размещены в
infrastructure/persistenceи используются только внутри инфраструктуры. - Репозитории сущностей реализуют интерфейсы из
domain/repositoriesвinfrastructure/repositories. Технические хранилища (Idempotency, RateLimit, AsyncIdempotency) - вinfrastructure/adapters/как*Storage. - События публикуются через
YiiEventPublisherAdapter, маппинг в jobs делаетEventToJobMapper. - Оптимистическая блокировка включена в
infrastructure/persistence/Book.phpчерезOptimisticLockBehavior, конфликты версий транслируются вStaleDataException.
- Строгая типизация (
declare(strict_types=1)) во всех PHP-файлах. - PHPStan level 9, кастомные правила в
infrastructure/phpstan. - PHPStan правила:
QueryPortsMustReturnDtoRule,NoActiveRecordInDomainOrApplicationRule,NoGhostQueryServiceInApplicationRule,DomainEntitiesMustBePureRule,DomainIsCleanRule,DisallowDateTimeRule,DisallowYiiTOutsideAdaptersRule,StrictRepositoryReturnTypeRule,UseCaseMustBeFinalRule,ValueObjectMustBeFinalRule. - Rector для авто-рефакторинга и миграций синтаксиса.
- Код-стайл через
phpcs.xml.dist. - Архитектурные ограничения через Deptrac и Arkitect.
- Deptrac: все 8 поддиректорий domain покрыты слоями -
DomainShared(values, events, exceptions, common),DomainEntities,DomainServices,DomainSpecifications,DomainRepositories. - Arkitect: domain isolation -
app\domainне может зависеть отyii,app\application,app\infrastructure,app\presentation(изоляция домена от фреймворка и внешних слоёв). - Arkitect:
application/*/queries- final, readonly, NotDependsOn(infrastructure) (контракт DTO-only).
- Критерии поиска формируются в
BookSearchSpecificationFactory. - Спецификации (
FullTextSpecification,IsbnPrefixSpecification,AuthorSpecification,StatusSpecification,YearSpecification) живут вdomain/specifications. - Композитные спецификации (
CompositeAndSpecification,CompositeOrSpecification) позволяют комбинировать критерии. ActiveQueryBookSpecificationVisitorстроит запросы под MySQL/PgSQL и делает fallback наLIKE.- Для ISBN используется префиксный поиск, для года - точное совпадение.
- Поиск по авторам идет через отдельную спецификацию и подзапрос.
- Публичный каталог использует
searchPublished()- поиск только среди опубликованных книг черезStatusSpecification. - В UI используется HTMX для фильтрации без полной перезагрузки страницы.
Чтобы тяжелые задачи не тормозили интерфейс:
- Сущность Book регистрирует событие при
transitionTo(); репозиторий публикует накопленные события приsave()(см. раздел 17). EventJobMappingRegistryмаппит событие вNotifySubscribersJobусловно (только если новый статус =Published).NotifySubscribersHandlerсоздает отдельныйNotifySingleSubscriberJobдля каждого подписчика.
Идемпотентность фоновой рассылки обеспечивается через AsyncIdempotencyStorageInterface внутри NotifySingleSubscriberHandler.
Результат: интерфейс отвечает сразу, а рассылка выполняется параллельно в фоне.
- Query Services возвращают
PagedResultInterfaceсPaginationDto. - Query Services не отдают
ActiveDataProvider, чтобы не тащить Yii2 в слой приложения. - В presentation-layer используется адаптер
PagedResultDataProvider. - Кэширование отчетов реализовано в
ReportQueryServiceCachingDecorator, инвалидация - черезReportCacheInvalidationListener.
- Зависимости передаются через конструкторы и настраиваются в
config/container/*.php. - В слоях application/domain нет обращений к
Yii::$app. - Фоновые задачи остаются DTO благодаря
HandlerAwareQueueиJobHandlerRegistry.
- Структурированное логирование через
YiiPsrLoggerс привязкойRequestIdProvider(UUID per request). - Каждый HTTP-ответ содержит заголовок
X-Request-Idдля корреляции запросов. - Логи разделены по категориям (
sms,error,warning) с выделенными файлами.
- Файловое хранилище реализовано через
ContentAddressableStorage. - Ключи формируются через
FileKey, а домен работает сStoredFileReference. - Имя файла = sha256 от содержимого, что дает дедупликацию.
- Домен не знает о путях к файлам, он оперирует ссылками на контент.
BaseActiveRecordRepositoryсодержит Identity Map на основеWeakMap, переводит ошибки БД в доменные исключения и обрабатываетStaleObjectException→StaleDataException.BaseQueryServiceстандартизирует пагинацию и маппинг в DTO.
- Read-side использует
AutoMapperи MappingListeners (Yii2ActiveRecordMappingListenerпарсит@propertyиз PHPDoc ActiveRecord-моделей,BookToBookReadDtoMappingListenerмаппит связанные данные). - Write-side использует
ActiveRecordHydratorв репозиториях (например,BookRepository).
События порождаются внутри сущностей и публикуются репозиторием после успешного сохранения. Полный flow:
- Сущность при мутации (например,
transitionTo(),changeYear(),markAsDeleted()) вызываетrecordEvent(DomainEvent)- трейтRecordsEventsнакапливает события во внутреннем массиве. - Use Case вызывает
repository->save($entity). Use Case не знает о событиях. - Репозиторий внутри транзакции выполняет
persist(), затем вызываетpublishRecordedEvents($entity). - publishRecordedEvents вызывает
$entity->pullRecordedEvents()- получает массив событий и очищает буфер сущности. - Для каждого события вызывается
TransactionalEventPublisher::publishAfterCommit($event)- колбэк регистрируется вTransactionInterface::afterCommit(). - После коммита транзакции БД выполняются колбэки - события публикуются через
EventPublisherInterface, маппятся в jobs и попадают в очередь (см. раздел 10).
src/domain/ - Слой домена (Business Logic)
├── common/ - Общие доменные элементы
├── entities/ - Сущности (Rich Model)
├── events/ - Domain Events
├── exceptions/ - Исключения домена
├── repositories/
├── services/ - Domain Services (редко)
├── specifications/ - Specifications (criteria)
├── values/ - Value Objects (Immutable)
src/application/ - Слой приложения (Application Logic)
├── common/ - Общие DTO и валидаторы
├── ports/ - Интерфейсы (Ports)
├── {{module}}/
│ ├── commands/ - DTO команд (Write)
│ ├── exceptions/ - Исключения модуля
│ ├── factories/ - Фабрики модуля
│ ├── mappers/ - Mappers модуля
│ ├── queries/ - DTO чтения (Read), DTO-only: final readonly, без infra
│ ├── usecases/ - Классы Use Case (execute)
src/infrastructure/ - Инфраструктурный слой (Framework Logic)
├── adapters/ - Адаптеры инфраструктуры
├── components/ - Вспомогательные компоненты
├── listeners/ - Event Listeners
├── mapping/ - Настройки маппинга
├── persistence/ - ActiveRecord модели (Mapping)
├── phpstan/ - Расширения и правила PHPStan
├── queries/ - Query Services
├── queue/ - Обработчики очередей
├── repositories/ - Реализации Repository (через AR)
├── services/ - Внешние сервисы
src/presentation/ - Слой представления (UI/API)
├── common/ - Общие компоненты
├── components/ - UI компоненты
├── controllers/ - Общие контроллеры
├── dto/ - DTO уровня представления
├── mail/ - Шаблоны писем
├── services/ - Общие сервисы представления
├── views/ - Шаблоны представлений
├── widgets/ - UI виджеты
├── {{module}}/
│ ├── dto/ - DTO уровня представления
│ ├── forms/ - Формы валидации
│ ├── handlers/ - Обработчики запросов
│ ├── mappers/ - Mappers модуля
│ ├── services/ - Сервисы модуля
│ ├── validators/ - Валидаторы модуля
│ ├── widgets/ - Виджеты модуля
assets/ - Frontend assets
bin/ - CLI утилиты
├── lib/ - Библиотеки CLI утилит
commands/ - Console контроллеры
├── support/ - Служебные утилиты и вывод карты проекта
config/ - Конфигурация приложения
├── container/ - Конфигурация контейнера зависимостей
docker/ - Docker конфигурация
├── nginx/ - Конфигурация nginx
docs/ - Документация
├── ai/ - Правила и инструкции для AI
├── generated/ - Автоматизированные материалы
messages/ - Переводы i18n
migrations/ - Миграции БД
runtime/ - Runtime кэш и логи
tests/ - Тесты
tools/ - Инструменты разработки
├── PHPUnit/ - Конфигурация PHPUnit
├── Rector/ - Конфигурация Rector
web/ - Web root
Список модулей:
| Модуль | Назначение |
|---|---|
| auth | Авторизация и сессии |
| authors | Управление авторами |
| books | Каталог книг |
| reports | Аналитические отчеты |
| subscriptions | Подписки на уведомления |
Независимы от Yii: application/ и domain/.
Зависят от Yii: infrastructure/ и presentation/.