Overview
@palbase/backend is the framework you write a Palbase backend in. You author plain TypeScript — class controllers, zod schemas, service classes and a db/public.ts — group each domain under modules/<domain>/, and run palbase push; the CLI bundles the directory with Bun and posts it to the Environment's own management API, and that one request applies the schema and activates the code together. What comes up answers as a typed HTTP API at https://<ref>.palbase.studio. There is no server file, no app.get(...), no Dockerfile and no port to bind — one @Module declaration per domain is the whole contract between your source and the runtime. See Modules & Dependency Injection.
Quick example
// modules/todos/dto/create.ts
import { z } from "@palbase/backend";
export const CreateTodoBody = z.object({ title: z.string().min(1) });
export type CreateTodoBody = z.infer<typeof CreateTodoBody>;
export const TodoSchema = z.object({
id: z.string(),
title: z.string(),
done: z.boolean(),
});
export type TodoSchema = z.infer<typeof TodoSchema>;
// modules/todos/todos.service.ts
// The decisions live here: which rows, whose, in what order.
import { Database, Injectable, NotFound } from "@palbase/backend";
import type { TodoSchema } from "./dto/create";
@Injectable()
export class TodoService {
list(userId: string): Promise<TodoSchema[]> {
return Database.public.todos.findMany({ where: { user_id: userId } });
}
create(userId: string, title: string): Promise<TodoSchema> {
return Database.public.todos.insert({ title, user_id: userId });
}
async get(id: string): Promise<TodoSchema> {
const todo = await Database.public.todos.findById(id);
if (!todo) throw new NotFound("No todo with that id");
return todo;
}
}
// modules/todos/todos.controller.ts
// HTTP only; the service arrives by constructor.
import { Body, Controller, Get, Param, Post, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";
import { CreateTodoBody, TodoSchema } from "./dto/create";
import { TodoService } from "./todos.service";
@Controller("/todos")
export class TodosController {
constructor(private readonly todos: TodoService) {}
@Get("")
list(@User() user: UserT): Promise<TodoSchema[]> {
return this.todos.list(user.id);
}
@Post("", { rateLimit: { max: 10, window: 60 } })
create(@Body(CreateTodoBody) body: CreateTodoBody, @User() user: UserT): Promise<TodoSchema> {
return this.todos.create(user.id, body.title);
}
@Get("/{id}")
get(@Param("id") id: string): Promise<TodoSchema> {
return this.todos.get(id);
}
}
// modules/todos/todos.module.ts
// The registration. Neither class exists without it.
import { Module } from "@palbase/backend";
import { TodosController } from "./todos.controller";
import { TodoService } from "./todos.service";
@Module({
controllers: [TodosController],
providers: [TodoService],
exports: [], // which of its own classes may ANOTHER module reach
imports: [], // whose exports may THIS module reach
})
export class TodosModule {}
Nothing else is wired. The controller never constructs TodoService; it names the type in its constructor and the container supplies it. Drop the module file and the deploy refuses both classes by name — see Modules & Dependency Injection.
Run palbase push and every client gets a typed call for each method — pb.todos.list(), pb.todos.create({ title }), pb.todos.get(id) — on web and iOS alike. The namespace is todos because the class is named TodosController, not because the base path is /todos.
The mental model
| Concept | What it is |
|---|---|
| Module | A class decorated with @Module({ controllers, providers, exports, imports }), one per domain folder. It is the only place ownership is decided: a class it does not list does not exist. There is no root module. |
| Controller | A class decorated with @Controller(basePath). Each method decorated with @Get/@Post/… is one HTTP endpoint. It exists because its module's controllers list names it. |
| Schema (dto) | A zod schema in the module's own dto/<endpoint>.ts, exported as a value and a same-named inferred type. Drives request validation and the generated client types. Nothing discovers dto/ — it is an ordinary import path. |
| Service | An @Injectable() class in modules/<domain>/*.service.ts, listed in that module's providers. The real business logic — controllers stay thin. |
| Dependency injection | A class names what it needs as constructor parameters, and the container supplies them by TYPE. No @Inject, no useFactory, no forwardRef, no token registry. An interface cannot be a dependency; use a class or an abstract class. |
| Service singletons | Ten importable globals (Database, Storage, …) that the runtime resolves per request. No client construction, no connection strings. |
| Response schema | The method's return type. Write : Promise<TodoSchema> and that named schema becomes the 200 response. There is no @Returns decorator, and an inline Promise<{ id: string }> is refused. |
| Errors | Throw classes (NotFound, Conflict, …) plus your own typed errors via defineError. Throw from a controller or a service; the runtime emits the standard envelope. |
What the deploy actually reads
A backend is a directory of TypeScript, and the deploy walks it for exactly one thing: files named *.module.ts, at any depth. It builds the container those modules describe, and that container is what the artifact carries.
| What it is | How it is found |
|---|---|
@Controller classes | named in a module's controllers list |
@Injectable() services, and the abstractions they stand behind | named in a module's providers list |
@Job, @Webhook, @Hook and @Room classes | named in the same module's providers list |
db/*.ts | by location — one file per schema, each export defaulting a defineSchema(...) |
*.e2e.test.{ts,js,mts,mjs}, at any depth — plus everything under tests/, also at any depth | by NAME; bundled one per suite and run against the release the push just built. Unit tests beside your code are not sent — see Testing |
A class no module lists does not exist. The build refuses it by name, and it never reaches the route table, the dispatcher or the OpenAPI document. Being in a particular folder grants nothing — the schema files are the only thing in the product whose path decides anything:
OrphanWebhook is decorated as an entry point but listed in no module, so nothing
decides whether it should be served.
→ add it to a module's `controllers` (for @Controller) or `providers`
(for @Room/@Job/@Hook/@Webhook)
There is no controllers/, services/, models/, jobs/, webhooks/ or hooks/ directory and nothing reads one. Those were directories the runtime read by name, which made the file system a second declaration: a class could carry @Job, sit outside jobs/, and never run — or sit inside it, be listed in no module, and run anyway. Now the module answers ownership and the decorator answers kind. See Modules & Dependency Injection.
Warning:
defineMiddlewareis still exported, and calling it throws. There is no middleware pipeline in this runtime and nothing reads amiddleware/directory, so a handler defined that way used to deploy and then never run with nothing reporting it; the call now refuses and names where the work belongs. Put cross-cutting work in a service the controllers call, and use route options for auth and rate limits. TheResourcebase class is gone outright — its boot registry was never wired.
config/*.ts is not read either, and the DSLs that used to fill it — defineStorage, defineFlags, defineSecrets, defineNotifications, defineEgress, defineTestUsers — no longer exist in the package at all. Settings live on the stack and are written by their own commands: palbase secret set, palbase egress add, palbase test-user templates set. See Stack Settings.
The ten service singletons
Import any of these from @palbase/backend and call them inside a request. Each one is a proxy that resolves the live client per access out of an AsyncLocalStorage box the runtime opens for the request, which is why there is no ctx parameter to thread and no client to construct.
| Singleton | Purpose | Docs |
|---|---|---|
Database | Typed Postgres — Database.public.todos.insert(...), transactions, raw SQL | Database |
Documents | Document store with collections and queries | Documents |
Storage | Buckets, uploads, downloads, signed URLs, variants — Storage.bucket("avatars") | Storage |
Cache | JSON key-value state with TTLs and single-flight getOrSet | Cache |
Secrets | Secrets.get(name) — the Environment's vault, null when unset | Secrets |
Log | Structured logging — Log.info/warn/error/debug | see Logging below |
Notifications | Push, email, SMS and in-app inbox | Notifications |
Flags | Read feature flags, write per-user overrides | Flags |
Realtime | Realtime.broadcast(...) — broadcast only; subscribing is a client SDK job | Realtime |
Auth | Roles only — assignRole, revokeRole, rolesOf. Signing in and sessions are client SDK work | Authentication |
There is no ctx object. ctx.db, ctx.auth, ctx.log, ctx.projectId, ctx.functions, ctx.links and ctx.analytics are not part of this SDK's shape. Per-request values come from parameter decorators instead: @User(), @RequestId(), @TraceId(), @Req(). Functions, CMS, Links and Analytics are not backend surfaces at all — they are client SDK concerns, and there is no singleton for any of them here. Auth is the one that looks like an exception and is not: it is a singleton, but only for granting and revoking roles, because assignment is server work while credentials never reach a backend handler.
Note: Singletons are request-scoped. Calling one outside a request — at module top level, or in a constructor the container runs at boot — throws
Palbase services accessed outside a request scope. That is also the rule about constructors: a constructor is pure wiring, synchronous and free of I/O, because the container builds every class once while the app comes up.Startup work is a method on a provider: declare
onStart()(andonShutdown()for the other end) on a class a module lists inproviders, and the container calls them in dependency order —onStart()awaited before the first request,onShutdown()in reverse construction order. A method that throws refuses the boot by its class's name rather than failing halfway through somebody's first call. The request-scoped singletons are not available insideonStart()— read a secret fromprocess.envthere, which the runtime mirrors the vault into at boot. See Modules & Dependency Injection.
Setup
palbase init scaffolds a project into an empty directory — AGENTS.md, CLAUDE.md, db/public.ts, modules/digest/digest.job.ts, modules/digest/digest.job.test.ts, modules/digest/digest.module.ts, modules/health/health.controller.ts, modules/health/health.module.ts, modules/notes/dto/create.ts, modules/notes/dto/list.ts, modules/notes/dto/note.ts, modules/notes/dto/update.ts, modules/notes/notes.controller.ts, modules/notes/notes.e2e.test.ts, modules/notes/notes.module.ts, modules/notes/notes.service.ts, modules/notes/notes.service.test.ts, package.json, test-users.json and tsconfig.json. That is two worked domains rather than a minimum — notes is the full vertical, and digest is a @Job that reaches it through module imports. See Architecture. It takes no arguments and no flags, and it creates nothing in the cloud; that is palbase project create, a separate step. The scaffold is not embedded in the binary: init asks npm which @palbase/backend is newest, installs it, and copies the template out of the installed package, so the scaffold and the SDK that compiles it are the same version by construction.
The generated package.json sets "type": "module" and declares the installed @palbase/backend version. Keep that generated dependency instead of copying a version number from a documentation example.
Commit package-lock.json with package.json so subsequent installs use the same tested dependencies.
Note: There is no
config/directory any more — it was retired in 23.0.0. Write secrets to the Environment's vault withpalbase secret set, and read them withSecrets.get(name).
Your tsconfig.json must enable legacy decorators and their metadata:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Note: Neither flag is optional, and they answer different questions.
experimentalDecoratorsis what compiles the route decorators at all — they are legacy parameter decorators.emitDecoratorMetadatais what makes injection work: the container reads a constructor's parameter TYPES out ofdesign:paramtypes, which only legacy decorators emit, so without it every injected field arrivesundefined. The build says so by name rather than letting that ship. The scaffold'stsconfig.jsonsets both.
TypeScript itself is your dependency, for your editor and your own tsc. Palbase never borrows it: the CLI and the deploy read your controllers with their own pinned TypeScript, so palbase build and palbase push behave identically whichever version you install, or none at all.
palbase build reports the @palbase/backend version it found but does not gate on it. The runtime vendors every SDK major inside a support window and builds each project against the major its lockfile resolved; the authoritative check is server-side. palbase build itself needs Node and npm, not Bun: it stages the tree the deploy would receive and validates it with the CLI's own pinned TypeScript and esbuild. Bun is what palbase push and palbase plan need — they build the artifact with bun build, because the stack runs Bun and the bundle should be built by the engine that will run it, and there a missing Bun refuses rather than falling back.
Project layout
modules/<domain>/<domain>.module.ts # @Module — controllers, providers, exports, imports
modules/<domain>/*.controller.ts # @Controller classes — listed in `controllers`
modules/<domain>/dto/<endpoint>.ts # zod schemas for this domain — nothing discovers dto/
modules/<domain>/*.service.ts # @Injectable classes — listed in `providers`
modules/<domain>/*.job.ts # cron (@Job, name REQUIRED) — also `providers`
modules/<domain>/*.webhook.ts # inbound webhooks (@Webhook, name REQUIRED) — also `providers`
modules/<domain>/*.hook.ts # internal event hooks (@Hook / @On) — also `providers`
db/public.ts # Postgres schema as code (tables, columns, RLS). export default.
modules/<domain>/*.e2e.test.ts # the deploy runs these against the candidate
modules/<domain>/*.test.ts # unit tests — the deploy never sends them
palbase/palbase-env.d.ts # GENERATED by palbase build. Commit it; never edit it.
tsconfig.json # experimentalDecorators + emitDecoratorMetadata: true
A new domain is a folder, and nothing central changes: there is no root module and nothing to mount one into. The health probe is modules/health/, a domain like any other. The file SUFFIX is a convention that helps a reader and decides nothing — what decides is the module list a class appears in and the decorator it carries.
palbase/palbase-env.d.ts is what makes Database.public.todos exist: palbase build derives it from db/*.ts and writes it inside palbase/, the one visible directory the CLI owns in your checkout. It is generated output and it is committed — edit the schema file, not the declaration.
See Project Structure for the full walkthrough.
The rules checklist
Get these right and your deploy succeeds; get them wrong and it fails the build gate or the TypeScript compile.
- A class exists because a module lists it.
@Controllerclasses go incontrollers;@Injectable()services,@Job,@Webhook,@Hookand@Roomgo inproviders. A class listed nowhere is refused at build, by name. Export every one of them by name — the module takes them withimport { X } from "./x", so a default export does not compile against its own module.db/*.tsis the one file that requiresexport default, onedefineSchemaper file. - Import
zfrom@palbase/backend, never from"zod". The package re-exports zod so that your schemas and the SDK that inspects them are the same zod instance. Other npm packages are fine — the push builds the artifact locally, withnode_modulespresent, so an import your project has installed is one the deploy resolves. - The return type is the response schema.
: Promise<TodoSchema>binds the 200 response, and it must be a named schema that exists as a value in scope. An inlinePromise<{ id: string }>or a bareinterfaceis rejected by the deploy. Use: Promise<void>for no body — a handler returningundefinedornullanswers204. - Methods that call a service are
asyncand returnPromise<T>. Services awaitDatabase, so annotate: Promise<TodoSchema>, not: TodoSchema. @User()is a value,UserTis a type.import { User } from "@palbase/backend"(decorator) plusimport type { UserT } from "@palbase/backend"(type). Write@User() user: UserT.- Schemas live in the module's own
dto/<endpoint>.ts, exported as a zod value and a same-named inferred type, so the controller reads@Body(CreateTodoBody) body: CreateTodoBody. Nothing discoversdto/; it is an ordinary import path. - Errors are throw classes.
throw new Conflict("…")from a controller or a service — no request object needed. See Responses & Errors. - Auth is secure by default. Every route requires a signed-in user unless something in the cascade says
false. See Authentication. - The backend has no ambient network.
fetch()to a host you have not allowed fails. Declare hosts withpalbase egress add— see Outbound Network. experimentalDecorators: trueandemitDecoratorMetadata: trueintsconfig.json— the second is what the container reads constructor parameter types from — and Bun installed on the machine that pushes.
Logging
Log is the structured logger; its output is what palbase logs and the Studio Logs page show.
// modules/todos/todos.log-example.ts
import { Injectable, Log } from "@palbase/backend";
@Injectable()
export class TodoAudit {
record(todoId: string, payload: unknown, err: unknown): void {
Log.info("todo created", { todoId });
Log.warn("rate approaching limit");
Log.error("import failed", err);
Log.debug("raw payload", payload);
}
}
Log is a request-scoped singleton like the others, so log from inside a handler, a service it calls, a job's run() or a hook — not from module top level.
Where to next
| Topic | Page |
|---|---|
@Module's four lists, injection by type, and the abstract class seam | Modules & Dependency Injection |
| Routing, method decorators, reserved prefixes, rate limits | Controllers & Routing |
@Body/@QueryParams/@Param/@Headers and the 400 shapes | Request Validation |
The auth cascade, @User, roles | Authentication |
Response schemas, error classes, defineError | Responses & Errors |
| Typed queries and transaction plans | Database |
| Schema as code | Schema · Row-Level Security · Schema changes |
| Documents, files, direct uploads | Documents · Storage · Direct Uploads |
| Caching and secrets | Cache · Secrets |
| Background and inbound work | Scheduled Jobs · Webhooks · Event Hooks |
| Reaching the internet | Outbound Network |
| Flags, notifications, realtime | Flags · Notifications · Realtime |
| Settings that are not in git | Stack Settings |
| Fixtures and suites | Test Users · Testing |
Related
- Modules & Dependency Injection — the four lists, and why a class no module names does not exist
- Controllers & Routing — the decorator set, and what never reaches your app
- Deploying —
palbase build,palbase plan,palbase push, and what travels - Linking a Checkout — how a directory learns which Environment it acts on
- Introduction — the Environment your backend runs inside
- Deploying — what the platform does with the artifact
- Quickstart — the same path, end to end