Most full-stack boilerplates hand you a route handler and let you fill it in. The database call, the permission check, the email send, the response shape — all in one function, right where the HTTP request arrives. It works beautifully for about three months.
Silverbullet makes a different trade. Every feature is split across two packages, and the split is enforced rather than suggested.
The split
A feature service package — packages/server/{feature}/ — holds the business logic. Schemas live in a schemas/ directory, and the actual work happens in {feature}.service.ts. This package knows nothing about HTTP, tRPC, or who called it.
A feature API package — packages/server/{feature}-api/ — holds a tRPC router in router.ts and nothing else. It validates input, calls a service function, and returns the result.
The API gateway combines every router into one appRouter and exports the AppRouter, RouterInputs, and RouterOutputs types the client consumes. It imports only from *-api packages — never from a service package directly.
Why bother
The honest answer is that the transport layer is not where your logic belongs, because it is not the only caller.
Silverbullet ships four backend surfaces: a Hono API, a BullMQ queue worker, a node-cron scheduler, and a mobile app. When "send a notification" lives inside a tRPC procedure, the queue worker cannot reuse it. You either import a router into a worker — dragging request context somewhere no request exists — or you copy the logic. Both choices are ones you make once and regret for the lifetime of the project.
When the logic is a plain exported function, the queue worker imports it and calls it. So does the cron app. So does the router. There is one implementation of the rule, and one place to fix it.
The second payoff is testing. A service function is a function: give it arguments, assert on the result. No HTTP layer to stand up, no request context to fabricate. That is why unit tests in Silverbullet live beside the service code in packages/server/* and packages/shared/* rather than exercising everything through the API.
The constraint that makes it hold
A layered architecture decays the moment someone takes a shortcut, so a few rules are absolute.
Never re-export across packages. A package's own index.ts may re-export its internal modules. Re-exporting another package's types "for convenience" is forbidden. It reads as harmless and it is how a clean dependency graph quietly becomes a web where nothing can move.
Never import infrastructure instances. The database, Redis, the logger, config, S3, auth, cache, and queues are always resolved from the dependency injection container using type-safe constants:
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
}
The container imports nothing from service packages — a rule you can verify with a single grep. That is what keeps a cycle from forming at the root of the graph, and what lets a test swap the database for a fake without touching the code under test.
Throw typed exceptions, not strings. Service code throws custom exceptions carrying an ErrorCodes enum value, and lets them propagate to the global tRPC handler:
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 };
}
No try-catch wrapping your own exceptions to re-throw them. No filtering on error.message.includes(...) — a check that compiles fine and breaks the first time someone rewords a message. The client receives a stable code it can branch on and translate, rather than an English sentence it has to pattern-match.
What it costs
Two packages per feature is more scaffolding than one file. Adding a feature means copying a boilerplate, wiring a router into the gateway, and touching more files than a monolithic handler would.
That cost is real, and it is front-loaded — paid once per feature, at the moment when you have the most context and the least pressure. The cost it replaces is the one you pay later: the day you need the same logic in a background job, or the day a bug in a permission check turns out to live in four handlers with three slightly different implementations.
Boilerplates are judged on how fast you can start. The better question is how the codebase reads after a year, when the person opening it is you having forgotten everything, or someone you hired last week. Structure that seems fussy on day one is usually just the shape of a decision that was expensive to reverse.