Palbase
Sign inGet started

Backend SDK

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

ConceptWhat it is
ModuleA 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.
ControllerA 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.
ServiceAn @Injectable() class in modules/<domain>/*.service.ts, listed in that module's providers. The real business logic — controllers stay thin.
Dependency injectionA 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 singletonsTen importable globals (Database, Storage, …) that the runtime resolves per request. No client construction, no connection strings.
Response schemaThe 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.
ErrorsThrow 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 isHow it is found
@Controller classesnamed in a module's controllers list
@Injectable() services, and the abstractions they stand behindnamed in a module's providers list
@Job, @Webhook, @Hook and @Room classesnamed in the same module's providers list
db/*.tsby 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 depthby 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: defineMiddleware is still exported, and calling it throws. There is no middleware pipeline in this runtime and nothing reads a middleware/ 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. The Resource base 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, defineTestUsersno 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.

SingletonPurposeDocs
DatabaseTyped Postgres — Database.public.todos.insert(...), transactions, raw SQLDatabase
DocumentsDocument store with collections and queriesDocuments
StorageBuckets, uploads, downloads, signed URLs, variants — Storage.bucket("avatars")Storage
CacheJSON key-value state with TTLs and single-flight getOrSetCache
SecretsSecrets.get(name) — the Environment's vault, null when unsetSecrets
LogStructured logging — Log.info/warn/error/debugsee Logging below
NotificationsPush, email, SMS and in-app inboxNotifications
FlagsRead feature flags, write per-user overridesFlags
RealtimeRealtime.broadcast(...) — broadcast only; subscribing is a client SDK jobRealtime
AuthRoles only — assignRole, revokeRole, rolesOf. Signing in and sessions are client SDK workAuthentication

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() (and onShutdown() for the other end) on a class a module lists in providers, 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 inside onStart() — read a secret from process.env there, 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 with palbase secret set, and read them with Secrets.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. experimentalDecorators is what compiles the route decorators at all — they are legacy parameter decorators. emitDecoratorMetadata is what makes injection work: the container reads a constructor's parameter TYPES out of design:paramtypes, which only legacy decorators emit, so without it every injected field arrives undefined. The build says so by name rather than letting that ship. The scaffold's tsconfig.json sets 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.

  1. A class exists because a module lists it. @Controller classes go in controllers; @Injectable() services, @Job, @Webhook, @Hook and @Room go in providers. A class listed nowhere is refused at build, by name. Export every one of them by name — the module takes them with import { X } from "./x", so a default export does not compile against its own module. db/*.ts is the one file that requires export default, one defineSchema per file.
  2. Import z from @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, with node_modules present, so an import your project has installed is one the deploy resolves.
  3. 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 inline Promise<{ id: string }> or a bare interface is rejected by the deploy. Use : Promise<void> for no body — a handler returning undefined or null answers 204.
  4. Methods that call a service are async and return Promise<T>. Services await Database, so annotate : Promise<TodoSchema>, not : TodoSchema.
  5. @User() is a value, UserT is a type. import { User } from "@palbase/backend" (decorator) plus import type { UserT } from "@palbase/backend" (type). Write @User() user: UserT.
  6. 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 discovers dto/; it is an ordinary import path.
  7. Errors are throw classes. throw new Conflict("…") from a controller or a service — no request object needed. See Responses & Errors.
  8. Auth is secure by default. Every route requires a signed-in user unless something in the cascade says false. See Authentication.
  9. The backend has no ambient network. fetch() to a host you have not allowed fails. Declare hosts with palbase egress add — see Outbound Network.
  10. experimentalDecorators: true and emitDecoratorMetadata: true in tsconfig.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

TopicPage
@Module's four lists, injection by type, and the abstract class seamModules & Dependency Injection
Routing, method decorators, reserved prefixes, rate limitsControllers & Routing
@Body/@QueryParams/@Param/@Headers and the 400 shapesRequest Validation
The auth cascade, @User, rolesAuthentication
Response schemas, error classes, defineErrorResponses & Errors
Typed queries and transaction plansDatabase
Schema as codeSchema · Row-Level Security · Schema changes
Documents, files, direct uploadsDocuments · Storage · Direct Uploads
Caching and secretsCache · Secrets
Background and inbound workScheduled Jobs · Webhooks · Event Hooks
Reaching the internetOutbound Network
Flags, notifications, realtimeFlags · Notifications · Realtime
Settings that are not in gitStack Settings
Fixtures and suitesTest Users · Testing