Palbase
Sign inGet started

Getting Started

Architecture

A Palbase backend is a plain TypeScript repository, and one module declaration says what exists, who owns it and what it may reach. A @Module lists a domain's classes — its controllers, its providers, what it exports and whose exports it imports — and a class that 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 one thing read by location is the schema: db/*.ts, one file per schema.

Dependencies arrive through the constructor, and a container supplies them — a dependency is named by its parameter's TYPE and by nothing else. There is no @Inject, no useFactory, no forwardRef and no token registry, and none is needed. The full declaration surface is on Modules.

That leaves one question this page answers: what shape does the code take? Everything below is the shape palbase init already ships, so you can open the files beside this text and read the same thing twice.

The layers

LayerWhat it doesWhat it never does
modules/<domain>/<domain>.module.tsThe declaration: which classes this domain owns, and which of them another domain may reachholds logic · imports a class it does not list
modules/<domain>/<name>.controller.tsHTTP only: validates the body through a named schema, names the 200 shape as the return type, turns a missing row into a status. Listed in the module's controllersimports Database, Storage or Cache · holds business rules · stores per-request state on the instance
modules/<domain>/<name>.service.tsThe decisions: which rows, whose, in what order. Throws the error classes. @Injectable(), listed in the module's providersknows about HTTP (no req, no res) · reaches for a singleton itself — it takes its dependency through the constructor
modules/<domain>/dto/<endpoint>.tszod schemas, exported twice under one name: a value and a same-named z.infer typeholds logic
db/public.tsIs the database: tables, columns, RLS policiesproduces migration files — there are none

In one line: controllers thin, services thick, schemas quiet, db/ the single truth — and the module is the registration.

A domain is a FOLDER, and adding one is adding a folder. There is no root module and nothing to mount one into: the health probe the scaffold ships is modules/health/, a domain like any other. The file SUFFIX (.controller.ts, .service.ts) is a convention that helps a reader and decides nothing — what decides is the list a class appears in and the decorator it carries.

The rule that carries the most weight is the narrowest one: a controller does not import Database. When a route starts reaching for a table, the logic has moved into the layer that is hardest to test, and the layer that was supposed to hold it becomes decoration.

Where does this go

The needIts home
Validate inputa zod schema in the module's dto/, passed to @Body(Schema)
Enforce ownershipan RLS policy in db/public.ts and the service's where — the policy is the backstop, not a reason to stop writing the filter
A business rule, a calculation, an orderingthe service
Return an HTTP statusthrow new NotFound(…) — from the controller or the service; no request object is needed
Call another featurename its class in your constructor — its module must exports it, and yours must imports that module
Scheduled or background worka @Job class listed in a module's providers — there is no queue, and no jobs/ directory
Register a new classadd its name to the module's providers (or controllers) — nothing else registers anything
Read a setting or a secretSecrets.get(name) / Flags.isEnabled(name) — the names are checked by the compiler
Share code between controllersa plain module they import — not a base class, not middleware

One vertical, end to end

This is the notes feature the scaffold ships, in the order you would build it.

1. The database

// db/public.ts
import { defineSchema, defineTable, ownedByUser, policy, text, timestamp, uuid } from "@palbase/backend";

const notes = defineTable("notes", {
  columns: {
    id: uuid().primaryKey().defaultRandom(),
    // `ownedByUser()` implies text, NOT NULL and ON DELETE CASCADE — a real
    // foreign key to your project's own users, so deleting an account takes its
    // rows with it. For a column that merely POINTS at a user (`created_by`),
    // use `userRef({ onDelete })` instead.
    user_id: ownedByUser(),
    body: text().notNull(),
    created_at: timestamp().defaultNow(),
  },
  // `policies` is a FUNCTION, not an array: the callback receives the column
  // context, so a policy can name this table's columns before the binding
  // above it exists.
  policies: () => [
    // Postgres enforces ownership, not the handler: a query that forgets its
    // `where user_id = …` still cannot see another user's rows.
    policy("notes_owner")
      .for("all")
      .to("authenticated")
      .using("user_id = (select auth.uid())")
      .withCheck("user_id = (select auth.uid())"),
  ],
});

export default defineSchema("public", { tables: [notes] });

2. The schemas

// modules/notes/dto/create.ts
import { z } from "@palbase/backend";

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

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

Each name is exported twice: as a zod value (what @Body receives and what a return type names) and as a same-named z.infer type (what the annotations are written with). That pairing is what lets a route read Promise<NoteSchema> instead of Promise<z.infer<typeof NoteSchema>>. Import them as values — import type erases the binding the deploy needs.

3. The service

// modules/notes/notes.service.ts
import { Database, Injectable } from "@palbase/backend";
import type { Tables } from "@palbase/backend/env";

/** One row of `notes`, exactly as `db/public.ts` declares it. */
export type Note = Tables["notes"]["row"];

/**
 * The one table this vertical touches, behind a class the container can build.
 *
 * `Database.public.notes` is a VALUE, not a class, so it cannot be a dependency
 * on its own — a dependency is named by its parameter's TYPE. Naming the table
 * here keeps the seam ONE table wide: a test stands in for four methods rather
 * than for the whole `Database`.
 *
 * It is `abstract` so a test can substitute it BY NAME. The container resolves
 * an abstraction to the single class that extends it.
 */
export abstract class NoteRepo {
  abstract findMany(where: { user_id: string }): Promise<Note[]>;
  abstract insert(row: { user_id: string; body: string }): Promise<Note>;
  abstract findById(id: string): Promise<Note | null>;
  abstract delete(id: string): Promise<void>;
}

@Injectable()
export class DbNoteRepo extends NoteRepo {
  private readonly notes = Database.public.notes;

  findMany(where: { user_id: string }): Promise<Note[]> {
    return this.notes.findMany({ where });
  }
  insert(row: { user_id: string; body: string }): Promise<Note> {
    return this.notes.insert(row);
  }
  findById(id: string): Promise<Note | null> {
    return this.notes.findById(id);
  }
  delete(id: string): Promise<void> {
    return this.notes.delete(id);
  }
}

@Injectable()
export class NoteService {
  // The dependency arrives through the CONSTRUCTOR and the container supplies
  // it. Nothing here registers anything: `notes.module.ts` lists both classes,
  // and that list is the only place ownership is decided.
  constructor(private readonly notes: NoteRepo) {}

  list(userId: string): Promise<Note[]> {
    return this.notes.findMany({ user_id: userId });
  }

  create(userId: string, body: string): Promise<Note> {
    return this.notes.insert({ user_id: userId, body });
  }

  get(id: string): Promise<Note | null> {
    return this.notes.findById(id);
  }

  remove(id: string): Promise<void> {
    return this.notes.delete(id);
  }
}

There is no inject(), no @Inject, and no token registry: a dependency is named by its parameter's type and by nothing else. An interface cannot be one, because it does not exist at runtime — use a class, or an abstract class when you want the abstraction.

The constructor is the seam, and it is the whole reason the layer pays off. The service asks for a NoteRepo and the container supplies it; a test supplies a different one and never needs a database:

// modules/notes/notes.service.test.ts
import { describe, expect, it } from "bun:test";
import { isolated } from "@palbase/backend/test";

import { NoteRepo, NoteService, type Note } from "./notes.service";

class FakeRepo extends NoteRepo {
  readonly seen: unknown[] = [];
  async findMany(where: { user_id: string }): Promise<Note[]> {
    this.seen.push(where);
    return [];
  }
  async insert(row: { user_id: string; body: string }): Promise<Note> {
    return { id: "n_1", user_id: row.user_id, body: row.body } as Note;
  }
  async findById(): Promise<Note | null> {
    return null;
  }
  async delete(): Promise<void> {}
}

describe("NoteService", () => {
  it("asks only for the caller's notes", async () => {
    const repo = new FakeRepo();
    const svc = isolated().with(NoteRepo, repo).get(NoteService);

    await svc.list("u_1");

    expect(repo.seen).toEqual([{ user_id: "u_1" }]);
  });
});

isolated() rebuilds the graph with the override in place and touches no process-wide state, so the next test in the file does not meet whatever this one substituted. The substitution is deep: it would work the same if NoteRepo were two hops below the class under test.

A repository reaching for an ambient platform service (Database, Storage, Cache, …) is a different seam — those are not injected, so isolated() cannot replace them. Use withServices({ Database: fake.raw }, …) from @palbase/backend/test.

4. The controller

// modules/notes/notes.controller.ts
import { Body, Controller, Delete, Get, NotFound, Param, Post, User } from "@palbase/backend";
import type { UserT } from "@palbase/backend";

import { CreateNoteBody, NoteSchema } from "./dto/create";
import { NoteService } from "./notes.service";

@Controller("/notes")
export class NotesController {
  constructor(private readonly notes: NoteService) {}

  @Get("")
  list(@User() user: UserT): Promise<NoteSchema[]> {
    return this.notes.list(user.id);
  }

  @Post("")
  create(@Body(CreateNoteBody) body: CreateNoteBody, @User() user: UserT): Promise<NoteSchema> {
    return this.notes.create(user.id, body.body);
  }

  @Get("/{id}")
  async get(@Param("id") id: string): Promise<NoteSchema> {
    // `null` covers both "no such note" and "not yours" — the second is not
    // distinguishable from outside on purpose, because telling a caller that a
    // row they may not read exists is itself a leak.
    const note = await this.notes.get(id);
    if (!note) throw new NotFound("no note with that id");
    return note;
  }

  @Delete("/{id}")
  remove(@Param("id") id: string): Promise<void> {
    return this.notes.remove(id);
  }
}

Four routes, and nothing in the class that can be wrong. The class is exported by name and no file here is default-exported: @Controller describes the class, it does not enrol it, and neither does the folder the file sits in. What enrols it is the last file of the vertical.

5. The module

// modules/notes/notes.module.ts
import { Module } from "@palbase/backend";

import { NotesController } from "./notes.controller";
import { DbNoteRepo, NoteService } from "./notes.service";

// The lists are TYPED: a non-class here is a compile error, so nothing has to
// be cast on the way in.
@Module({
  controllers: [NotesController],           // which entry points does it own
  providers: [NoteService, DbNoteRepo],     // which classes does it own
  exports: [],   // which of its own classes may ANOTHER module reach
  imports: [],   // whose exports may THIS module reach
})
export class NotesModule {}

That list is the registration, and it is the only place ownership is decided. NoteRepo is absent on purpose: the abstraction is the token and the implementation is the provider, so DbNoteRepo is listed and NoteRepo is not — the container resolves the abstraction to the single class that extends it, and refuses by name when there are zero or two. Listing the abstract class itself is refused too.

exports is empty until another domain genuinely needs one of these classes; nothing outside the module can reach them before a name appears there. @Job, @Webhook, @Hook and @Room classes go in this same providers list — there is no jobs/, webhooks/ or hooks/ directory and nothing reads one. See Modules.

Never write this

These are the shapes a code assistant reaches for by default. Every one of them is wrong here:

  • Express-style handlersapp.get(...), (req, res) => …, req.params. A route is a method on a class.
  • A ctx object — there is no context to thread and no handler signature that receives one.
  • A class that no module lists — refused at build, by name. It never reaches the route table, the dispatcher or the OpenAPI document, and the folder it sits in grants it nothing.
  • @Inject, useFactory, forwardRef, or a token registry — a dependency is named by its constructor parameter's TYPE and by nothing else. None of them exists here and none is needed: a real cycle cannot be built, because the import dies first, so there is nothing for forwardRef to rescue.
  • An interface as a dependency — it has no runtime existence, so there is nothing for the container to read. Use a class, or an abstract class when you want the abstraction.
  • A hand-wired singletonexport const noteService = new NoteService(…). Mark the class @Injectable(), name what it needs, list it in a module's providers; the container builds the graph once at boot.
  • I/O in a constructor — it runs while the app is coming up, it is synchronous, and it stays pure wiring. Startup work goes in onStart, which is awaited before the first request.
  • middleware/ — nothing mounts it and the engine has no middleware pipeline. Code written against it deploys, never runs, and nothing reports it.
  • A second abstraction over the repository — the scaffold already puts one class between the service and the table (NoteRepo), and that class IS the seam a test substitutes. A layer above it buys nothing and hides what the constructor already says.
  • A migration file — there are none anywhere in this product. See Schema changes.
  • process.env for configuration — a secret is read with Secrets.get(), and its value never enters your repository.
  • try { … } catch { return 500 } — throw the error class and let the runtime build the envelope. Swallowing it turns a 404 into a 500.
  • Hand-built responses — no JSON.stringify, no new Response(...). The 200 body is the schema your return type names.
  • An inline return typePromise<{ ok: boolean }>, a union, or a bare interface. The deploy refuses it; name a zod schema. See Responses & Errors.

Warning: Class and method names are your public API. NotesController.list generates pb.notes.list(), so renaming either renames the call in every app. The verb and the path do not affect it — restructure paths freely, rename those two carefully.

Adding a feature

  1. db/public.ts — declare the table and its RLS policies.
  2. palbase db plan — read what would change.
  3. palbase db apply — apply it in one transaction.
  4. modules/<domain>/dto/<endpoint>.ts — the schema, as a value and a type.
  5. modules/<domain>/<name>.service.ts — the logic; @Injectable(), and name what it needs as constructor parameters. Nothing is wired by hand.
  6. modules/<domain>/<name>.controller.ts — the routes; name the service in the constructor, never import Database.
  7. modules/<domain>/<domain>.module.ts — list them. A new domain is a FOLDER, and until a module names a class, that class does not exist.
  8. palbase build — writes the one generated file, palbase/palbase-env.d.ts. Commit it.
  9. npm test — exercise the service.

Give this to your agent

palbase init writes two files for coding assistants, and they are one guide, not two:

  • AGENTS.md — the code-shape guide. Read by Codex, Cursor, Copilot, Windsurf, Gemini CLI, Zed, Aider and the rest.
  • CLAUDE.md — a one-line bridge, @AGENTS.md. Claude Code reads CLAUDE.md and not AGENTS.md, so without this file the guide would reach every assistant except that one.

Keep the rules in AGENTS.md. Two hand-maintained copies drift apart, and the one that drifts is the one nobody is reading when it matters.

For the full reference — every decorator, every service, every schema helper — point your assistant at /llms.txt (or /llms-full.txt for the whole corpus in one file). Assistants fetch those when you name them; they do not go looking.