Skip to content

Node.js – Project Structure

A well-defined project structure makes the codebase navigable, enforces layer separation, and prevents the slow drift towards "all logic in the route handler" that plagues ungoverned Node.js projects.

Core Principle: Feature-Based Layers

All Cygnus Dynamics Node.js services are organised by feature domain, with a consistent three-layer architecture inside each feature: routes → controllers → services. Database access sits in a dedicated repository layer beneath services.

src/
├── features/
│   ├── orders/
│   │   ├── orders.routes.ts          # Express router — maps HTTP to controller methods
│   │   ├── orders.controller.ts      # HTTP layer — parse request, call service, send response
│   │   ├── orders.service.ts         # Business logic — orchestrates repositories and external calls
│   │   ├── orders.repository.ts      # Data access — all DB queries live here
│   │   ├── orders.validator.ts       # Zod schemas for request validation
│   │   ├── orders.types.ts           # TypeScript interfaces for this domain
│   │   └── orders.test.ts            # Unit tests (service layer)
│   │
│   ├── users/
│   │   ├── users.routes.ts
│   │   ├── users.controller.ts
│   │   ├── users.service.ts
│   │   ├── users.repository.ts
│   │   ├── users.validator.ts
│   │   └── users.types.ts
│   │
│   └── payments/
│       ├── payments.routes.ts
│       ├── payments.controller.ts
│       ├── payments.service.ts
│       ├── payments.validator.ts
│       └── payments.types.ts
├── middleware/
│   ├── auth.middleware.ts            # JWT validation
│   ├── errorHandler.middleware.ts    # Centralised error handler
│   ├── rateLimiter.middleware.ts     # Rate limiting
│   ├── requestLogger.middleware.ts   # HTTP request logging
│   └── validate.middleware.ts        # Zod validation middleware
├── config/
│   ├── index.ts                      # Validated, typed config from env vars
│   ├── database.ts                   # DB connection setup
│   └── logger.ts                     # Logger instance (Pino)
├── utils/
│   ├── asyncHandler.ts               # Wraps async controllers to catch errors
│   ├── pagination.ts                 # Pagination helpers
│   └── errors.ts                     # AppError class and domain error types
├── types/
│   └── express.d.ts                  # Express type augmentation (req.user, etc.)
├── app.ts                            # Express app setup — no HTTP server here
└── server.ts                         # Starts HTTP server — imports app.ts

Layer Responsibilities

Never skip layers. Each layer has one job — mixing responsibilities is the most common source of untestable, unmaintainable Node.js code.

Layer File Responsibility What it must NOT do
Routes *.routes.ts Mount middleware, map HTTP methods/paths to controllers Contain any business logic
Controller *.controller.ts Parse request, call service, format and send response Query the database directly
Service *.service.ts Business logic, orchestration, domain rules Know about HTTP (req/res)
Repository *.repository.ts All database queries — nothing else Contain business logic
Validator *.validator.ts Zod schemas for request bodies, params, queries Be imported by the service
// ✅ Correct — each layer doing exactly its job

// orders.routes.ts — routing only
router.post('/', authenticate, validate(createOrderSchema), ordersController.create)
router.get('/:id', authenticate, ordersController.getById)
router.delete('/:id', authenticate, ordersController.cancel)

// orders.controller.ts — HTTP layer only
export const create = asyncHandler(async (req: Request, res: Response) => {
  const order = await ordersService.createOrder(req.user!.id, req.body)
  res.status(201).json({ data: order })
})

// orders.service.ts — business logic only, no req/res
export const createOrder = async (
  userId: string,
  request: CreateOrderRequest
): Promise<Order> => {
  const user = await usersRepository.findById(userId)
  if (!user) throw new NotFoundError(`User not found: ${userId}`)

  await inventoryService.reserveItems(request.items)
  return ordersRepository.create({ userId, ...request })
}

// orders.repository.ts — DB access only
export const create = async (data: CreateOrderData): Promise<Order> => {
  return db.order.create({ data })
}

// ❌ Incorrect — business logic leaking into the controller
export const create = asyncHandler(async (req: Request, res: Response) => {
  const user = await db.user.findById(req.user.id)      // DB access in controller
  if (user.creditLimit < req.body.total) {               // business rule in controller
    return res.status(400).json({ error: 'Insufficient credit' })
  }
  const order = await db.order.create(req.body)
  await emailService.send(user.email, 'Order confirmed') // side effect in controller
  res.status(201).json(order)
})

app.ts vs server.ts

Always separate the Express app from the HTTP server. This is one of the most important structural decisions — it makes integration testing possible without binding to a port.

// ✅ app.ts — sets up Express, exports the app instance
import express from 'express'
import helmet from 'helmet'
import cors from 'cors'
import { createRateLimiter } from './middleware/rateLimiter.middleware'
import { requestLogger } from './middleware/requestLogger.middleware'
import { errorHandler } from './middleware/errorHandler.middleware'
import { ordersRouter } from './features/orders/orders.routes'
import { usersRouter } from './features/users/users.routes'
import { config } from './config'

const app = express()

// Security middleware — first
app.use(helmet())
app.use(cors({ origin: config.allowedOrigins, credentials: true }))
app.use(createRateLimiter())

// Request parsing
app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true }))

// Logging
app.use(requestLogger)

// Routes
app.use('/api/v1/orders', ordersRouter)
app.use('/api/v1/users', usersRouter)

// Health check — no auth required
app.get('/health', (_, res) => res.json({ status: 'ok', timestamp: new Date().toISOString() }))

// Catch-all for unknown routes
app.use((req, res) => {
  res.status(404).json({ error: `Route not found: ${req.method} ${req.path}` })
})

// Centralised error handler — must be last
app.use(errorHandler)

export { app }
// ✅ server.ts — starts the HTTP server, handles graceful shutdown
import { app } from './app'
import { config } from './config'
import { logger } from './config/logger'
import { db } from './config/database'

const server = app.listen(config.port, () => {
  logger.info({ port: config.port, env: config.nodeEnv }, 'Server started')
})

// Graceful shutdown
const shutdown = async (signal: string) => {
  logger.info({ signal }, 'Shutting down gracefully')
  server.close(async () => {
    await db.$disconnect()
    logger.info('Server closed')
    process.exit(0)
  })
  // Force exit after 10 seconds if graceful shutdown fails
  setTimeout(() => {
    logger.error('Forced shutdown after timeout')
    process.exit(1)
  }, 10_000)
}

process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))

process.on('unhandledRejection', (reason) => {
  logger.error({ reason }, 'Unhandled promise rejection')
  process.exit(1)
})

process.on('uncaughtException', (error) => {
  logger.error({ error }, 'Uncaught exception')
  process.exit(1)
})

Configuration Management

All configuration must come from environment variables — never hardcode values. Config must be validated at startup so the application fails immediately with a clear error if a required variable is missing.

// config/index.ts — typed, validated config using Zod
import { z } from 'zod'

const configSchema = z.object({
  nodeEnv:         z.enum(['development', 'test', 'production']),
  port:            z.coerce.number().int().min(1).max(65535).default(3000),
  databaseUrl:     z.string().url(),
  jwtSecret:       z.string().min(32),
  jwtExpiresIn:    z.string().default('15m'),
  allowedOrigins:  z.string().transform(s => s.split(',')),
  logLevel:        z.enum(['trace', 'debug', 'info', 'warn', 'error']).default('info'),
  redisUrl:        z.string().url().optional(),
})

const parsed = configSchema.safeParse(process.env)

if (!parsed.success) {
  console.error('Invalid environment configuration:')
  console.error(parsed.error.flatten().fieldErrors)
  process.exit(1)
}

export const config = parsed.data

// TypeScript type derived from the schema
export type Config = z.infer<typeof configSchema>
# .env.example — committed to the repo, never .env itself
NODE_ENV=development
PORT=3000
DATABASE_URL=postgresql://user:password@localhost:5432/cygnus_dev
JWT_SECRET=your-secret-here-minimum-32-characters
JWT_EXPIRES_IN=15m
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:3000
LOG_LEVEL=info

.env files must never be committed

Add .env to .gitignore immediately when creating a repository. Commit only .env.example with placeholder values. Use a secrets manager (AWS Secrets Manager, Doppler, or HashiCorp Vault) for production secrets.

TypeScript Configuration

All Cygnus Dynamics Node.js services use TypeScript with strict mode enabled.

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "lib": ["ES2022"],
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitOverride": true,
    "paths": { "@/*": ["./src/*"] }
  },
  "include": ["src"],
  "exclude": ["node_modules", "dist"]
}

Tooling Quick Reference

Tool Purpose Command
TypeScript Static typing tsc --noEmit
ESLint + @typescript-eslint Linting eslint src/
Prettier Formatting prettier --write src/
Vitest Unit tests vitest run
Pino Structured JSON logging — (configured in config/logger.ts)
Zod Runtime validation — (used in validators and config)
tsx Run TypeScript directly in dev tsx src/server.ts
npm audit Dependency vulnerability scan npm audit --audit-level=high