Architektur

So ist das Monorepo aufgebaut

Aktualisiert am 1 Aufruf

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

apps/client

TanStack Start (React)

Cloudflare Workers

apps/api

Hono REST mit tRPC

Docker-Container auf Coolify

apps/queue

BullMQ-Hintergrund-Worker

Docker-Container auf Coolify

apps/cron

node-cron-Scheduler

Docker-Container auf Coolify

apps/mobile

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.

TypeScript
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.

War dieser Artikel hilfreich?