doc/architecture.md
Loosely inspired by the Controller–Service–Repository pattern with dependency injection. The backend is organized as a stack of layers where each layer only depends on the layers beneath it, and PuterServer (src/backend/server.ts) instantiates each layer in order and hands the instances down to the next.
block-beta
columns 1
REQ["HTTP request"]
CTRL["Controllers — route handlers, gates, I/O shaping"]
DRV["Drivers (optional) — RPC handlers on /drivers/*"]
SVC["Services — business logic, no auth"]
STR["Stores — persistence / domain shapes"]
CLI["Clients — sql, redis, s3, dynamo, email, …"]
CFG["Config — IConfig"]
REQ --> CTRL
CTRL --> DRV
DRV --> SVC
SVC --> STR
STR --> CLI
CLI --> CFG
style REQ fill:#0ea5e9,stroke:#0369a1,color:#fff
style CTRL fill:#1d4ed8,stroke:#1e3a8a,color:#fff
style DRV fill:#2563eb,stroke:#1e40af,color:#fff
style SVC fill:#4f46e5,stroke:#3730a3,color:#fff
style STR fill:#7c3aed,stroke:#5b21b6,color:#fff
style CLI fill:#9333ea,stroke:#6b21a8,color:#fff
style CFG fill:#334155,stroke:#1e293b,color:#fff
Each layer only depends on the layers beneath it, and every dependency is injected through the constructor by PuterServer. Extensions sit alongside this stack and can register into any layer — see Extensions below.
| Layer | Lives in | Responsibility |
|---|---|---|
| Controllers | src/backend/controllers/ | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers — see RouteOptions), call into services, format responses. |
| Drivers | src/backend/drivers/ | Optional. RPC-style handlers exposed over the /drivers/* surface (puter-kvstore, puter-chat-completion, …). A driver is a thin shell that validates RPC inputs and calls into services/stores; controllers can hold a typed reference to drivers when they need the same logic over HTTP. |
| Services | src/backend/services/ | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
| Stores | src/backend/stores/ | Persistence and storage logic. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
| Clients | src/backend/clients/ | Adapters for external/internal services (sql, redis, s3, dynamodb, email, event bus, …). Knows protocols, not domain concepts. |
| Config | config.*.json → IConfig | The flat, typed config object every layer receives at construction. |
Each layer receives the layers beneath it through its constructor, so dependencies are explicit and traceable from PuterServer. A controller does not reach into a client directly; if it needs one, the right move is usually a service.
PuterServerPuterServer is the bootstrap. It:
config.extensions) so extensions can register before instantiation begins.PuterRouter (which translates RouteOptions into the gate/parser middleware chain), and mounts extension routes through the same materializer.onServerStart hooks across every layer once HTTP is listening, and onServerPrepareShutdown / onServerShutdown on the way down.We use Context — backed by AsyncLocalStorage — to carry per-request state without threading it through every function signature. It is used sparingly, mostly for actor and req. The request-context middleware opens a scope per request after the auth probe runs; anything inside a request handler can call Context.get('actor') / Context.get('req') instead of plumbing it as an argument.
Prefer explicit arguments. Reach for Context only when the value is truly request-scoped and would otherwise need to thread through many layers.
Extensions live alongside core (packages/puter/extensions/) and parallel the layered stack. They are meant for non-crucial parts of the system — things Puter still works without if removed.
The extension global (src/backend/extensions.ts) exposes:
extension.registerClient(name, ClientClass)extension.registerStore(name, StoreClass)extension.registerService(name, ServiceClass)extension.registerDriver(name, DriverClass)extension.registerController(name, ControllerClass)extension.on(event, handler) — subscribe to event-bus events.extension.get(path, opts?, handler) / .post / .put / .delete / .patch / .head / .options / .all / .use — register routes. The opts shape is the same RouteOptions controllers use, so subdomain, requireAuth, adminOnly, body parsers, etc. all work identically.extension.import('client' | 'store' | 'service' | 'controller' | 'driver') returns a lazy proxy to instantiated objects, and extension.config exposes the live config.import { extension } from '@heyputer/backend/src/extensions';
const services = extension.import('service');
extension.get('/healthcheck/deep', { subdomain: 'api', adminOnly: true }, async (_req, res) => {
res.json({ ok: await services.health.runDeepCheck() });
});
extension.on('user.signup', (_key, data) => {
console.log('new user', data.user.username);
});
camelCase for variable/function names; PascalCase for classes and for files that contain a class (AuthService.ts, KVStoreDriver.ts).