Palbase
Sign inGet started

Backend SDK

Controllers & Routing

Routes are declared in code, not in a router. A controller is a class decorated with @Controller(basePath); each route is a method decorated with @Get/@Post/@Put/@Patch/@Delete/@Query/@Upload/@Sse. The file lives with the domain that owns it — modules/todos/todos.controller.ts — and it is mounted because its module's controllers list names the class. There is no central router to register with, and no directory whose name mounts anything: a @Controller class no module lists is refused at build, by name. 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).max(200) });
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.controller.ts
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 "./todo.service";

@Controller("/todos")
export class TodosController {
  // The container supplies this. Nothing here constructs or imports an instance.
  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}", { auth: false })
  get(@Param("id") id: string): Promise<TodoSchema> {
    return this.todos.get(id);
  }
}
// modules/todos/todos.module.ts
import { Module } from "@palbase/backend";

import { DbTodoRepo, TodoService } from "./todo.service";
import { TodosController } from "./todos.controller";

// The lists are typed: every entry is a `Token`, which any class satisfies and
// no other value does. There is nothing to cast — a cast here would only
// suppress the check that makes the list worth having.
@Module({
  controllers: [TodosController],
  providers: [TodoService, DbTodoRepo],
  exports: [],
  imports: [],
})
export class TodosModule {}

That mounts three endpoints:

Method nameVerbFull pathGenerated client call
listGET/todospb.todos.list()
createPOST/todospb.todos.create({ title })
getGET/todos/{id}pb.todos.get(id)

@Controller(basePath, options?)

Two arguments: basePath, and an options object whose only field is auth.

  • basePath is where every route in the class mounts ("/todos").
  • options.auth is an AuthSpecboolean, or { required?, role?, permission?, verifiedEmail? } — and it sets the controller-wide auth default, which individual routes override. See Authentication.

Note: Export the class by nameexport class TodosController. Its module takes it with import { TodosController } from "./todos.controller" to list it in controllers, and that list is the registration; @Controller only describes the class. A export default class paired with that import does not compile, which is why the scaffold writes a named export for every controller and no file in it is default-exported. The one exception in the whole product is db/*.ts, which requires export default, one defineSchema per file.

Controllers are instantiated once, when the route table is built at boot, and that one instance serves every request. this is fine for wiring helpers onto the class; never store per-request state in an instance field. Per-request data comes from the parameter decorators.

A @Controller class that collected zero routes is fatal at boot rather than a 404 later — usually it means the decorators were compiled without experimentalDecorators, and the error says so.

/webhooks is refused at decoration time

@Controller throws while decorating if the first path segment of the base path — or of any route path it composes — is webhooks. That segment belongs to the webhook dispatcher, which routes /webhooks/<name> — the name declared on @Webhook — before the app router ever sees the request. The check is segment-wise and covers every verb, so @Controller("/") + @Post("/webhooks/stripe") is refused while @Controller("/webhooksy") is fine.

Method decorators

Eight decorators mount a route. Each takes (subpath, options?), except @Upload, whose second argument is required:

DecoratorWire methodNotes
@GetGET
@PostPOST
@PutPUT
@PatchPATCH
@DeleteDELETE
@QueryQUERYRFC 10008 — a safe, idempotent read that carries a body
@UploadPOSTdeclares a direct-to-storage upload; see Direct Uploads
@SsePOSTdeclares a server-sent-event stream; see Streaming

The options object is RouteOptions, and the SDK exports the type — so you can name it:

import type { RouteOptions } from "@palbase/backend";

// what a method decorator's second argument accepts
const options: RouteOptions = {
  // boolean, or { required?, role?, permission?, verifiedEmail? }
  auth: { required: true, verifiedEmail: true },
  // per-route rate limit, window in SECONDS
  rateLimit: { max: 10, window: 60 },
  // a query budget enforced before statements and COMMIT
  databaseBudget: { maxQueries: 20 },
};

uploadConfig and sseConfig are the two remaining fields, and you never write either: @Upload and @Sse set them, and their presence is what marks the route as an upload or a stream through the whole pipeline.

Route input and output are not decorator options: input comes from the parameter decorators (Request Validation), and the success response is the method's return type (Responses & Errors). The method decorator carries route concerns only — path, auth, rate limit.

verifiedEmail: true answers 403 email_not_verified when the caller's token does not carry email_verified: true. See Authentication.

@Query — reads with a body (RFC 10008)

@Query(subpath, options?) mounts the route on the HTTP QUERY method: safe and idempotent like GET, but carrying a request body like POST. Use it for reads whose input is too structured for a query string — search, nested filters, batch lookups:

// modules/todos/todos-search.controller.ts
import { Body, Controller, Query, z } from "@palbase/backend";

import { TodoSchema } from "./dto/create";
import { TodoService } from "./todo.service";

export const SearchTodosBody = z.object({
  titleContains: z.string().optional(),
  tags: z.array(z.string()).max(20).default([]),
});
export type SearchTodosBody = z.infer<typeof SearchTodosBody>;

@Controller("/todos")
export class TodosSearchController {
  constructor(private readonly todos: TodoService) {}

  @Query("/search")
  search(@Body(SearchTodosBody) body: SearchTodosBody): Promise<TodoSchema[]> {
    return this.todos.search(body.titleContains);   // `todos` arrived through the constructor
  }
}

A QUERY route's input rides in the body via @Body, per the RFC. Do not confuse the @Query method decorator with the @QueryParams parameter decorator (query-string input), and never combine @Body and @QueryParams on the same QUERY route — the web codegen drops operations that declare both.

Choosing a verb for a read:

  • GET — no input, or input that fits a few scalar query params (@QueryParams).
  • QUERY — a read whose input is a structured object (nested filters, arrays). Safe and idempotent semantics, with a body.
  • POST — the request changes state. Do not reach for POST just because a read needs a body; that is what QUERY is for.

Note: @Query(schema) on a parameter throws at decoration time with a message naming the replacement — the query-string parameter decorator was renamed @QueryParams(schema) in @palbase/backend 9.0.0.

Paths

The full path of a route is basePath + subpath, split on / with empty segments dropped — so @Controller("/todos") with @Get("/"), @Get("") and @Get("//") all mount at /todos.

A {segment} declares a path parameter, injected by name with @Param. A literal :segment works too and means the same thing:

// modules/comments/comments.controller.ts
import { Controller, Get, Injectable, Param, z } from "@palbase/backend";

export const CommentSchema = z.object({ id: z.string(), body: z.string() });
export type CommentSchema = z.infer<typeof CommentSchema>;

@Injectable()
export class CommentService {
  get(todoId: string, commentId: string): Promise<CommentSchema> {
    return Promise.resolve({ id: commentId, body: `a comment on ${todoId}` });
  }
}

@Controller("/todos")
export class CommentsController {
  constructor(private readonly comments: CommentService) {}

  @Get("/{id}/comments/{commentId}")
  getComment(@Param("id") id: string, @Param("commentId") commentId: string): Promise<CommentSchema> {
    return this.comments.get(id, commentId);
  }
}

Matching is a segment walk with an exact segment count/todos/{id} never matches /todos/a/b — and the first matching entry in declaration order wins. Path parameter values are decodeURIComponent'd and are always strings; validate or coerce them in your handler or service if you need a stricter shape.

Reserved path prefixes

Your Environment splits traffic by prefix before it reaches you. Ten prefixes go to the platform, and everything else goes to your runtime. A route you mount under one of these is never called:

/.well-known   /_artifacts   /_internal   /_panel   /admin
/auth          /oauth        /realtime    /rt       /v1

Matching is at a segment boundary, so /v1beta and /authors are yours; /v1 and /v1/balance are not. Note that /admin is reserved — it is one of the ten, not a safe alternative.

The failure this causes is silent by construction, and it has been measured live: a controller mounted four routes under /v1, the deploy reported success, the contract kept declaring them, palbase spec generated client methods for them, and the first call got a 404 from the platform that nobody could account for.

Warning: Nothing warns you. There is a shadowed-route check in the platform that names each offending path and the prefix that swallows it, but it sits on a push route no working configuration reaches — and palbase push in a linked checkout posts straight to the Environment's management API, which does not run it. Treat this list as an authoring rule you enforce yourself: give your routes a prefix of your own (/billing/v1/balance, not /v1/balance).

Rate limiting

Declare a per-route limit with rateLimit: { max, window } — at most max requests per window seconds:

// modules/todos/todos-import.controller.ts
import { Body, Controller, Injectable, Post, z } from "@palbase/backend";

export const ImportBody = z.object({ titles: z.array(z.string()).max(1000) });
export type ImportBody = z.infer<typeof ImportBody>;

export const ImportResult = z.object({ imported: z.number().int() });
export type ImportResult = z.infer<typeof ImportResult>;

@Injectable()
export class ImportService {
  run(body: ImportBody): Promise<ImportResult> {
    return Promise.resolve({ imported: body.titles.length });
  }
}

@Controller("/todos")
export class TodosImportController {
  constructor(private readonly imports: ImportService) {}

  @Post("/import", { rateLimit: { max: 5, window: 60 } })
  importTodos(@Body(ImportBody) body: ImportBody): Promise<ImportResult> {
    return this.imports.run(body);
  }
}

Over the limit, the route answers 429 with a retry-after header in seconds and this body:

{
  "error": "too_many_requests",
  "error_description": "Rate limit exceeded for this endpoint",
  "status": 429,
  "request_id": "req_01h…"
}

There are no X-RateLimit-* headers. What you get is the retry-after on the refusal, and nothing on a request that passed.

How the caller is identified, because it decides what the limit actually protects:

  • A signed-in request is keyed by route plus the user id from the verified token.
  • An anonymous request is keyed by route plus the first hop of x-forwarded-for, or x-real-ip.
  • A request that arrived with no identity at all falls back to the literal key anonymous, so every unidentified caller shares one bucket. That is deliberately conservative: the alternative is a limit anyone resets by omitting a header.

The window is fixed and counted in this process's memory, bounded at 100 000 distinct keys. A restart forgets every window, and an overflow drops the oldest ones — both forgive rather than invent a refusal.

Note: This is your route's own limit, not the plan's. Separately, your plan sets a ceiling for the whole Environment, answering 429 with retry-after: 60 above it. See Limits.

Note: To rate-limit inside your own business logic (a per-resource cooldown, say), throw new TooManyRequests({ retryAfter }) instead — that one surfaces as a typed retryAfter on generated clients. See Responses & Errors.

Where a controller lives

Nothing about the path decides anything. A controller is mounted because the controllers list of a @Module names it, so the file sits with the domain that owns it — modules/todos/todos.controller.ts, modules/billing/invoices.controller.ts — and moving it, nesting it or renaming it changes nothing about whether it is served.

Two consequences, and both used to be silent:

  • A class no module lists is refused at build, by name. It does not deploy and then quietly go missing. There is no directory that mounts anything and nothing reads one; back when a flat glob was the discovery, a controller one folder deeper was never mounted and nothing reported it — the routes just did not exist.
  • The .controller.ts suffix is a convention. It helps a reader find the file; what mounts the class is the module list. Helpers, types and shared utilities are ordinary modules that something imports — keep them beside the domain that uses them, and put that domain's zod schemas in its own dto/ (Request Validation).

How class names become client calls

Every route gets an operation id of the form <controllerName>.<methodName>, and the generated clients (web, iOS) group calls by that namespace:

  • The namespace is the class name with one trailing Controller stripped and the first letter lower-cased: TodosControllertodos, UserProfileControlleruserProfile, Membersmembers.
  • The method name is used as-is: TodosController.createpb.todos.create(...).
// modules/inbox/messages.controller.ts
import { Controller, Injectable, Post } from "@palbase/backend";

@Injectable()
export class MessageService {
  archiveAll(): Promise<void> {
    return Promise.resolve();
  }
}

@Controller("/inbox")
export class MessagesController {
  constructor(private readonly messages: MessageService) {}

  @Post("/archive")
  archiveAll(): Promise<void> {
    return this.messages.archiveAll();
  }
}

That is pb.messages.archiveAll() — from the class name — even though the base path is /inbox. The base path does not name the namespace. A class named exactly Controller, or an anonymous class, yields an empty namespace and the generator falls back to a flat id built from the verb and path.

Warning: Renaming the class or a method changes the generated client call: archiveAllarchiveDone breaks every client calling pb.messages.archiveAll. The verb and the path do not affect the operation id, so you can restructure paths freely. Treat class names and method names as your public API.

Note: The bundler restores controller class names before emitting the entry. A bundler MUST rename duplicate top-level identifiers to keep them apart — two files both declaring class PalaiController become PalaiController and PalaiController2 — and this happens during ordinary bundling, with no minification involved. Eleven files each declaring a PalaiController once shipped a contract split into palai plus palaiController2 through palaiController11, breaking every generated client. The restoration reads the names back out of your source; --keep-names does not cover this case, because that flag defends against minification rather than against de-duplication.

Keep controllers thin

Controllers translate HTTP into service calls. The decisions — which rows, whose, in what order — live in an @Injectable() service the same module lists in providers:

// modules/todos/todo.service.ts
import { Database, Injectable, NotFound } from "@palbase/backend";

import { TodoSchema } from "./dto/create";

/** The typed surface of ONE table, behind a class the container can resolve.
 * `Database.public.todos` is a VALUE, so it cannot BE a dependency — a
 * dependency is named by its parameter's TYPE. Naming one table here keeps the
 * seam one table wide: a test fake implements two methods, not the whole
 * `Database`. */
export abstract class TodoRepo {
  abstract insert(row: { user_id: string; title: string }): Promise<TodoSchema>;
  abstract findMany(where: { user_id: string }): Promise<TodoSchema[]>;
  abstract findById(id: string): Promise<TodoSchema | null>;
  abstract search(titleContains: string): Promise<TodoSchema[]>;
}

@Injectable()
export class DbTodoRepo extends TodoRepo {
  insert(row: { user_id: string; title: string }): Promise<TodoSchema> {
    return Database.public.todos.insert(row);
  }
  findMany(where: { user_id: string }): Promise<TodoSchema[]> {
    return Database.public.todos.findMany({ where });
  }
  findById(id: string): Promise<TodoSchema | null> {
    return Database.public.todos.findById(id);
  }
  search(titleContains: string): Promise<TodoSchema[]> {
    return Database.public.todos.findMany({ where: { title: { icontains: titleContains } } });
  }
}

@Injectable()
export class TodoService {
  // The seam. The container supplies it; a test supplies a different one.
  constructor(private readonly todos: TodoRepo) {}

  /** Ownership is written HERE — the request body may not carry it. */
  create(userId: string, title: string): Promise<TodoSchema> {
    return this.todos.insert({ user_id: userId, title });
  }
  list(userId: string): Promise<TodoSchema[]> {
    return this.todos.findMany({ user_id: userId });
  }
  async get(id: string): Promise<TodoSchema> {
    const todo = await this.todos.findById(id);
    if (!todo) throw new NotFound("No todo with that id");
    return todo;
  }
  search(titleContains = ""): Promise<TodoSchema[]> {
    return this.todos.search(titleContains);
  }
}

Once the repository exists, todos.module.ts lists both TodoService and DbTodoRepo in providers. TodoRepo is not listed: the abstraction is the token and the implementation is the provider, so the container resolves TodoRepo to the single class that extends it — and refuses the build by name when that is zero classes or two.

The constructor is what makes the layer pay off: because the service is handed its repository rather than reaching for the table itself, a test builds it with a stand-in and never needs a database — isolated().with(TodoRepo, fake).get(TodoService). A controller gets its service the same way, and it is the only way to hold a dependency here: there is no @Inject, no useFactory and no token registry. See Modules & Dependency Injection and Testing.

Error classes like NotFound can be thrown from a service directly — no request object is needed, because the singletons and the error envelope are both resolved from the request scope the engine opened. The runtime turns the throw into the standard envelope (Responses & Errors), and services stay independently testable (Testing).

There is no middleware pipeline to put cross-cutting work in. defineMiddleware is still exported, but no middleware/ directory is discovered and the engine never calls one — a service the controllers call is the working shape.