Derived Modular Arch
Гайды

Склейка модулей

Как связать 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>

См. sveltekit-routes example.

Порты (когда нужен 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 импортирует notificationsfeature → feature
Глобальный event bus в shared/ без владельцаНепонятный граф, скрытые рёбра
Порт вместо promotionВходящие рёбра от модуля → должен быть services/
Вся логика склейки в одном god-featureНарушает предикат изолированного модуля

Что проверяют инструменты

  • feature-to-feature — прямой импорт между features
  • no-cycle — цикл через services/shared/app
  • layer-direction — app не импортируется модулями

Что дальше

On this page