Перейти к содержимому

Контракты файлов

Контракт файла — это его роль: одна на файл, с фиксированным набором экспортов. model.server.ts — только операции с данными, types.ts — только типы, policy.ts — только правила доступа.

Контракты решают три задачи. Поиск: зная роль, вы находите код по имени файла, а не полнотекстовым поиском. Ревью: нарушение видно сразу — логика в constants.ts бросается в глаза, даже не читая дифф. Генерация: агент с скиллом FDA знает, какой файл создавать и что в него писать, — и не перемешивает слои.

Структура домена

ФайлСлойЭкспортирует
schema.tsModelТаблицы ORM
model.server.tsModelФункции операций с данными
rpc.tsRPCAPI для других доменов
+server.tsRPCHTTP-обработчики
client.tsRPCFetch-обёртки для браузера
controller.svelte.ts / stores.tsControllerРеактивное состояние и обработчики
+page.server.tsControllerload и actions
+page.svelteUI—
ui/UIКомпоненты модуля
types.ts—Типы и интерфейсы
constants.ts—Константы и enum
policy.ts—Правила доступа
templates.ts—Шаблоны форм и валидация
utils.ts—Хелперы модуля
index.ts—Реэкспорт публичного API

Разберём каждый контракт. Примеры кода взяты из живого домена cart рабочего примера — имена файлов приведены для SvelteKit.

Декларативное описание таблиц ORM (Drizzle, Prisma). Никакой логики — только структура:

// lib/server/repo/db/schema.ts — пример на Drizzle
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").unique().notNull(),
createdAt: timestamp("created_at").defaultNow(),
});

❌ Не сюда: функции работы с данными — их место в model.server.ts.

Бизнес-правила домена над данными: каждый метод дёргает repo и возвращает готовые доменные объекты. Не знает ни про HTTP, ни про интерфейс:

routes/cart/model.server.ts
import { db } from "$lib/server/repo/db.mock";
import type { Cart, CartItem } from "./types";
export async function getCart(): Promise<Cart> {
const items = await db.table<CartItem & { id: string }>("cart_items").findMany();
return calculateCart(items);
}
function calculateCart(items: (CartItem & { id: string })[]): Cart {
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const totalCount = items.reduce((sum, i) => sum + i.quantity, 0);
return { items, total, totalCount };
}

Модель вызывают из load-функции, из RPC или из фонового скрипта — результат одинаков. ❌ Не сюда: HTTP-обработчики и реактивное состояние.

Публичный контракт модуля: соседние домены импортируют только отсюда. Каждый обработчик валидирует вход и вызывает одну функцию модели:

routes/cart/rpc.ts
import { getCart } from "./model.server";
import type { Cart } from "./types";
export async function getCartForOrder(): Promise<Cart> {
return getCart();
}

❌ Не сюда: доступ к UI, экспорт «всего подряд» — внешний API должен быть узким и стабильным.

Принимает запросы браузера, парсит тело, выбирает метод модели, возвращает JSON.

routes/cart/+server.ts
import { json } from "@sveltejs/kit";
import * as model from "./model.server";
export const POST = async ({ request }) => {
const body = await request.json();
switch (body.action) {
case "add":
return json(await model.addToCart(body.productId, body.quantity ?? 1));
case "remove":
return json(await model.removeFromCart(body.itemId));
default:
return json({ error: "Unknown action" }, { status: 400 });
}
};

❌ Не сюда: расчёты и правила — они в модели.

Если есть rpc-клиент/генератор из swagger, то этот слой не нужен. Представляет собой тонкую обёртку над fetch: знает эндпоинты своего модуля, типов не теряет:

routes/cart/client.ts
import type { Cart } from "./types";
export async function removeFromCart(itemId: string): Promise<Cart> {
const res = await fetch("/cart", {
method: "POST",
body: JSON.stringify({ action: "remove", itemId }),
});
return res.json();
}

❌ Не сюда: обработка ошибок интерфейса и состояние — это контроллер.

controller.svelte.ts, stores.ts, state.ts — реактивное состояние

Заголовок раздела «controller.svelte.ts, stores.ts, state.ts — реактивное состояние»

Три имени — одна роль: хранить состояние модуля и реагировать на действия пользователя. Выбор имени зависит от фреймворка и версии: controller.svelte.ts для рун Svelte 5, stores.ts для классических сторов, composables.ts в Nuxt.

// routes/cart/stores.ts — Svelte 4 stores
import { writable, derived } from "svelte/store";
import * as client from "./client";
import type { Cart } from "./types";
export const cart = writable<Cart>({ items: [], total: 0, totalCount: 0 });
export const isEmpty = derived(cart, ($cart) => $cart.items.length === 0);
export async function removeItem(itemId: string) {
const updated = await client.removeFromCart(itemId);
cart.set(updated);
}

Контроллер дёргает client.ts, обновляет сторы — разметка перерисовывается сама. ❌ Не сюда: импорты repo и серверной модели — иначе секреты окажутся в браузере.

load отдаёт начальные данные при заходе на URL, actions принимает отправку форм. Файл создавайте, даже если он пустой, — точка входа для серверных данных пригодится:

routes/cart/+page.server.ts
import { getCart } from "./model.server";
export const load = async () => {
const cart = await getCart();
return { cart };
};

❌ Не сюда: разметка и обработчики кликов.

Только отображение. Данные — из data (их подготовил load), состояние — из контроллера, никаких fetch и расчётов:

routes/cart/+page.svelte
<script lang="ts">
import { cart, removeItem, formatPrice } from "./stores";
let { data } = $props();
$effect(() => {
cart.set(data.cart);
});
</script>
{#each $cart.items as item (item.id)}
<div class="cart-item">
<div class="name">{item.name}</div>
<div class="subtotal">{formatPrice(item.price * item.quantity)}</div>
<button onclick={() => removeItem(item.id)}>×</button>
</div>
{/each}

❌ Не сюда: fetch в разметке, import модели, бизнес-расчёты.

Внутренние компоненты домена: CartWidget, CartItemCard, CheckoutForm. Используются только внутри своего модуля. Компонент понадобился соседям — переезжает в lib/ui/.

Только type и interface:

routes/cart/types.ts
export interface CartItem {
id: string;
productId: string;
name: string;
price: number;
image: string;
quantity: number;
}
export interface Cart {
items: CartItem[];
total: number;
totalCount: number;
}

❌ Не сюда: константы и функции — только типы.

routes/cart/constants.ts
export const MAX_ITEM_QUANTITY = 99;
export const MIN_ITEM_QUANTITY = 1;

❌ Не сюда: функции и вычисления.

routes/cart/policy.ts
export const canEditCart = (userId: string, cartOwnerId: string) =>
userId === cartOwnerId;

❌ Не сюда: проверка «кто такой пользователь» — только решения «можно или нельзя».

Схемы валидации и начальные состояния форм (zod, formik):

routes/cart/templates.ts
export const addToCartSchema = z.object({
productId: z.string(),
quantity: z.number().min(1).max(99),
});

❌ Не сюда: обработка отправки — она в actions страницы.

Функции, не попавшие в другие контракты: формат цены, генерация id:

routes/cart/utils.ts
export const formatPrice = (value: number) =>
new Intl.NumberFormat("ru-RU", { style: "currency", currency: "RUB" }).format(value);

❌ Не сюда: хелперы для всего проекта — им место в lib/.

Реэкспортирует внешний контракт модуля. Потребители импортируют из папки, а не из внутренних файлов:

routes/cart/index.ts
export * from "./rpc";
export * from "./controller";
export * from "./types";
export * from "./constants";

❌ Не сюда: реэкспорт model.server.ts — серверный код не должен утекать через публичный API.

Имена файлов роутинга зависят от мета-фреймворка, роли — нет:

КонцепцияSvelteKitNext.jsNuxt
Страница+page.sveltepage.tsxindex.vue
Layout+layout.sveltelayout.tsxlayout.vue
Server load+page.server.tspage.server.tsxsetup.ts
Server actions+page.server.tsactions.tsserver/
API-роуты+server.tsroute.ts/server/api/
RPCrpc.tsrpc.tsrpc.ts
Modelmodel.server.tsmodel.tsmodel.ts

RPC и модель называются одинаково везде — это ядро переносимости FDA.

→ FAQ и чеклист — проверка модуля перед мержем

→ Скилл для AI-агентов — те же контракты в виде инструкций для агента