Skip to content

Backend

Our backend stack is built on NestJS with Node.js, supporting both REST and GraphQL APIs. We emphasize modular architecture, type safety, and testability.

Architecture Overview

Core Technologies

NestJS

NestJS is our primary backend framework. It provides a structured, opinionated architecture with first-class TypeScript support, dependency injection, and a modular design that scales from small APIs to complex applications.

Why NestJS:

  • Modular architecture — Encourages separation of concerns and code reuse across projects
  • Dependency injection — Built-in IoC container for clean, testable service composition
  • Protocol agnostic — Same codebase can serve REST, GraphQL, WebSockets, and gRPC
  • Rich ecosystem — First-party packages for auth, config, caching, queues, scheduling, and more
  • TypeScript native — Designed from the ground up for TypeScript, not retrofitted
  • Enterprise-grade — Battle-tested patterns for validation, error handling, logging, and security

Trade-offs to consider:

  • NestJS adds abstraction overhead — for simple APIs, this can mean higher development cost for clients
  • The learning curve is steeper compared to plain Express/Fastify
  • Some client budgets or project scopes don't justify the full NestJS setup

Choosing a Backend Framework

NestJS is the default, not a mandate. For projects where NestJS is overkill, consider lighter alternatives:

Framework When to use
NestJS Medium-to-large projects, complex business logic, multiple modules, long-term maintenance
Express / Fastify Small APIs, microservices, tight budgets, simple CRUD

The decision should be made at project kickoff based on scope, budget, and expected complexity. Document the choice in the project README.

Request Lifecycle

Every incoming request passes through a well-defined pipeline of NestJS components:

Each layer has a clear responsibility:

Layer Responsibility Example
Middleware Request preprocessing CORS, body parsing, request logging
Guards Authentication & authorization JWT validation, role checks
Interceptors Cross-cutting concerns Response mapping, caching, timing
Pipes Data validation & transformation DTO validation, type coercion
Controllers/Resolvers Route handling Map HTTP/GraphQL requests to services
Services Business logic Core application functionality
Filters Error handling Standardized error responses

Module Structure

Every NestJS project follows a modular architecture:

src/
├── app.module.ts           # Root module
├── shared/                 # Shared utilities, guards, interceptors
│   ├── guards/
│   ├── interceptors/
│   ├── filters/
│   └── decorators/
├── config/                 # Configuration and validation
├── auth/                   # Authentication module
│   ├── auth.module.ts
│   ├── auth.service.ts
│   ├── auth.controller.ts
│   └── strategies/
├── users/                  # Feature module
│   ├── users.module.ts
│   ├── users.service.ts
│   ├── users.controller.ts (REST) or users.resolver.ts (GraphQL)
│   ├── dto/
│   └── entities/
└── ...

Guidelines:

  • One module per domain entity or feature area
  • Modules expose only what's needed via exports
  • Shared logic goes in shared/
  • Configuration is validated at startup using @nestjs/config with Zod or class-validator

API Patterns

REST

For standard CRUD operations and simple APIs, we use REST controllers:

@Controller('users')
export class UsersController {
    constructor(private readonly usersService: UsersService) {}

    @Get()
    findAll(@Query() query: PaginationDto): Promise<PaginatedResponse<UserResponseDto>> {
        return this.usersService.findAll(query)
    }

    @Get(':id')
    findOne(@Param('id', ParseUUIDPipe) id: string): Promise<UserResponseDto> {
        return this.usersService.findOne(id)
    }

    @Post()
    create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
        return this.usersService.create(dto)
    }
}

GraphQL

For complex data requirements or when the frontend needs flexible queries, we use GraphQL with the code-first approach:

@Resolver(() => UserType)
export class UsersResolver {
    constructor(private readonly usersService: UsersService) {}

    @Query(() => [UserType])
    async users(): Promise<UserType[]> {
        return this.usersService.findAll()
    }

    @Mutation(() => UserType)
    async userCreate(@Args('input') input: UserCreateInput): Promise<UserType> {
        return this.usersService.create(input)
    }

    @ResolveField(() => [ProjectType])
    async projects(@Parent() user: UserType): Promise<ProjectType[]> {
        return this.usersService.getProjects(user.id)
    }
}

Choosing an API Style

REST is the default. GraphQL adds complexity (schema management, N+1 queries, caching challenges) and should only be used when its benefits clearly outweigh the cost.

Use REST when... Use GraphQL when...
Simple CRUD operations Multiple clients with diverging field requirements
Public APIs for third parties Frontend needs flexible queries across many entities per request
File uploads / downloads Real-time subscriptions needed
Webhooks, callbacks, and scheduled jobs (external scheduler hits an API-key-protected endpoint) Schema-driven typed clients are a hard requirement
Most projects (default) Only when justified by a concrete need above

GraphQL Considerations

GraphQL is powerful but adds operational cost: schema management, query complexity limits, caching strategies, and tooling overhead. "Complex data" alone is not a strong enough signal — in practice, frontends often end up consuming GraphQL the same way they would REST (one query per screen, fixed shape), at which point you have all the GraphQL costs without the benefits. Adopt it only when there's a concrete problem REST can't solve well (multiple clients with diverging field needs, real-time subscriptions, deep partial fetching) and you have a plan for breaking changes. We don't currently have a robust schema versioning story beyond suffixing types and operations (UserTypeV2, userCreateV2), which is another reason to be conservative.

Validation

All incoming data is validated using DTOs with class-validator:

// dto/create-user.dto.ts
export class CreateUserDto {
    @IsEmail()
    email: string

    @IsString()
    @MinLength(2)
    @MaxLength(100)
    name: string

    @IsEnum(UserRole)
    role: UserRole
}

Nested data with class-validator

class-validator does not recursively validate nested objects or arrays of objects by default — and the failure mode is silent. To validate a nested DTO you need both @ValidateNested() (from class-validator) and @Type(() => NestedDto) (from class-transformer). Forget either one and the nested payload passes through unchecked, even with ValidationPipe enabled.

export class CreateOrderDto {
    @IsString()
    customerId: string

    @ValidateNested({ each: true })
    @Type(() => OrderItemDto)
    items: OrderItemDto[]
}

export class OrderItemDto {
    @IsUUID()
    productId: string

    @IsInt()
    @Min(1)
    quantity: number
}

Always configure ValidationPipe with whitelist: true and forbidNonWhitelisted: true so unknown fields are rejected at the API boundary rather than silently ignored. Discriminated unions and generic types are also poorly supported — if your payloads need them, consider Zod for that DTO instead.

Architecture: Monolith vs. Services

Start with a modular monolith. Only split into dedicated services when there is a clear, justified need.

Approach When to use
Modular monolith Most projects. Single deployable, modules separated by NestJS module boundaries. Simpler ops, easier debugging.
Dedicated services When parts of the system have fundamentally different scaling, deployment, or team ownership needs.

Signs you need to split:

  • One part of the system needs independent scaling (e.g. heavy background processing)
  • Different release cadences for different parts
  • Separate teams owning separate domains
  • A module's failure should not bring down the rest

Rules for services:

  • Each service owns its data — no shared databases
  • Services communicate via REST APIs or message queues, not direct DB access
  • Every service must be independently deployable and have its own CI/CD pipeline
  • Document service boundaries and communication contracts

Node.js Runtime

  • We use the LTS version of Node.js specified in each project's .nvmrc
  • Package manager: yarn (primary across all projects); a transition to pnpm is currently being explored on selected projects
  • All projects include engines field in package.json to enforce Node.js version