Coding Standards
These are the conventions the codebase actually follows. Match them; consistency beats personal preference.
Formatting
Prettier, configured at the root with a single override:
{ "printWidth": 120 }pnpm format # prettier --write "**/*.{ts,tsx,md}"Run it before committing. There is no pre-commit hook, so nothing will catch it for you.
TypeScript
- TypeScript 5.9 everywhere; shared base configs live in
packages/tsconfigs. pnpm check-typesrunstsc --noEmitacross the workspace via Turborepo.- Backend services run through
tsxin development (no build step) and compile withtscfor production images.
Backend service structure
Every Express service uses the same four layers. Keep the boundaries clean:
| Layer | Directory | Responsibility | Must not |
|---|---|---|---|
| Router | src/router/ | Map a path + method + middleware to a controller. Nothing else. | Contain logic |
| Controller | src/controllers/ | Parse and validate input, call services, shape the HTTP response | Call Prisma directly |
| Service | src/services/ | Business logic and data access | Import express types |
| Lib | src/lib/ | Cross-cutting helpers: logger, redis, jwt, s3, mux, livekit | Contain feature logic |
The rule that matters most: controllers own HTTP, services own the domain. If a service function takes a Request or returns a Response, it is in the wrong place.
Validation
Input validation uses zod, defined next to the controller that uses it:
const createPostSchema = z.object({
body: z.string().min(1),
visibility: z.enum(["public", "private", "followers"]).optional(),
commentable: z.boolean().default(true),
type: z.enum(["text", "clip", "photos"]).default("text"),
gameId: z.string().optional(),
});
export const createPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
const parsed = createPostSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ message: "Invalid post payload", errors: parsed.error.flatten() });
}
// …
};Larger or reused schemas go in apps/api/src/schemas/. Prefer safeParse with an explicit 400 over parse with a thrown error — the response shape is clearer to clients.
Error handling
Controllers are wrapped in try/catch and forward to next(error). The terminal error middleware (apps/api/src/middleware/error.ts) logs the error and responds:
{ "message": "…" }Stack traces are included only when NODE_ENV === "development".
There is a helper class in apps/api/src/lib/httpErrors.ts:
import { notFound, forbidden, badRequest } from "../lib/httpErrors";
throw notFound("Channel not found");Known defect: the error middleware and HttpError disagree
HttpError stores the HTTP status on .status and an optional string identifier on .code:
constructor(status: number, message: string, code?: string)But the middleware derives the response status from .code:
const statusCode = err.code || 500;So throw notFound("…") currently responds 500, not 404 — and an error carrying a string code (Prisma's P2002, Node's ECONNREFUSED) is passed straight to res.status(), which throws inside the error handler.
Until this is fixed, controllers that need a specific status should respond directly rather than throwing. If you fix it, change the middleware to read err.status ?? err.statusCode ?? 500 and check every existing throw site.
Authentication
Protected routes take the authenticated middleware from apps/api/src/middleware/authenticated.ts. It reads a bearer token (or the token cookie), verifies it, loads the user, checks tokenVersion, and attaches the result to req.user — typed via AuthRequest from src/types/request.ts.
Use AuthRequest instead of Request in any handler behind that middleware. Details in Authentication.
Logging
pino everywhere; there is no console.log in service code. The object comes first, the message second:
logger.info({ port: config.port }, "Server started");
logger.error({ err: error }, "Authentication middleware error");That ordering matters — pino treats the first argument as structured fields, and Loki queries in Grafana depend on those fields being real keys rather than interpolated strings. HTTP request logging is handled by pino-http; in development pino-pretty makes it readable.
Configuration
Never read process.env outside of a config module. Each service exposes a typed config object at src/configs/index.ts (or src/config/index.ts in esu, ogun, anansi, igdb-heartbeat):
const config = {
port: process.env.PORT || 3000,
jwtSecret: process.env.JWT_SECRET || "supersecretkey",
// …
};
export default config;When you add a variable, add it in four places: the config module, the service's .env.example, the matching infra/k8s/*-deployment.yaml, and Environment Variables.
Insecure defaults
jwtSecret and resetSecret fall back to "supersecretkey". That is convenient locally and dangerous anywhere else — production must set them explicitly, and the manifests do.
Naming
| Thing | Convention | Example |
|---|---|---|
| Files | camelCase | pushToken.ts, channelRoom.ts |
| Route files | <domain>Routes.ts | subscriptionRoutes.ts |
| React components | PascalCase files under components/ | CalendarView.tsx |
| Mobile hooks | use<Thing>.ts | useTrending.ts |
| Prisma models | PascalCase singular | CreatorSubscription |
| Prisma columns | Mixed — snake_case on User, camelCase elsewhere | display_name, monthlyPrice |
The Prisma column inconsistency is historical. Follow the surrounding model rather than trying to normalise it in an unrelated change.
Mobile
- Screens live in
apps/mobile/app/and are routed by file path (expo-router). See Navigation & Routes. - Server state is TanStack Query; there is no Redux. See Data Layer.
- Styling uses the token modules in
apps/mobile/theme/. Do not hard-code hex values — see Design System.
Comments
Comment the why, not the what. The existing codebase does this well in places worth imitating — for example, the note in infra/k8s/ingress.yaml explaining why the WebSocket ingress is a separate object, or the comment in packages/queue about maxRetriesPerRequest: null being a BullMQ requirement. Those save the next person an hour.
