Coding Rules
Naming Conventions
| Target | Convention | Example |
|---|---|---|
| Variables and functions | camelCase | getCandidateById |
| Types and interfaces | PascalCase | ArrangeSettings |
| File names | kebab-case | survey-import.ts / use-project-arrange.ts |
| Constants | UPPER_SNAKE_CASE | VALID_STATUS_TRANSITIONS |
| DB tables and columns | snake_case (plural tables) | project_candidates.external_user_id |
| Public API fields | snake_case | interview_duration_minutes |
| Internal code from services down | camelCase | interviewDurationMinutes |
| React components | PascalCase (files kebab-case) | SegmentSidebar / segment-sidebar.tsx |
| Orval-generated hooks | Derived from the path (do not rename) | useGetApiV1ProjectsProjectId |
Where snake_case โ camelCase conversion happens
Public API fields are snake_case, internals are camelCase. The conversion is performed explicitly inside the route handler (data.interview_duration_minutes โ interviewDurationMinutes). Never let snake_case leak into the service layer.
Formatter and Linter
# backend
bun run format # prettier --write 'src/**/*.ts'
bun run lint # eslint src/
bun run lint:fix
bun run check-types # tsc --noEmit
# frontend
bun run format
bun run lint
bun run check-types
Configuration files:
| File | Contents |
|---|---|
.prettierrc.mjs |
semi: true / singleQuote: true / printWidth: 100 / trailingComma: 'es5' / tabWidth: 2 |
eslint.config.js (backend) |
@typescript-eslint recommended plus custom rules |
eslint.config.mjs (frontend) |
eslint-config-next plus prettier |
Notable backend ESLint rules:
| Rule | Setting |
|---|---|
@typescript-eslint/no-unused-vars |
error, with an _ prefix exemption |
@typescript-eslint/no-explicit-any |
warn |
no-console |
warn (only console.warn / console.error allowed; use logger normally) |
@typescript-eslint/explicit-function-return-type |
off |
Both repositories run prettier --write and eslint --fix over src/** on commit via husky + lint-staged.
Backend Conventions
Imports carry the ESM .js extension
This applies even when the target is a TypeScript source file.
Always return through the response helper
Use response.success / created / paginated / error from src/lib/response.ts. The body is always wrapped in the envelope.
return response.success(c, project);
return response.created(c, project);
return response.paginated(c, items, pagination);
Never call c.json() directly.
Just throw errors
middlewares/error-handler.ts converts them into status / code / message. Do not build error responses with try-catch inside handlers.
if (!project) throw new NotFoundError('Project'); // 404
throw new InvalidStatusTransitionError(oldStatus, newStatus); // 400
| Location | Contents |
|---|---|
src/lib/errors.ts |
Generic (HTTP / DB / external API / Databricks) |
src/error.ts |
Domain-specific (Google API, scheduling tokens, slot conflicts, status transitions) |
Errors with isOperational === false do not expose details to the client (stack traces only in development).
Do not skip layers
Preserve the one-way dependency routes โ services โ repositories โ Drizzle.
- Never call Drizzle from a route
- Never pass Hono's
Contextinto a service - Never put business decisions in a repository
Read environment variables through getEnvConfig()
Do not read process.env directly. In particular, use the dedicated helpers for PROTOTYPE_MODE.
if (isEmailEnabled()) { ... } // โ
if (isCalendarEnabled()) { ... } // โ
if (process.env.PROTOTYPE_MODE) { ... } // โ
Batches (src/batch/*.ts) are the exception: they read DATABASE_URL directly so they can run where the app's required variables are absent.
Write the route definition next to its handler
const getProjectRoute = createRoute({
method: 'get',
path: '/{projectId}',
tags: ['Projects'],
summary: 'Get project detail',
security: [{ bearerAuth: [] }],
request: { params: ProjectIdParamSchema },
responses: { 200: { ... }, 404: commonResponses[404] },
});
projectsRoutes.openapi(getProjectRoute, async (c) => { ... });
Reuse commonResponses for error responses.
Frontend Conventions
src/api/** is generated โ never edit it
After changing the API, start the backend and run bun run generate-api.
src/lib/auth.ts is the only gateway for auth tokens
Do not touch localStorage directly. Use setAuthToken / getAuthToken / removeAuthToken.
Unwrap API responses with apiData()
Do not hand-traverse response.data.data.
Where state lives
| Kind | Location |
|---|---|
| Server state | React Query (Orval-generated hooks) |
| Local UI state | useState in the component |
| Shared providers | src/components/providers.tsx |
| Arrange flow state | Concentrated in use-project-arrange.ts; keep screens thin |
Compose class names with cn()
About the Duplicated Type
ArrangeSettings (the contents of the projects.arrange_settings JSON column) is defined in two places because OpenAPI cannot express the internal structure of a JSON column.
| File | Purpose |
|---|---|
backend src/db/schema.ts |
DB and service side |
frontend src/types/arrange-settings.ts |
Screen side |
Change one and you must change the other.
PR and Review Standards
- One PR, one purpose
- Do not mix in unrequested features, refactors, or abstractions
- A PR that changes the API must include the resulting
src/api/**diff frombun run generate-api - A PR that changes the DB schema must also update the Database docs
- Self-review before requesting review
- Never use force pushes
Anti-patterns
- Overusing
any(only a warning, but avoid it on principle) - Inline
console.log(uselogger; onlyconsole.warn/console.errorare allowed) - Calling Drizzle from a route handler
- Calling
c.json()directly (use theresponsehelper) - Building error responses with try-catch inside handlers (just
throw) - Reading
process.envdirectly - Hand-editing generated files (
src/api/**) - Adding an environment variable to
src/config/env.tsbut forgetting.env.example - Inline magic numbers (extract constants)
- Deep nesting (use early returns)