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 |