Кросс-доменное взаимодействие
Модуль FDA владеет своим кодом, но не работает в вакууме: корзина отдаёт число товаров в шапку, чекаут читает её содержимое, дашборд строит отчёт по заказам. Связей между модулями два вида — вертикальные (родитель и потомок) и горизонтальные (соседние домены первого уровня), — и правила у них разные: потомку доступно больше, соседу только публичный вход. Этот раздел разбирает оба случая и алиасы, которые держат импорты читаемыми.
🪆 Родитель и потомок
Заголовок раздела «🪆 Родитель и потомок»Субдомен — часть домена: _checkout живёт внутри cart и без родителя смысла не имеет. Поэтому связи между ними теснее, чем между соседями: родитель собирает работу потомков в свой процесс, а потомки читают состояние родителя. Опасность здесь одна — замкнуть два файла друг на друга, когда импорты идут в обе стороны. Тогда субдомен перестаёт быть переносимым: его нельзя ни вынести в отдельный домен, ни заменить, не задев родителя. Правило простое: между двумя файлами связь односторонняя, и для неё есть три механизма, по одному на слой.
Модель: подпроцессы внутри одного процесса
Заголовок раздела «Модель: подпроцессы внутри одного процесса»Модель родителя вызывает модели потомков, когда операция потомка — часть операции родителя. Оформление заказа: оплата и доставка описаны приватными субдоменами, каждая модель отвечает за свой процесс, а order/model.server.ts собирает их в один createOrder.
import { charge } from "./_payment/model.server";import { schedule } from "./_delivery/model.server";import * as repo from "$lib/server/repo/db";
export async function createOrder(input: OrderInput): Promise<Order> { const payment = await charge(input.payment); const delivery = await schedule(input.delivery); return repo.orders.insert({ ...input, payment, delivery });}Направление строго родитель → потомок. Обратный импорт (_payment тянет order) создаёт цикл: оба модуля невозможно вынести или перенести по отдельности, а на ревью связь выглядит как обычный импорт модели. Модель потомка про родителя не знает и остаётся самодостаточным процессом — именно это позволяет позже превратить её в отдельный домен.
Компоненты и состояние: потомок импортирует состояние родителя
Заголовок раздела «Компоненты и состояние: потомок импортирует состояние родителя»Общее состояние домена живёт у родителя — в stores.ts или controller.svelte.ts: текущая корзина, флаг загрузки, ошибка, выбранная позиция. Это состояние нужно потомкам, и без механизма они получали бы его через props drilling: страница → список → карточка → счётчик, естественно, такая реализация является антипаттерном.
Обычно эта задача решается через контекст. В Svelte, React и Nuxt родитель распространяет потомкам состояние по ключу, а потомоки достают его и используют. В FDA можно получить тот же эффект через обычный импорт: stores.ts содержит синглтон-сторы, поэтому они отдают тоже самое реактивное состояние как родителю, так и потомкам.
// src/routes/cart/stores.ts — состояние принадлежит родителюexport const cart = writable<Cart>({ items: [], total: 0, totalCount: 0 });export const selectedItemId = writable<string | null>(null);<!-- src/routes/cart/items/ui/ItemList.svelte — потомок читает состояние родителя --><script lang="ts"> import { cart, selectedItemId } from "../../stores";</script>
{#each $cart.items as item (item.id)} <CartItemCard {item} selected={item.id === $selectedItemId} />{/each}Направление остаётся односторонним: потомок знает про сторы родителя, родитель про потомка — нет. cart/stores.ts ничего из items/ не импортирует; как только родитель потянется к состоянию потомка, связь замкнётся в циклическую зависимость.
Частый случай, где такой импорт окупается, — производное состояние от выбранного объекта. В корзине кликают по строке: id выделения живёт у родителя в selectedItemId, рядом с самой корзиной. А карточке под списком нужен уже не id, а сам объект — название, цена, количество выбранного товара. Разметка может найти его и сама, $cart.items.find((item) => item.id === $selectedItemId), но тогда этот find повторяется в каждом компоненте, которому нужен выбор, и каждый повтор — ещё один источник правды.
Контроллер потомка закрывает вопрос одним derived-стором: импортирует сторы родителя и выводит из них готовый объект.
// src/routes/cart/items/stores.ts — контроллер потомкаimport { derived } from "svelte/store";import { cart, selectedItemId } from "../stores";
export const selectedItem = derived([cart, selectedItemId], ([$cart, $id]) => $cart.items.find((item) => item.id === $id) ?? null,);Разметка потомка читает selectedItem как любой свой стор — откуда взялись корзина и выделение, ей знать не нужно. Меняется коллекция или клик — пересчитывается один стор, а не каждый компонент по отдельности.
Сложные производные состояния — валидация выбранной позиции, превью с жизненным циклом ресурса, прогресс загрузки — живёт там же, в контроллере потомка: сторы строятся поверх уже существующих родительских.
RPC: родитель агрегирует rpc потомков
Заголовок раздела «RPC: родитель агрегирует rpc потомков»Для внешнего мира rpc-родитель выглядит одной точкой входа. Его rpc.ts собирает композер: собственные обработчики плюс rpc-объекты потомков. Обратите внимание: родитель не реэкспортирует функции модели — каждый публичный метод принадлежит классу-обработчику, валидирует вход и сам вызывает модель. Реэкспорт модели из rpc.ts выглядел бы короче, но это нарушение контракта: публичный вход теряет валидацию и начинает светить сигнатурами модели наружу.
// src/routes/cart/rpc.ts — публичный вход доменаimport { Composer, toRPC } from "@chord-ts/rpc";
import * as model from "./model.server";import { Items } from "./items/rpc";import { Checkout } from "./_checkout/rpc";
export class Cart { async getSummary(userId: string) { const cart = await model.getCart(userId); return { ...cart, isEmpty: cart.items.length === 0 }; }
async removeFromCart(userId: string, itemId: string) { if (!itemId) throw new Error("itemId is required"); return model.removeFromCart(userId, itemId); }}
export const composer = Composer.init({ Cart: toRPC(new Cart()), Items: toRPC(new Items()), Checkout: toRPC(new Checkout()),});Тот же композер обслуживает HTTP-вход домена: +server.ts возвращает json(await composer.exec(event)), поэтому браузер и соседние домены проходят через одни и те же обработчики с одной валидацией. Без библиотеки-композера ту же роль играет обычный объект: собственные функции родителя плюс rpc-объекты потомков по именам — суть не в инструменте, а в том, что агрегируются обработчики, а не реэкспортируется модель.
Агрегация не значит «тащить всё». В композер попадают только те методы, которые соседи реально запрашивают; остальное остаётся внутренним делом домена и может меняться без объявления.
| Связь | Слой | Направление | Когда нужна |
|---|---|---|---|
| Композиция моделей | model.server.ts | родитель → потомок | Процесс потомка — часть процесса родителя: оплата и доставка внутри оформления заказа |
| Сторы родителя | stores.ts → ui/ потомка | потомок импортирует родителя | Состояние, общее для домена, и производное от выбранного объекта: текущая корзина, выделение, статусы |
| Агрегация rpc | rpc.ts | сосед → родитель | Внешний домен обращается к модулю как к целому, не зная его субдоменов |
🤝 Соседние домены
Заголовок раздела «🤝 Соседние домены»cart и auth — равные: ни один не владеет другим. Стоит разрешить импорт внутренностей соседа, и граница исчезает: правка модели корзины ломает шапку, а на вопрос «где логика корзины» снова невозможно ответить одним словом. Поэтому соседи общаются только через то, что домен опубликовал: в браузере — HTTP-эндпоинты через собственный client.ts, на сервере — импорт через алиас на публичный вход index.ts.
В живом примере эта пара выглядит так. +server.ts домена принимает запросы и диспетчерит их в модель, а client.ts заворачивает вызовы для контроллера:
// src/routes/cart/client.ts — публичный вход домена для браузераexport async function addToCart(productId: string, quantity = 1): Promise<Cart> { const res = await fetch("/cart", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "add", productId, quantity }), }); return res.json();}Кросс-импорт через алиас не ограничен эндпоинтами: алиас указывает на index.ts домена, и импортируемо всё, что там опубликовано, — rpc-композер, типы, константы. Главное — опубликовать в index.ts: чего не опубликовали, того для соседа не существует, внутренности через алиас не достать. А вместо относительных крючков ../../cart в коде используется имя домена.
const config = { kit: { alias: { $cart: "./src/routes/cart/index.ts", }, },};// импорт у соседаimport { composer } from "$cart";{ "compilerOptions": { "paths": { "@cart": ["./app/cart/index.ts"] } }}// импорт у соседаimport { composer } from "@cart";export default defineNuxtConfig({ alias: { "@cart": "./pages/cart/index.ts", },});// импорт у соседаimport { composer } from "@cart";// src/routes/dashboard/+page.server.ts: сосед берёт только публичный входimport { composer } from "$cart";
export const load: PageLoad = async ({ locals }) => { const cart = composer.createScoped(locals); return { cart: await cart.Cart.getSummary(locals.userId) };};В браузере домены не импортируют друг друга вовсе: разметка говорит со своим контроллером, контроллер — со своим client.ts, а тот стучится в HTTP-эндпоинт своего домена. Прямой импорт чужого стора в разметку тянет за собой контекст и зависимости, таким образом превращая границу между доменами в фикцию.
🚧 Как ломают границы
Заголовок раздела «🚧 Как ломают границы»Четыре способа сломать границу — и что происходит, когда её ломают:
- Импорт модели соседа вместо alias — незаметно повышается связность, которая в дальнейшем сильно выстрелит. Осознанно подходим к кросс-связям и предпочитаем иерархические связи, над соседними.
- Реэкспорт не тех сущностей не из того слоя — нарушается последовательность и повышается хаотичность импортов. Экспорт/импорты желательно делать в рамках функционального слоя, если речь идет о связь родитель-потомок.
- Неправильное направление импортов — импорты должны быть однонаправленными, дабы избежать циклические зависимости
- Импорт субдомена соседа напрямую, в обход родителя-агрегатора — структура субдоменов перестаёт быть внутренним делом домена.
🔗 Следующие шаги
Заголовок раздела «🔗 Следующие шаги»→ Контракты файлов — роль и экспорты каждого файла, упомянутого выше
→ FAQ и чеклист — проверка связей модуля перед мержем