Склейка модулей
Как связать features без feature → feature: props, события, порты.
Feature не импортирует feature — жёсткое правило. Но продукту нужна склейка: каталог добавил в корзину → показать уведомление, checkout завершился → очистить корзину.
Ответ DMA: корень композиции связывает модули, импортируя обе стороны вниз.
Четыре механизма
| Задача | Механизм |
|---|---|
| Нужен код ниже по слоям | Прямой импорт services/*/public/* или shared/ |
| Уведомить, не зная подписчиков | Событие в public/ эмиттера; подписка в app/ |
| Прямой импорт дал бы цикл | Порт в public/ports.ts + привязка в app/ |
| Только визуальная композиция | Props / slots в корень композиции |
Props и callbacks (самый простой)
Корень композиции монтирует оба модуля и передаёт callback:
// app/App.tsx
import { CatalogPage } from "@/features/catalog/public/catalog-page";
import { addNotification } from "@/features/notifications/public/notifications-api";
<CatalogPage
onAddedToCart={(name) => addNotification(`Added ${name}`)}
/>- Граф:
app → catalog,app → notifications— оба вниз - Нет ребра
catalog → notifications
Это паттерн из vite-react example.
События (event module)
Эмиттер экспортирует подписку из public/:
// features/checkout/public/checkout.events.ts
type Listener = () => void;
const listeners = new Set<Listener>();
export const onCheckoutCompleted = (fn: Listener) => {
listeners.add(fn);
return () => listeners.delete(fn);
};
export const emitCheckoutCompleted = () => {
for (const fn of listeners) fn();
};Корень композиции подписывается и вызывает service:
<!-- routes/app-providers.svelte (SvelteKit example) -->
<script>
import { onCheckoutCompleted } from "@/features/checkout/public/checkout.events";
import { clearCart } from "@/services/cart/public/cart";
onCheckoutCompleted(() => clearCart());
</script>Порты (когда нужен runtime привязка)
Порт — интерфейс в public/ports.ts, реализация подставляется в app/:
// services/cart/public/ports.ts
export type CartSeedPort = () => Product[];
let seed: CartSeedPort | null = null;
export const provideCartSeed = (port: CartSeedPort) => {
seed = port;
};
export const getCartSeed = () => seed?.() ?? [];---
// app/shop-providers.astro (Astro example)
import { provideCartSeed } from "@/services/cart/public/ports";
import { demoCartSeed } from "@/app/cart-seed";
provideCartSeed(demoCartSeed);
---
<slot />Порты невидимы для статического графа — не используйте их, чтобы обойти promotion (feature-has-inbound).
Providers / context (React, Vue)
Cross-cutting context (session, theme) — в app/providers:
// app/providers.tsx
"use client";
import { SessionProvider } from "@/services/session/public/session-provider";
export function Providers({ children }) {
return <SessionProvider>{children}</SessionProvider>;
}Feature читает через hook/inject, не импортируя другой feature. Пример — next-app.
Антипаттерны
| ✗ | Почему |
|---|---|
catalog импортирует notifications | feature → feature |
Глобальный event bus в shared/ без владельца | Непонятный граф, скрытые рёбра |
| Порт вместо promotion | Входящие рёбра от модуля → должен быть services/ |
| Вся логика склейки в одном god-feature | Нарушает предикат изолированного модуля |
Что проверяют инструменты
feature-to-feature— прямой импорт между featuresno-cycle— цикл через services/shared/applayer-direction— app не импортируется модулями