Почему Silverbullet отделяет бизнес-логику от транспорта

TypeScript

Большинство full-stack-шаблонов дают вам обработчик маршрута и предлагают его заполнить. Запрос к базе, проверка прав, отправка письма, форма ответа — всё в одной функции, прямо там, куда приходит HTTP-запрос. Это прекрасно работает примерно три месяца.

Silverbullet выбирает другой компромисс. Каждая фича разделена на два пакета, и это разделение не рекомендация, а правило.

Разделение

Сервисный пакет фичи — packages/server/{feature}/ — содержит бизнес-логику. Схемы лежат в каталоге schemas/, а сама работа происходит в {feature}.service.ts. Этот пакет ничего не знает об HTTP, tRPC и о том, кто его вызвал.

API-пакет фичи — packages/server/{feature}-api/ — содержит tRPC-роутер в router.ts, и больше ничего. Он проверяет входные данные, вызывает сервисную функцию и возвращает результат.

API-шлюз собирает все роутеры в один appRouter и экспортирует типы AppRouter, RouterInputs и RouterOutputs, которыми пользуется клиент. Он импортирует только из пакетов *-api — никогда напрямую из сервисного пакета.

Зачем это нужно

Честный ответ: транспортному слою не место для логики, потому что он не единственный, кто её вызывает.

В Silverbullet четыре бэкенд-поверхности: API на Hono, воркер очередей на BullMQ, планировщик на node-cron и мобильное приложение. Когда «отправить уведомление» живёт внутри tRPC-процедуры, воркер очередей не может его переиспользовать. Остаётся либо импортировать роутер в воркер — и тащить контекст запроса туда, где запроса нет, — либо копировать логику. И то и другое решается один раз, а жалеть о нём приходится всю жизнь проекта.

Когда логика — обычная экспортируемая функция, воркер очередей просто импортирует её и вызывает. Так же делает cron-приложение. Так же делает роутер. У правила одна реализация и одно место, где его исправлять.

Второй выигрыш — тестирование. Сервисная функция — это функция: передайте аргументы и проверьте результат. Не нужно поднимать HTTP-слой и выдумывать контекст запроса. Поэтому юнит-тесты в Silverbullet лежат рядом с сервисным кодом в packages/server/* и packages/shared/*, а не гоняют всё через API.

Ограничения, на которых всё держится

Слоистая архитектура разваливается, как только кто-то срезает путь, поэтому несколько правил абсолютны.

Никаких реэкспортов между пакетами. Собственный index.ts пакета может реэкспортировать его внутренние модули. Реэкспортировать типы другого пакета «для удобства» запрещено. Это выглядит безобидно, но именно так чистый граф зависимостей тихо превращается в паутину, где ничего нельзя сдвинуть.

Никаких прямых импортов инфраструктуры. База данных, Redis, логгер, конфиг, S3, аутентификация, кеш и очереди всегда берутся из DI-контейнера через типобезопасные константы:

TypeScript
import { getContainer, ServiceName } from '@repo/container';
import type { DbInstance } from '@repo/drizzle';

const getDb = () => getContainer().get<DbInstance>(ServiceName.DB);

export async function myService() {
  const db = getDb();
  // ... service logic
}

Контейнер ничего не импортирует из сервисных пакетов — это правило проверяется одним grep. Именно это не даёт циклу возникнуть в корне графа и позволяет тесту подменить базу данных фейком, не трогая тестируемый код.

Бросайте типизированные исключения, а не строки. Сервисный код бросает собственные исключения со значением из перечисления ErrorCodes и даёт им дойти до глобального обработчика tRPC:

TypeScript
export async function deletePost(id: string, userId: string) {
  const post = await db.query.Post.findFirst({ where: eq(Post.id, id) });
  if (!post) throw NotFoundException(ErrorCodes.POST_NOT_FOUND);
  if (post.userId !== userId) throw ForbiddenException(ErrorCodes.FORBIDDEN);
  await db.delete(Post).where(eq(Post.id, id));
  return { success: true };
}

Никаких try-catch, которые ловят ваши же исключения, чтобы бросить их снова. Никакой фильтрации по error.message.includes(...) — такая проверка отлично компилируется и ломается, как только кто-то перефразирует сообщение. Клиент получает стабильный код, по которому можно ветвиться и который можно перевести, а не английскую фразу, которую приходится разбирать по шаблону.

Чего это стоит

Два пакета на фичу — это больше обвязки, чем один файл. Чтобы добавить фичу, нужно скопировать заготовку, подключить роутер к шлюзу и затронуть больше файлов, чем потребовал бы монолитный обработчик.

Эта цена реальна, и она платится заранее — один раз на фичу, в момент, когда у вас больше всего контекста и меньше всего спешки. Она заменяет цену, которую пришлось бы платить потом: в тот день, когда та же логика понадобится в фоновой задаче, или в тот день, когда ошибка в проверке прав окажется в четырёх обработчиках с тремя немного разными реализациями.

Шаблоны оценивают по тому, как быстро с ними можно начать. Вопрос важнее — как код читается через год, когда его открывает человек, всё забывший, то есть вы сами, или тот, кого вы наняли на прошлой неделе. Структура, которая в первый день кажется придирчивой, обычно просто форма решения, которое дорого отменять.