Тема
Обмен данными: ядро ↔ модули ↔ компоненты
Статус: зафиксировано. Сводит в одну картину все каналы обмена, разбросанные по трём осям, расширяемости, контракту модуля и графу зависимостей. Единой «супершины» нет намеренно — есть пять узких каналов, каждый под свой тип обмена. Всё, что не входит в эти каналы, — запрещено (главный источник хаоса в зрелых CMS — обход каналов).
Пять каналов обмена
| # | Канал | Тип обмена | Направление | Когда использовать |
|---|---|---|---|---|
| 1 | Шина событий | «случился факт» (fire-and-forget) | любой → все подписчики | Реакция на чужое действие: заказ оплачен → выдать цифровой товар, начислить бонусы, отправить чек |
| 2 | FilterBus (фильтры) | «доработай значение» (конвейер с порядком) | ядро/модуль → цепочка фильтров → обратно | Модификация данных в потоке владельца: скидки пересчитывают корзину, SEO-модуль дописывает мета |
| 3 | Сервисные контракты (provides) | «нужна способность» (вызов по интерфейсу) | потребитель → реализация через DI | Абстрактная зависимость: платежи вызывают PaymentGateway, не зная, что за ним ЮKassa |
| 4 | Прямой сервис-вызов (requires) | «нужны данные/действие конкретного модуля» | зависимый → публичный сервис владельца | Жёсткая зависимость по графу: корзина запрашивает у commerce-pricing эффективную цену |
| 5 | Очереди | асинхронная работа | издатель job → воркер | Всё тяжёлое/внешнее: индексация, письма, вебхуки, импорт |
Плюс два внешних канала (не для внутримодульного обмена): REST /api/v1 (API-контракт) — для headless-фронтов, кабинетов (зона 3), мобильных приложений; и шина интеграций cms/integrations-bus + вебхуки — для внешних систем. Модуль никогда не дёргает собственный сайт по HTTP — внутри процесса есть каналы 1–5.
Как выбрать канал (алгоритм)
Четыре вопроса — в этом порядке:
- Мне нужен результат прямо сейчас, чтобы продолжить? Нет → событие (канал 1); тяжёлую реакцию подписчик сам унесёт в очередь (канал 5).
- Я дорабатываю чужое значение в его потоке (цена, мета, состав корзины)? Да → фильтр (канал 2): владелец потока определяет точку и порядок, я встраиваюсь.
- Мне нужна способность, а не конкретный модуль (принять платёж, отправить SMS)? Да → provides-контракт (канал 3): завишу от интерфейса, реализацию решает сайт.
- Мне нужны данные/действие конкретного модуля, без него я не работаю? Да →
requiresв манифест и прямой вызов его публичного сервиса (канал 4).
Если ни один пункт не подошёл — скорее всего, обмен не нужен вовсе (переизбыток связей), либо задача решается на стороне владельца данных новым событием/фильтром.
Гарантии: транзакции, доставка, порядок
Правила, без которых каналы превращаются в источник гейзенбагов:
- события — после коммита (
afterCommit): подписчик не должен увидеть факт, которого ещё нет в БД (или который откатится). Событие внутри транзакции = баг; - слушатель идемпотентен: очередь гарантирует «хотя бы одну» доставку, не «ровно одну» — повтор job не создаёт второй чек и не начисляет бонусы дважды (ключ идемпотентности по id события/сущности);
- порядок не гарантирован между событиями разных джобов: подписчик работает от текущего состояния сущности, а не от предполагаемой последовательности событий;
- сбой подписчика — его проблема: ретраи с backoff, после исчерпания — failed job
- алерт (
cms/health), но издатель и другие подписчики этого не замечают;
- алерт (
- критичная внешняя отправка (чек в ОФД, вебхук партнёру) — через журнальную запись в своей транзакции + отправку из очереди по журналу (outbox): сбой процесса между коммитом и отправкой не теряет отправку;
- тестируемость: контракт каждого канала проверяется стандартными средствами —
Event::fake(издание), прогон конвейера фильтров, мок provides-контракта; ТЗ модуля перечисляет свои события/фильтры/контракты — это и есть его тестовая поверхность.
Ядро ↔ модуль
Ядро ничего не знает о модулях. Обмен всегда инициируется по контрактам ядра:
- вниз (ядро → модули): ядро издаёт канонические события (
PagePublished,LeadCreated,SettingChanged…) и прогоняет данные через фильтры — модули подписываются/встраиваются. Слушатели изолированы: упавший слушатель логируется и не валит поток ядра; - вверх (модуль → ядро): только через регистрацию в реестрах на bootstrap — блоки в BlockRegistry, виджеты в WidgetRegistry, типы полей в FieldTypeRegistry, настройки в settings-store (своя группа), расписание через
registerScheduleVia, permissions из манифеста. Модуль не правит таблицы, конфиги и код ядра; - данные ядра (страницы, лиды, медиа, пользователи) модуль читает через репозитории / query-сервисы
cms/core-contracts— не сырыми запросами вcms_*-таблицы.
Модуль ↔ модуль
Правило выбора канала — по силе связи:
- Связи нет (модули друг о друге не знают) → только события/фильтры. Пример:
cms/loyaltyслушаетOrderPaidотcms/commerce-orders— заказы не знают о существовании бонусов; - Связь абстрактная (нужна «какая-то реализация способности») →
provides-контракт: интерфейс живёт вcms/core-contracts, реализация резолвится DI-контейнером. Канонический реестр контрактов ведётся в одном месте — стандарт модуля, §2 (payment-gateway, delivery-provider, notification-channel, search-provider, …, geo-provider, import-target); новый контракт вводится только ревизией ядра; - Связь жёсткая (объявлен
requiresв манифесте) → вызов публичного сервиса модуля-владельца (его API-фасада). Пример:commerce-cart→PricingServiceизcommerce-pricing.
Владение данными: каждая таблица принадлежит ровно одному модулю. Чужие таблицы — только чтение и только через сервисы/репозитории владельца (не raw SQL): владелец может менять схему, не ломая соседей. Писать в чужие таблицы нельзя — «запись» в чужой домен выражается событием («предложи факт»), а владелец сам решает, как его применить. FK на чужую сущность допустим только на её первичный id; при выключении владельца строки остаются (деградация, жизненный цикл).
Компонент ↔ модуль
Компоненты (блоки, виджеты, Filament-ресурсы, секции кабинета) — представительства модуля в чужих зонах, а не самостоятельные акторы:
- блок/виджет объявляется модулем в реестре и рендерится ядром; данные для рендера он получает от сервиса своего модуля (не запросами в БД из шаблона). Модуль выключен → реестр отдаёт fallback-заглушку — страница жива;
- JSONB блока хранит только контент и ссылки (id сущностей), не бизнес-данные модулей: блок «товары раздела» хранит id раздела и параметры вывода, а список товаров запрашивает у
commerce-catalogна рендере (и кешируется тегами); - Filament-ресурсы модуля видят чужие сущности через те же сервисы и relation'ы, что и публичный код — без спецдоступа;
- секции кабинета (зона 3) получают данные через REST
/api/v1своего модуля — кабинет технически внешний потребитель.
Сквозной пример: «заказ оплачен»
Шлюз (внешний) → cms/webhooks-in (подпись, идемпотентность по event_id)
→ PaymentsService::confirm() [канал 4: requires]
→ событие PaymentSucceeded [канал 1]
├─ commerce-orders: статус paid → издаёт OrderPaid
├─ commerce-receipts: job фискализации в очередь [канал 5]
├─ commerce-digital: job выдачи файлов/лицензий [канал 5]
├─ loyalty: начисление бонусов (правила)
├─ notifications-bus: письмо/telegram клиенту и менеджеру [контракт 3]
└─ integration-crm: сделка в CRM через integrations-bus [внешний канал]Ни один из подписчиков не знает о других; отключение любого не ломает остальных; порядок неважен (каждый работает от факта, не от соседа). Где порядок важен (скидки → бонусы → итог для чека) — используется FilterBus с приоритетами внутри одного потока пересчёта корзины, а не цепочка событий.
Запрещено (антипаттерны обмена)
- прямой SQL в таблицы чужого модуля или ядра (даже SELECT);
- запись в чужие таблицы/настройки; «подкрутить чужую строку» = событие владельцу;
- вызов конкретного класса чужого модуля без
requires(скрытая зависимость — сломается при выключении); - HTTP-вызов собственного сайта из кода модуля;
- обмен через глобальное состояние (статики,
session()между модулями, файлы); - бизнес-логика в слушателе события (слушатель — тонкий: валидирует факт и кладёт job).
Перенос типов контента между инсталляциями (dev → prod)
Реализовано волной 22 (урок Directus, api-lessons §11):
cms:export <dir>— manifest.json со схемами всех типов контента и каноническим sha256-хешем каждой схемы;cms:import <dir> --dry-run— diff с целевой БД (new/changed/same) + фиксация apply-токена: хеша текущего состояния цели;cms:import <dir>— применяет manifest, отклоняется, если схема цели изменилась после снятия diff (optimistic lock); осознанный обход —--force. Токен одноразовый.
Граница входа на импорте та же, что в админке: Field::fromArray + RegexGuard::assertSafeRules на каждое поле (включая вложенные). Записи (cms_content_entries) переносятся отдельно (bulk-канал JSONL — фаза 3).
Связи
- Три оси — события, FilterBus, очереди, настройки (механика);
- Расширяемость — реестры и лестница расширения;
- Граф зависимостей — requires/provides/каскады;
- Интеграции — внешний контур (шина коннекторов, вебхуки);
- ТЗ модулей — контракты обмена конкретных модулей.