Instruction file imported from NhanDinhVan/wifi-sentry (
.github/instructions/backend.instructions.md). Copyright stays with the author.
Backend Conventions
For the workspace overview, service communication flow, and cross-service rules, see
.github/instructions/project.instructions.md.
Tech Stack
| Tool | Version / Config |
|---|---|
| NestJS | ^11 |
| TypeScript | ^5.7, target ES2023 |
| Node.js | module: nodenext |
| GraphQL | Apollo Server v4 + @nestjs/apollo v13 |
| ORM | TypeORM ^0.3.28 |
| Database | PostgreSQL (pg ^8.20) |
| Naming strategy | typeorm-naming-strategies SnakeNamingStrategy |
| Package manager | pnpm@10.28.1 |
| Linter | ESLint v9 + typescript-eslint v8 |
| Formatter | Prettier v3 |
Folder Structure
src/
app.module.ts # Root module — imports domain modules only
main.ts # Bootstrap entry point
commons/ # Shared, framework-agnostic utilities
constants/ # APP_ENV, route paths, etc.
enums/
guards/
interceptors/
utils/
validators/
configs/ # Config factory functions, one file per concern
app.config.ts # Central config object (reads process.env)
database.config.ts # getPostgresDatabaseConfig()
graphql.config.ts # getGraphqlConfig()
index.ts # Re-exports all configs
databases/
postgres/
datasource.module.ts # TypeORM module wrapper
datasource.ts # DataSource for CLI (migrations)
entities/{feature}/ # One folder per entity
migrations/ # TypeORM migration files
modules/
client/ # "client" domain — all client-facing features
client.module.ts # Domain root module
{feature}/ # One folder per feature
{feature}.module.ts
{feature}.resolver.ts
{feature}.service.ts
interfaces/
dtos/requests/
dtos/responses/
shared/ # Cross-domain shared providers/services
providers/ # Infrastructure providers (Redis, BullMQ, etc.)
redis/index.ts
bull-queue/index.ts
Naming Conventions
Files
| Artifact | Pattern |
|---|---|
| Module | {feature}.module.ts |
| Service | {feature}.service.ts |
| Resolver | {feature}.resolver.ts |
| Entity | {feature}.entity.ts |
| Config fn | {name}.config.ts |
| DTO | {action}-{feature}.request.dto.ts / {feature}.response.dto.ts |
| Util | {name}.util.ts |
| Constant | {name}.constant.ts |
Classes
Modules, Services, and Resolvers must be domain-prefixed:
// ✓ Domain-prefixed — prevents naming conflicts across domains
export class ClientUserModule {}
export class ClientUserService {}
export class ClientUserResolver {}
// ✗ Not allowed — ambiguous across domains
export class UserService {}
- Entities: no domain prefix —
UserEntity,TaskEntity - DTOs: describe the action —
CreateUserRequestDto,UserResponseDto - Interfaces:
I-prefixed —ICreateUserInput - Enums: PascalCase —
UserRole,TaskStatus
Variables & Constants
- camelCase for variables and function parameters
- Exported constants use grouped
constobjects — not flat SCREAMING_SNAKE_CASE:
// ✓ Correct
export const APP_ENV = { LOCAL: 'local', STAGING: 'staging', RELEASE: 'release' }
// ✗ Avoid
export const APP_ENV_LOCAL = 'local'
Database
- Table names: plural snake_case —
task_recurrence_rules,workspace_members - Column names: snake_case (handled automatically by
SnakeNamingStrategy)
Code Style (ESLint + Prettier)
| Setting | Value |
|---|---|
| Semicolons | false |
| Quotes | single |
| Trailing commas | all |
| Print width | 100 |
| Tab width | 4 spaces |
| Arrow parens | always |
Key ESLint rules:
no-duplicate-imports: error— merge all imports from the same moduleobject-shorthand: always— use{ foo }not{ foo: foo }max-depth: 4— maximum nesting depth of 4unused-imports/no-unused-imports: error— prefix unused variables with_@typescript-eslint/no-floating-promises: warn— alwaysawaitor.catch()promises
Module Scope & Boundaries
- Each domain (
client,shared) owns its own NestJS module tree. Do not import a feature module from another domain directly. - Cross-domain shared logic lives in
src/modules/shared/and is exported fromSharedModule. - Infrastructure (Redis, BullMQ) is provided via
src/providers/and imported into the domain module that needs it. - Direct repository access is not allowed in resolvers — use the feature service.
- Config factory functions must not be called inline inside module files — pass them via
useFactory.
TypeORM Entity Auto-Discovery
PostgresDatasourceModule discovers all *.entity.ts files via a glob pattern automatically. Do NOT use TypeOrmModule.forFeature([...]) to register entities manually.
- When creating a new entity, do not modify module imports.
- Use
@InjectRepository(EntityClass)in services to inject repositories.
GraphQL Resolver Naming
All @Query and @Mutation names must use action verbs — never bare nouns:
| Operation | Verb | Example |
|---|---|---|
| Fetch list | getAll |
getAllUsers, getAllTasks |
| Fetch one | get |
getUser, getTask |
| Create | create |
createUser, createTask |
| Update | update |
updateUser, updateTask |
| Delete | delete |
deleteUser, deleteTask |
// ✓ Correct
@Query(() => [UserResponseDto])
async getAllUsers(): Promise<UserResponseDto[]> { ... }
// ✗ Not allowed — bare noun
@Query(() => [UserResponseDto])
async users(): Promise<UserResponseDto[]> { ... }
Step-by-Step: Create a New Feature Module
Example: adding devices to the client domain.
1. Create folder structure:
src/modules/client/devices/
devices.module.ts
devices.resolver.ts
devices.service.ts
interfaces/
index.ts
device.interface.ts
dtos/
requests/index.ts
responses/index.ts
2. Scaffold module, service, resolver:
// devices.module.ts
@Module({ providers: [ClientDevicesResolver, ClientDevicesService] })
export class ClientDevicesModule {}
// devices.service.ts
@Injectable()
export class ClientDevicesService {}
// devices.resolver.ts
@Resolver()
export class ClientDevicesResolver {
constructor(private readonly devicesService: ClientDevicesService) {}
}
3. Register in domain root module:
// client.module.ts
@Module({ imports: [ClientUserModule, ClientDevicesModule] })
export class ClientModule {}
4. Create entity (if DB table needed):
- File:
src/databases/postgres/entities/device/device.entity.ts - No module registration needed — auto-discovered via glob.
5. Generate and run migration:
pnpm migration:generate CreateDevicesTable
pnpm migration:run
6. Add barrel exports to every interfaces/index.ts, dtos/requests/index.ts, dtos/responses/index.ts.
Step-by-Step: Add Config or Provider
New config section (e.g., caching):
- Create
src/configs/cache.config.tsexporting a factory function - Re-export from
src/configs/index.ts - Add raw env values to
src/configs/app.config.tswith sensible defaults — never let values beundefined
New infrastructure provider (e.g., queue):
- Create
src/providers/<name>/index.tsexporting aDynamicModuleor provider array - Import it into the domain module that needs it — not into
AppModule - Never instantiate Redis/BullMQ clients directly in feature services
TypeScript Conventions
Strict Mode
- No implicit
any— every parameter and return value must be typed when TypeScript cannot infer precisely - No non-null assertion (
!) — use guard clauses or optional chaining instead ascasts — allowed only when TypeScript cannot infer the type; add an inline comment explaining why- Floating promises — always
awaitor.catch()async calls
// ✗ Unsafe
const name = user!.name
// ✓ Safe guard
if (!user) throw new Error('User not found')
const name = user.name
// ✓ Justified cast — AuthGuard validates payload shape upstream
const userId = (req.user as JwtPayload).sub
Naming
| Kind | Convention | Example |
|---|---|---|
| Interface | I prefix, PascalCase |
ICreateUserInput |
| Type alias | PascalCase, no prefix | UserSortField |
| Enum | PascalCase | UserRole |
| Enum member | UPPER_SNAKE_CASE | UserRole.SUPER_ADMIN |
| Generic param | Single uppercase or short descriptive | T, TValue, TEntity |
| Private class field | _ prefix |
_cacheKey |
| Boolean variable | is / has / can prefix |
isActive, hasPermission |
interface vs type
Use interface for object shapes passed as inputs (especially 4+ fields) and anything that may be extended.
Use type for unions, intersections, conditional types, and utility-derived types.
// ✓ interface — service input contract
export interface ICreateDeviceInput {
name: string
macAddress: string
networkId: string
notes?: string
}
// ✓ type — union
type SortDirection = 'ASC' | 'DESC'
Enums
Always use string enums — numeric enums are fragile when serialized:
export enum DeviceStatus {
ONLINE = 'ONLINE',
OFFLINE = 'OFFLINE',
SUSPICIOUS = 'SUSPICIOUS',
}
Place shared enums in src/commons/enums/; feature-local enums in the feature's interfaces/ file.
Function Parameters and Return Types
- ≤3 parameters: positional arguments are fine
-
3 parameters: use a single named input object typed via an interface
- Always declare explicit return types on public class methods
- All async methods must explicitly declare
Promise<T>— no inferred wrappers - "Not found" returns use
T | null, notT | undefined
// ✓
async findById(id: string): Promise<UserEntity | null> { ... }
async createDevice(input: ICreateDeviceInput): Promise<DeviceEntity> { ... }
Migration Conventions
For the complete reference with SQL format examples, see
backend/.github/instructions/migration.instructions.md.
File Naming
Pattern: {timestamp}-{kebab-case-description}.ts
1777461999633-create-uuid-extension.ts
1777738982218-create-users-table.ts
Generate: pnpm migration:generate <PascalCaseName>
Create stub: pnpm migration:create <PascalCaseName>
A migration file must never be renamed or edited after being applied to any environment.
Class Format
import { MigrationInterface, QueryRunner } from 'typeorm'
export class CreateDevicesTable1234567890123 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`-- SQL statement`)
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`-- reversal SQL`)
}
}
- Only import:
{ MigrationInterface, QueryRunner }from'typeorm'— no project imports - Each
queryRunner.query()must contain exactly one SQL statement
Standard Columns
Every new table must include, in this order: id first, then business columns, then created_at and updated_at last.
| Column | Type | Nullability | Default |
|---|---|---|---|
id |
uuid |
NOT NULL |
uuid_generate_v4() |
created_at |
timestamp with time zone |
NULL |
— |
updated_at |
timestamp with time zone |
NULL |
— |
The uuid-ossp extension is already enabled — do not create it again.
Constraint Naming
| Type | Pattern | Example |
|---|---|---|
| Primary key | PK_{table} |
PK_devices |
| Foreign key | FK_{table}_{referenced_table} |
FK_devices_networks |
| Unique | UQ_{table}_{column} |
UQ_devices_mac_address |
| Index | IDX_{table}_{column} |
IDX_devices_network_id |
Always use the full table name — never abbreviations.
Foreign Key Actions
| Scenario | Action |
|---|---|
| Child has no meaning without parent | ON DELETE CASCADE |
| Reference optional, row preserved | ON DELETE SET NULL |
| Parent must not be deleted while child exists | ON DELETE RESTRICT |
ON DELETE must always be declared explicitly. Omit ON UPDATE for UUID primary keys.
General Authoring Rules
- One migration = one logical change (one table, one index group, one column addition)
- Migrations contain raw SQL only — never import TypeORM entity or repository methods
synchronize: truemust never be used as a substitute for migrationsdown()must fully and precisely reverse everything inup()- Use
IF NOT EXISTSonCREATE TABLE,DROP TABLE,CREATE INDEX,DROP INDEX - Do not use
IF NOT EXISTSonALTER TABLE ADD COLUMN— failure there indicates a real inconsistency