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
| Layer | What it does | What it never does |
|---|---|---|
modules/<domain>/<domain>.module.ts | The declaration: which classes this domain owns, and which of them another domain may reach | holds logic · imports a class it does not list |
modules/<domain>/<name>.controller.ts | HTTP 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 controllers | imports Database, Storage or Cache · holds business rules · stores per-request state on the instance |
modules/<domain>/<name>.service.ts | The decisions: which rows, whose, in what order. Throws the error classes. @Injectable(), listed in the module's providers | knows about HTTP (no req, no res) · reaches for a singleton itself — it takes its dependency through the constructor |
modules/<domain>/dto/<endpoint>.ts | zod schemas, exported twice under one name: a value and a same-named z.infer type | holds logic |
db/public.ts | Is the database: tables, columns, RLS policies | produces 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 need | Its home |
|---|---|
| Validate input | a zod schema in the module's dto/, passed to @Body(Schema) |
| Enforce ownership | an 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 ordering | the service |
| Return an HTTP status | throw new NotFound(…) — from the controller or the service; no request object is needed |
| Call another feature | name its class in your constructor — its module must exports it, and yours must imports that module |
| Scheduled or background work | a @Job class listed in a module's providers — there is no queue, and no jobs/ directory |
| Register a new class | add its name to the module's providers (or controllers) — nothing else registers anything |
| Read a setting or a secret | Secrets.get(name) / Flags.isEnabled(name) — the names are checked by the compiler |
| Share code between controllers | a 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 handlers —
app.get(...),(req, res) => …,req.params. A route is a method on a class. - A
ctxobject — 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 forforwardRefto rescue.- An
interfaceas a dependency — it has no runtime existence, so there is nothing for the container to read. Use a class, or anabstract classwhen you want the abstraction. - A hand-wired singleton —
export const noteService = new NoteService(…). Mark the class@Injectable(), name what it needs, list it in a module'sproviders; 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.envfor configuration — a secret is read withSecrets.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, nonew Response(...). The 200 body is the schema your return type names. - An inline return type —
Promise<{ ok: boolean }>, a union, or a bareinterface. The deploy refuses it; name a zod schema. See Responses & Errors.
Warning: Class and method names are your public API.
NotesController.listgeneratespb.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
db/public.ts— declare the table and its RLS policies.palbase db plan— read what would change.palbase db apply— apply it in one transaction.modules/<domain>/dto/<endpoint>.ts— the schema, as a value and a type.modules/<domain>/<name>.service.ts— the logic;@Injectable(), and name what it needs as constructor parameters. Nothing is wired by hand.modules/<domain>/<name>.controller.ts— the routes; name the service in the constructor, never importDatabase.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.palbase build— writes the one generated file,palbase/palbase-env.d.ts. Commit it.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 readsCLAUDE.mdand notAGENTS.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.
Related
- Modules — the four lists, and why a class no module names does not exist
- Project Structure — every directory, what is generated, what is committed
- Controllers & Routing — the full routing and decorator surface
- Request Validation —
@Body,@QueryParams,@Param,@Headers - Responses & Errors — return types as response schemas, and the error envelope
- Schema · Row-Level Security — the database half
- Testing — how
tests/is graded before a release takes traffic