Backend Overview
The backend lives in apps/backend/ and runs a Hono API server on Node.js. It uses Prisma for schema management, Kysely
for typed queries, PostgreSQL for storage, and Redis (Valkey) for caching.
Folder structure
apps/backend/src/
cache/ # Valkey (Redis) client and initialization
config/ # Environment config, app constants
db/ # Database client (Postgres + Kysely)
features/ # Domain-organized business logic
jobs/ # Registered cron jobs (empty until a pack adds one)
libs/ # Shared backend libraries: email, logger, scheduler, sentry, storage, websocket
middleware/ # Error handling, validation, auth, file uploads
paraglide/ # Compiled backend message catalog (emails, notifications)
routes/ # HTTP route definitions (thin layer)
sockets/ # WebSocket event definitions
testing/ # Test utilities, mocks, setup
utils/ # Shared helpersBootstrap order
At process start the server roughly does this:
Startup
Validate env
Zod parses process.env. Missing keys stop the process before ports open.
Attach context
Postgres, Valkey, storage, and other clients land on the Hono app context.
Register sockets and routes
WebSocket routers and HTTP routers mount under their prefixes.
Global middleware
CORS and the centralized error middleware wrap every request.
Start the scheduler
Once the database is reachable, every job in src/jobs runs on its cron schedule.
Request lifecycle
Authenticated HTTP request
Global middleware
CORS runs, then the error middleware wraps the rest of the chain.
Route match
Hono picks the feature router mounted from registerRoutes.
Auth and permissions
getAuthenticatedApp() runs withAuthUser. Routes add hasAccess({ action, subject }) when needed.
After a starter packValidation
The validate middleware parses json or query input with Zod and types the handler.
Module then db
The thin route calls a feature module. The module calls queries or mutations. No business logic in the route file.
JSON response or typed error
Success returns ctx.json(...). Failures throw ApplicationError / AuthError / ValidationError and the error middleware formats the response (and reports unexpected errors to Sentry when configured).
Layer responsibilities
| Layer | Responsibility | Must not do |
|---|---|---|
main / app bootstrap | env, context, mount routers, global middleware | Own feature rules |
| Global middleware | CORS, centralized errors | Feature-specific DB writes |
| Route middleware | Auth, validate(), hasAccess(), uploads | Orchestrate multi-step business flows |
| Route handler | Read context, call one module, return JSON | Raw SQL or provider SDK sprawl |
| Feature module | Business rules and orchestration | Know HTTP status codes |
db/queries / db/mutations | Persistence | Session / request semantics beyond args |
Key patterns
-
Features own business logic. Each domain (projects, notifications, auth) has its own folder in
src/features/with modules, database queries, mutations, and schemas. See How to organize features. -
Routes are thin. Route files validate input, read context, call a module function, and return a response. No business logic in routes. See Routing.
-
No try/catch. A centralized error middleware handles all errors. Throw typed errors (
ApplicationError,AuthError,ValidationError) and let the middleware do its job. Swallowing errors also skips Sentry. -
Validation goes through Zod. Every route that accepts input uses the
validate()middleware with a Zod schema. This powers both runtime validation and the end-to-end type bridge to the frontend SDK.
Scheduled jobs
The backend runs its own cron scheduler (croner) in process, so there is no worker to deploy. Jobs are declared in
src/jobs/index.ts and start with the server:
import type { JobDefinition } from "../libs/scheduler/scheduler";
export const jobs: JobDefinition[] = [{ name: "newsletter-dispatch", cron: "* * * * *", run: dispatchDueReleases }];Each fire takes a Postgres advisory lock, so a job runs once across every instance of the API. Jobs are stateless
sweepers: they look at your tables for work that is due and do it, and a crashed run is simply picked up on the next
tick. A job holds a database transaction while it runs, so keep it fast. If it finds slow work, it enqueues
tasks and returns. The list ships empty. Feature packs append entries through the jobs field of their
manifest, and your own jobs go in the same array.
Task queue
Cron answers "when". The task queue answers "how much at once". Anything slow (parsing an upload, calling a model, sending a batch) runs as a task, so it can retry on its own and never holds up a request or a cron tick.
Tasks live in Postgres (pg-boss, in its own pgboss schema), so there is still nothing extra to deploy. Queues are
declared with defineQueue and registered in src/queues/index.ts:
export const renderReportQueue = defineQueue({
name: "render-report",
schema: z.object({ reportId: z.string() }),
concurrency: 2,
exclusive: true,
run: async ({ data, signal }) => renderReport(data.reportId, signal),
onExhausted: async ({ data }) => markReportFailed(data.reportId),
});Enqueue inside the transaction that writes the row. The task commits or rolls back with it, so a row can never sit in
queued with nothing behind it:
await postgres.qb.transaction().execute(async (trx) => {
await trx.insertInto("report").values(report).execute();
await enqueue(renderReportQueue, { reportId: report.id }, { trx, key: report.id });
});A failed run is retried with backoff (3 retries by default), then onExhausted fires once so you can mark your own row
as failed. Handlers can run twice after a crash or a deploy, so checkpoint progress in your tables and resume from it.
Keep a status column the UI reads; the queue is plumbing. The list ships empty. Feature packs append entries through the
queues field of their manifest.
Where tasks run
Inside the API process, next to the scheduler. There is no separate worker to deploy. Every backend instance works
tasks, concurrency at a time per queue, so adding a replica adds capacity. The queue is free until used: with nothing
registered in src/queues/index.ts it creates no schema and polls nothing. When it is on, it runs on the backend's own
database connections, so there is no second pool.
A dedicated worker is not something the template ships. If a project ever needs one, the seam is already there:
main.ts starts the queue with startQueues({ queues, postgres }), so a second entry file can make the same call after
setupContext(), with workers: false passed in main.ts. Tasks live in Postgres, so the two processes never need to
talk to each other.
HyperFetch SDK generation
registerRoutes returns a typed Hono app. The frontend HyperFetch SDK is derived from that type, so a new route appears
on sdk.* without a separate codegen step. Details: end-to-end type safety and
HTTP.
Database access
- Prisma for schema definition, migrations, and seeding
- Kysely for typed SQL queries (reads and complex writes)
- Prisma for inserts, Kysely for everything else
Reads go in db/queries/, writes go in db/mutations/. See Prisma and Kysely and
Migrations.