Skip to content

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:

json
{ "printWidth": 120 }
bash
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-types runs tsc --noEmit across the workspace via Turborepo.
  • Backend services run through tsx in development (no build step) and compile with tsc for production images.

Backend service structure

Every Express service uses the same four layers. Keep the boundaries clean:

LayerDirectoryResponsibilityMust not
Routersrc/router/Map a path + method + middleware to a controller. Nothing else.Contain logic
Controllersrc/controllers/Parse and validate input, call services, shape the HTTP responseCall Prisma directly
Servicesrc/services/Business logic and data accessImport express types
Libsrc/lib/Cross-cutting helpers: logger, redis, jwt, s3, mux, livekitContain 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:

ts
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:

json
{ "message": "…" }

Stack traces are included only when NODE_ENV === "development".

There is a helper class in apps/api/src/lib/httpErrors.ts:

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:

ts
constructor(status: number, message: string, code?: string)

But the middleware derives the response status from .code:

ts
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:

ts
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):

ts
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

ThingConventionExample
FilescamelCasepushToken.ts, channelRoom.ts
Route files<domain>Routes.tssubscriptionRoutes.ts
React componentsPascalCase files under components/CalendarView.tsx
Mobile hooksuse<Thing>.tsuseTrending.ts
Prisma modelsPascalCase singularCreatorSubscription
Prisma columnsMixed — snake_case on User, camelCase elsewheredisplay_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.

Internal documentation — PlayPalz platform