Silverbullet ist ein Monorepo aus Turbo und pnpm-Workspaces. Sobald du die Schichten kennst, findest du die richtige Datei für eine Änderung ganz mechanisch.
Die Apps
App | Stack | Läuft auf |
|---|---|---|
| TanStack Start (React) | Cloudflare Workers |
| Hono REST mit tRPC | Docker-Container auf Coolify |
| BullMQ-Hintergrund-Worker | Docker-Container auf Coolify |
| node-cron-Scheduler | Docker-Container auf Coolify |
| Expo / React Native | App Stores |
Nur das Frontend läuft auf Cloudflare. Jeder Backend-Prozess ist ein Container auf einem dedizierten Server, und Hintergrundjobs laufen mit BullMQ über Redis – Cloudflare Queues kommen nicht vor.
Die Paketschichten
Alles unter packages/ steht im Workspace als @repo/* bereit, und der serverseitige Code ist bewusst in drei Schichten aufgeteilt.
Feature-Service-Pakete – packages/server/{feature}/ – enthalten die gesamte Geschäftslogik: Schemas in einem schemas/-Verzeichnis, Typen, Konstanten und die Service-Funktionen in {feature}.service.ts.
Feature-API-Pakete – packages/server/{feature}-api/ – enthalten nichts als einen tRPC-Router in router.ts. Sie sind eine dünne Transportschicht, die das passende Service-Paket aufruft.
Das API-Gateway – packages/server/api-gateway/ – fasst alle Feature-Router zu einem appRouter zusammen und exportiert die Typen AppRouter, RouterInputs und RouterOutputs. Es importiert nur aus *-api-Paketen, nie direkt aus einem Service-Paket.
Der Gewinn: Die Geschäftslogik hängt nie von tRPC ab, und die Transportschicht bekommt nie eigene Logik.
Nie paketübergreifend re-exportieren
Die eigene index.ts eines Pakets darf dessen interne Module re-exportieren. Typen oder Funktionen eines anderen Pakets „der Bequemlichkeit halber“ zu re-exportieren, ist nicht erlaubt – importiere immer aus dem Quellpaket. Bequemlichkeits-Re-Exports machen aus einem sauberen Abhängigkeitsgraphen unbemerkt ein Geflecht, in dem sich nichts mehr verschieben lässt.
Services aus dem Container holen
Kerninfrastruktur – die Datenbank, Redis, der Logger, die Konfiguration, S3, Auth, Cache und Queues – wird nie als Instanz importiert. Sie wird über typsichere Konstanten aus dem Dependency-Injection-Container geholt; genau das hält Services testbar und austauschbar.
import { getContainer, ServiceName } from '@repo/container';
import { QueueEnum } from '@repo/queue-events';
import type { DbInstance } from '@repo/drizzle';
import type { Logger } from '@repo/logger';
import type { QueueAdapter } from '@repo/shared-utils';
const getDb = () => getContainer().get<DbInstance>(ServiceName.DB);
const getLogger = () => getContainer().get<Logger>(ServiceName.LOGGER);
const getEventBusQueue = () => getContainer().get<QueueAdapter>(QueueEnum.EVENT_BUS);
export async function myService() {
const db = getDb();
const logger = getLogger();
// ... service logic
}
Definiere diese Getter am Anfang einer Service-Datei und rufe sie in deinen Funktionen auf. Der Container selbst importiert nichts aus Service-Paketen – das verhindert eine zirkuläre Abhängigkeit an der Wurzel des Graphen.
Wohin Abhängigkeiten gehören
Externe npm-Pakete werden im Root mit pnpm i <name> -w installiert und indirekt über ein @repo/*-Paket genutzt. Die package.json einer App oder eines Feature-Pakets listet nur @repo/*-Workspace-Abhängigkeiten.
Es gibt eine dokumentierte Ausnahme: apps/mobile listet seine nativen Expo- und React-Native-Abhängigkeiten direkt, weil Metro versionsgebundene, app-lokale native Pakete braucht, die sich nicht hoisten lassen.
Etwas Neues anfangen
Wenn du ein Paket hinzufügst, kopiere eine der Boilerplates, statt die Konfiguration von Hand zusammenzubauen – /boilerplates/server-lib-example/ für ein Server-Paket, /boilerplates/shared-lib-example/ für ein gemeinsames. Ein bestehendes Paket wie packages/server/user/ anzusehen, bevor du etwas schreibst, geht meist schneller, als die Regeln zweimal zu lesen.