Hype StackHypeStack

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 helpers

Bootstrap order

At process start the server roughly does this:

Startup

  1. Validate env

    Zod parses process.env. Missing keys stop the process before ports open.

  2. Attach context

    Postgres, Valkey, storage, and other clients land on the Hono app context.

  3. Register sockets and routes

    WebSocket routers and HTTP routers mount under their prefixes.

  4. Global middleware

    CORS and the centralized error middleware wrap every request.

  5. Start the scheduler

    Once the database is reachable, every job in src/jobs runs on its cron schedule.

Request lifecycle

Authenticated HTTP request

  1. Global middleware

    CORS runs, then the error middleware wraps the rest of the chain.

  2. Route match

    Hono picks the feature router mounted from registerRoutes.

  3. Auth and permissions

    getAuthenticatedApp() runs withAuthUser. Routes add hasAccess({ action, subject }) when needed.

    After a starter pack
  4. Validation

    The validate middleware parses json or query input with Zod and types the handler.

  5. Module then db

    The thin route calls a feature module. The module calls queries or mutations. No business logic in the route file.

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

LayerResponsibilityMust not do
main / app bootstrapenv, context, mount routers, global middlewareOwn feature rules
Global middlewareCORS, centralized errorsFeature-specific DB writes
Route middlewareAuth, validate(), hasAccess(), uploadsOrchestrate multi-step business flows
Route handlerRead context, call one module, return JSONRaw SQL or provider SDK sprawl
Feature moduleBusiness rules and orchestrationKnow HTTP status codes
db/queries / db/mutationsPersistenceSession / 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:

ts
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:

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:

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