Architecture

How the monorepo is organized

Updated August 22nd, 202617 views

Silverbullet is a Turbo and pnpm workspaces monorepo. Once you know the layers, finding the right file for a change becomes mechanical.

The apps

App

Stack

Runs on

apps/client

TanStack Start (React)

Cloudflare Workers

apps/api

Hono REST with tRPC

Docker container on Coolify

apps/queue

BullMQ background worker

Docker container on Coolify

apps/cron

node-cron scheduler

Docker container on Coolify

apps/mobile

Expo / React Native

App stores

Only the frontend runs on Cloudflare. Every backend process is a container on a dedicated server, and background jobs run on BullMQ over Redis — there are no Cloudflare Queues in the picture.

The package layers

Everything under packages/ is published to the workspace as @repo/*, and server-side code is split into three deliberate layers.

Feature service packagespackages/server/{feature}/ — hold all the business logic: schemas in a schemas/ directory, types, constants, and the service functions in {feature}.service.ts.

Feature API packagespackages/server/{feature}-api/ — hold nothing but a tRPC router in router.ts. They are a thin transport layer that calls into the matching service package.

The API gatewaypackages/server/api-gateway/ — combines every feature router into one appRouter and exports the AppRouter, RouterInputs, and RouterOutputs types. It imports only from *-api packages, never from a service package directly.

The payoff is that business logic never depends on tRPC, and the transport layer never grows logic of its own.

Never re-export across packages

A package's own index.ts may re-export its internal modules. Re-exporting another package's types or functions "for convenience" is not allowed — always import from the source package. Convenience re-exports quietly turn a clean dependency graph into a web where nothing can be moved.

Resolve services from the container

Core infrastructure — the database, Redis, the logger, config, S3, auth, cache, and queues — is never imported as an instance. It is resolved from the dependency injection container using type-safe constants, which is what keeps services testable and swappable.

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
}

Define these getters at the top of a service file and call them inside your functions. The container itself imports nothing from service packages, which is what prevents a circular dependency at the root of the graph.

Where dependencies go

External npm packages are installed at the root with pnpm i <name> -w and reached indirectly through an @repo/* package. The package.json of an app or feature package lists only @repo/* workspace dependencies.

There is one documented exception: apps/mobile lists its native Expo and React Native dependencies directly, because Metro requires version-locked, app-local native packages that cannot be hoisted.

Starting something new

When you add a package, copy one of the boilerplates rather than assembling config by hand — /boilerplates/server-lib-example/ for a server package, /boilerplates/shared-lib-example/ for a shared one. Studying an existing package such as packages/server/user/ before you write anything is usually faster than reading the rules twice.