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 createCommentSchema = z.object({
  body: z.string().trim().min(1),
  parentId: z.string().optional(),
});

export const createComment = async (req: AuthRequest, res: Response, next: NextFunction) => {
  const validated = createCommentSchema.safeParse(req.body);

  if (!validated.success) {
    return res.status(400).json({ message: "Invalid comment payload", errors: validated.error.flatten() });
  }
  // …
};

Larger or reused schemas go in apps/api/src/schemas/ (e.g. createPostSchema in schemas/post.ts). 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");

The error middleware (apps/api/src/middleware/error.ts) responds with the error's numeric status (or statusCode), and with 500 for anything else. A Prisma code like P2002 is never used as a status. A ZodError becomes a 400. A 5xx message is only sent to the client for an HttpError. Anything else says "Internal Server Error", so throw badRequest(…) rather than new Error(…) for a message the user should see.

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 on the socket ingress in infra/helm/values/production/platform.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