Web Backend Development on Node.js (Express)
Express remains pragmatic choice for web backend: minimal magic, predictable behavior, huge middleware ecosystem. Not fastest framework (Fastify faster), not most feature-rich (NestJS richer), but combination of simplicity and flexibility makes it working tool for most tasks.
Project Structure
Flat "all files in routes/" works until certain scale. With growth need explicit layered architecture:
src/
├── config/
│ ├── env.ts
│ └── database.ts
├── modules/
│ ├── users/
│ │ ├── users.router.ts
│ │ ├── users.service.ts
│ │ ├── users.repository.ts
│ │ ├── users.schema.ts
│ │ └── users.types.ts
│ ├── products/
│ └── orders/
├── middleware/
│ ├── auth.ts
│ ├── errorHandler.ts
│ ├── requestLogger.ts
│ └── rateLimit.ts
├── lib/
│ ├── database.ts
│ ├── redis.ts
│ ├── mailer.ts
│ └── queue.ts
└── app.ts
App Setup
// src/app.ts
import express from 'express';
import helmet from 'helmet';
import cors from 'cors';
import { pinoHttp } from 'pino-http';
import { usersRouter } from './modules/users/users.router';
export function createApp() {
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.ALLOWED_ORIGINS.split(','),
credentials: true,
}));
// Parsing
app.use(express.json({ limit: '1mb' }));
// Logging
app.use(pinoHttp({ logger }));
// Routes
app.use('/api/users', usersRouter);
// Error handling—must be last
app.use(errorHandler);
return app;
}
Environment Config
// src/config/env.ts
import { z } from 'zod';
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
});
export const env = envSchema.parse(process.env);
Fail on startup with explicit error better than cryptic behavior at runtime.
Router → Service → Repository Pattern
// modules/products/products.router.ts
import { Router } from 'express';
import { ProductsService } from './products.service';
import { createProductSchema } from './products.schema';
const service = new ProductsService();
export const productsRouter = Router();
productsRouter.get('/', async (req, res, next) => {
try {
const result = await service.list(req.query);
res.json(result);
} catch (err) { next(err); }
});
productsRouter.post('/', async (req, res, next) => {
try {
const product = await service.create(req.body);
res.status(201).json(product);
} catch (err) { next(err); }
});
// modules/products/products.service.ts
import { ProductsRepository } from './products.repository';
import { redis } from '../../lib/redis';
export class ProductsService {
private repo = new ProductsRepository();
async list(query: any) {
const cacheKey = `products:list:${JSON.stringify(query)}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const result = await this.repo.findMany(query);
await redis.set(cacheKey, JSON.stringify(result), 'EX', 300);
return result;
}
async create(data: any) {
const product = await this.repo.create(data);
// Invalidate list cache
const keys = await redis.keys('products:list:*');
if (keys.length > 0) await redis.del(keys);
return product;
}
}
Middleware: Validation, Auth, Errors
// middleware/validate.ts
export function validate(schemas: { body?: ZodSchema; query?: ZodSchema }) {
return (req: Request, res: Response, next: NextFunction) => {
try {
if (schemas.body) req.body = schemas.body.parse(req.body);
if (schemas.query) req.query = schemas.query.parse(req.query);
next();
} catch (err) {
return res.status(422).json({
error: 'Validation failed',
details: err.flatten().fieldErrors,
});
}
};
}
// middleware/errorHandler.ts
export function errorHandler(err: Error, _req: Request, res: Response) {
logger.error({ err }, 'Unhandled error');
if (err instanceof AppError) {
return res.status(err.statusCode).json({ error: err.message });
}
const status = 500;
const message = process.env.NODE_ENV === 'production'
? 'Internal server error'
: err.message;
res.status(status).json({ error: message });
}
JWT Authentication
// middleware/auth.ts
export function authenticate(req: Request, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Unauthorized' });
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, env.JWT_SECRET) as JwtPayload;
req.user = { id: payload.sub, role: payload.role };
next();
} catch (err) {
res.status(401).json({ error: 'Invalid token' });
}
}
Database Layer
// lib/database.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export { prisma };
Running the Server
// src/server.ts
import { createApp } from './app';
import { env } from './config/env';
const app = createApp();
app.listen(env.PORT, () => {
console.log(`Server running on port ${env.PORT}`);
});
Timeline
Basic setup: routes, services, middleware—1 day. Database integration (Prisma), authentication, CRUD endpoints—2–3 days. Testing, documentation—1 day.







