Skip to content

Backend Guidelines

  • NestJS
  • TypeScript
  • MikroORM
  • Zod
  • Swagger/OpenAPI
  • Better Auth

Each feature should be organized as a NestJS module with the following structure:

modules/feature-name/
├── feature-name.module.ts # Module definition
├── feature-name.controller.ts # HTTP endpoints
├── feature-name.service.ts # Business logic
├── feature-name.entity.ts # If there is a single entity, a single entity file is fine
├── feature-name.contract.ts # If there is a single entity, a single contract is fine
└── contracts/ # DTOs and validation schemas
└── feature-name.contract.ts # If multiple contracts are needed, create a subfolder to store them together
└── entities/ # Database entities
└── feature-name.entity.ts # If multiple entities are needed, create a subfolder to store them together
  • Files: Use kebab-case for filenames (e.g., user-profile.service.ts)
  • Classes: Use PascalCase for class names (e.g., UserProfileService)
  • Methods: Use camelCase for method names (e.g., getUserProfile)
  • Variables: Use camelCase for variable names (e.g., userProfile)
  • Constants: Use UPPER_SNAKE_CASE for constants (e.g., MAX_USERS)
  • Interfaces/Types: Use PascalCase prefixed with ‘I’ for interfaces (e.g., IUserProfile)
  • Enums: Use PascalCase for enum names (e.g., UserRole)

To create a new module, you can use the following command:

Terminal window
pnpm generate:module --name=module-name

It’s generated with the following files:

  • __name__.controller.ts
  • __name__.service.ts
  • __name__.entity.ts
  • __name__.module.ts
  • contracts/__name__.contract.ts
  • tests/__name__.controller.spec.ts

If you want to create a new module from scratch without the generator, you should follow the following steps (posts module is provided as an example because it’s present in the boilerplate, adapt as needed)

  1. Create an entity, using posts.entity.ts (or another existing entity file if not found) as reference;
  2. Create a contract, using posts.contract.ts (or another existing contract file if not found) as reference. a. The contract file should contain the schema for all CRUD actions (GET/POST/PUT/DELETE); b. If related contracts are needed (relations), create the related contract files first; c. When creating a contract, always start from the GET contract (nameOfEntitySchema) and then create the create and update contracts by extending the initial schema (you can use zod pick);
  3. Create a service, using posts.service.ts (or another existing service file if not found) as reference;
  4. Create a controller, using posts.controller.ts (or another existing controller file if not found) as reference;
  5. Create a module, using posts.module.ts (or another existing module file if not found) as reference;
  • Use decorators from NestJS for route definition
  • Group related endpoints under the same controller
  • Use versioning when making breaking changes
  • Use proper HTTP methods:
    • GET for retrieving data
    • POST for creating resources
    • PUT for full updates
    • PATCH for partial updates
    • DELETE for removing resources
  • Use Zod schemas for request validation
  • Define schemas in the contracts directory
  • Use TypedBody, TypedParam, and other typed decorators from @lonestone/nzoth/server
  • Export types from schemas using z.infer<typeof schema>
  • Use .meta() to add OpenAPI documentation metadata to schemas

Why use .meta()?

  • .meta() adds OpenAPI/Swagger documentation metadata to your Zod schemas
  • This metadata is automatically used by @lonestone/nzoth/server to generate API documentation
  • It provides better developer experience with auto-generated OpenAPI specs
  • The metadata includes title, description, examples, and other OpenAPI-specific information

Schema Structure:

  • Start with basic validation rules (type, length, format, etc.)
  • Add .meta() for documentation purposes
  • Export the inferred type for TypeScript usage
  • Use descriptive names that indicate the purpose (e.g., createUserSchema, updateUserSchema)

Example:

import { z } from 'zod'
// Basic schema with validation rules
export const createUserSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email format'),
age: z.number().min(18, 'User must be at least 18 years old').optional(),
}).meta({
title: 'Create User',
description: 'Schema for creating a new user account',
examples: [
{
name: 'John Doe',
email: 'john.doe@example.com',
age: 25
}
]
})
// Export the inferred type for TypeScript usage
export type CreateUserInput = z.infer<typeof createUserSchema>
// Controller usage
@TypedController('user')
export class UserController {
@TypedRoute.Post('', createUserSchema)
async createUser(@TypedBody(createUserSchema) body: CreateUserInput) {
// Implementation
}
}

Create vs Update Schemas:

// Base schema for common fields
const userBaseSchema = z.object({
name: z.string().min(2),
email: z.string().email(),
})
// Create schema (all fields required)
export const createUserSchema = userBaseSchema.meta({
title: 'Create User',
description: 'Create a new user account'
})
// Update schema (all fields optional)
export const updateUserSchema = userBaseSchema.partial().meta({
title: 'Update User',
description: 'Update an existing user account'
})

Response Schemas:

export const userResponseSchema = z.object({
id: z.string().uuid(),
name: z.string(),
email: z.string().email(),
createdAt: z.date(),
updatedAt: z.date(),
}).meta({
title: 'User Response',
description: 'User data returned by the API'
})
  • Use consistent response formats
  • Return typed responses using Zod schemas
  • Document responses with OpenAPI annotations
  • Use NestJS exceptions for error handling
  • Return appropriate HTTP status codes
  • Provide meaningful error messages
  • Use exception filters for global error handling
  • Use MikroORM decorators for entity definition
  • Follow single responsibility principle
  • Use UUIDs for primary keys
  • Include audit fields (createdAt, updatedAt)
  • Define proper indexes for performance
  • Use appropriate relationships (OneToMany, ManyToOne, etc.)

Example:

@Entity({ tableName: 'user' })
export class User {
@PrimaryKey({ type: 'uuid', defaultRaw: 'gen_random_uuid()' })
id!: string
@Property()
@Index()
name!: string
@Property({ fieldName: 'createdAt' })
createdAt: Date = new Date()
@Property({ fieldName: 'updatedAt', onUpdate: () => new Date() })
updatedAt: Date = new Date()
}
  • Use the EntityManager for database operations
  • Use transactions for operations that modify multiple entities. MikroORM wraps each operation in a transaction by default, if they come from a controller.
  • Ensure you have a MikroORM Context. Mikro provides one for each request by default, but if you call a method from a CRON you’ll need to create one or use the EnsureRequestContext decorator.
  • Optimize queries for performance
  • Use pagination for large result sets
  • Use Better Auth for authentication
  • Use guards for protecting routes
  • Use decorators for role-based access control
  • Validate user permissions in services

Example:

@Controller('admin/users')
@UseGuards(AuthGuard)
export class UserController {
// Protected endpoints
}
  • Use Swagger/OpenAPI for API documentation
  • Document all endpoints, parameters, and responses
  • Use tags to group related endpoints
  • Provide examples for request and response bodies
  • Write unit tests for services
  • Write integration tests for controllers
  • Use Jest for testing
  • Mock external dependencies