How to Build a REST API in Node.js That Won't Embarrass You in a Code Review

You followed a tutorial. You have 15 route handlers in a single server.js file. Each one has a try/catch block that returns either { success: true, data } or { error: error.message }. There's no input validation. The database queries live inside the route handlers. Environment variables are accessed with process.env.DB_URL in six different files.

It works. And it will absolutely fail you in a code review, a technical interview, or the first week of production traffic.

I've reviewed dozens of Node.js APIs from junior and mid-level developers. The same structural problems show up in almost every one. Here's what they are and exactly how to fix them.

The Problem: Tutorial Architecture

Most tutorials produce something like this:

// ❌ The classic "everything in one handler" pattern
app.post('/api/users', async (req, res) => {
  try {
    const { name, email, password } = req.body;
    
    // Validation in the handler
    if (!name || !email || !password) {
      return res.status(400).json({ error: 'Missing fields' });
    }
    if (!email.includes('@')) {
      return res.status(400).json({ error: 'Invalid email' });
    }
 
    // Business logic in the handler
    const existingUser = await db.query('SELECT * FROM users WHERE email = ?', [email]);
    if (existingUser.length > 0) {
      return res.status(409).json({ error: 'Email already taken' });
    }
 
    // Password hashing in the handler
    const hashedPassword = await bcrypt.hash(password, 10);
 
    // Database query in the handler
    const result = await db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
      [name, email, hashedPassword]
    );
 
    // Token generation in the handler
    const token = jwt.sign({ id: result.insertId }, process.env.JWT_SECRET);
 
    res.status(201).json({ user: { id: result.insertId, name, email }, token });
  } catch (error) {
    console.log(error); // console.log in production 🤦
    res.status(500).json({ error: 'Something went wrong' });
  }
});

This is 35 lines for one endpoint. Now multiply by 30 endpoints. You get a 1000-line file that nobody can maintain, test, or debug.

The Structure That Works

Here's how production APIs are organized. Every professional Node.js codebase I've worked on follows some variant of this:

src/
├── config/
│   └── env.ts              # Environment variables, validated once
├── middleware/
│   ├── errorHandler.ts     # Global error handler
│   ├── validate.ts         # Request validation middleware
│   └── auth.ts             # Authentication middleware
├── modules/
│   └── user/
│       ├── user.routes.ts  # Route definitions only
│       ├── user.controller.ts  # Request/response handling
│       ├── user.service.ts     # Business logic
│       ├── user.repository.ts  # Database queries
│       ├── user.schema.ts      # Validation schemas
│       └── user.types.ts       # TypeScript interfaces
├── utils/
│   ├── ApiError.ts         # Custom error class
│   └── asyncHandler.ts     # Async error wrapper
├── app.ts                  # Express app setup
└── server.ts               # Server startup

Each module (user, product, order) is self-contained. Let me build each layer.

Layer 1: Configuration — Validate Environment Variables

Stop scattering process.env calls everywhere. Validate once at startup and export typed values:

// src/config/env.ts
import { z } from 'zod';
 
const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  PORT: z.coerce.number().default(3000),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32),
  SMTP_HOST: z.string().optional(),
  SMTP_PORT: z.coerce.number().optional(),
});
 
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('❌ Invalid environment variables:');
  console.error(parsed.error.flatten().fieldErrors);
  process.exit(1); // Crash immediately — don't run with bad config
}
 
export const env = parsed.data;

Now every file imports env.JWT_SECRET instead of process.env.JWT_SECRET. If someone deploys without setting DATABASE_URL, the app crashes at startup with a clear error instead of failing mysteriously at runtime.

Layer 2: Error Handling — Custom Errors + Global Handler

Create a custom error class that carries HTTP status codes:

// src/utils/ApiError.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    public message: string,
    public details?: Record<string, unknown>
  ) {
    super(message);
    this.name = 'ApiError';
  }
 
  static badRequest(message: string, details?: Record<string, unknown>) {
    return new ApiError(400, message, details);
  }
 
  static unauthorized(message = 'Unauthorized') {
    return new ApiError(401, message);
  }
 
  static forbidden(message = 'Forbidden') {
    return new ApiError(403, message);
  }
 
  static notFound(message = 'Resource not found') {
    return new ApiError(404, message);
  }
 
  static conflict(message: string) {
    return new ApiError(409, message);
  }
}

Create a wrapper that eliminates try/catch from every handler:

// src/utils/asyncHandler.ts
import { Request, Response, NextFunction } from 'express';
 
export const asyncHandler = (
  fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
) => {
  return (req: Request, res: Response, next: NextFunction) => {
    fn(req, res, next).catch(next); // Forward errors to global handler
  };
};

Build the global error handler:

// src/middleware/errorHandler.ts
import { Request, Response, NextFunction } from 'express';
import { ApiError } from '../utils/ApiError';
import { env } from '../config/env';
 
export function errorHandler(
  err: Error,
  _req: Request,
  res: Response,
  _next: NextFunction
) {
  // Known operational errors
  if (err instanceof ApiError) {
    res.status(err.statusCode).json({
      error: err.message,
      ...(err.details && { details: err.details }),
    });
    return;
  }
 
  // Zod validation errors
  if (err.name === 'ZodError') {
    res.status(400).json({
      error: 'Validation failed',
      details: (err as any).flatten?.().fieldErrors,
    });
    return;
  }
 
  // Unknown errors — don't leak internals
  console.error('Unhandled error:', err);
  res.status(500).json({
    error: env.NODE_ENV === 'production'
      ? 'Internal server error'
      : err.message,
  });
}

With this setup, you never write try/catch in a route handler again. Throw ApiError.notFound() from anywhere in the call stack, and it bubbles up to the global handler automatically.

Layer 3: Validation — Zod Schemas + Middleware

Define what valid input looks like with Zod:

// src/modules/user/user.schema.ts
import { z } from 'zod';
 
export const createUserSchema = z.object({
  body: z.object({
    name: z.string().min(2).max(100),
    email: z.string().email(),
    password: z.string().min(8).max(128),
  }),
});
 
export const getUserSchema = z.object({
  params: z.object({
    id: z.coerce.number().int().positive(),
  }),
});
 
export const updateUserSchema = z.object({
  params: z.object({
    id: z.coerce.number().int().positive(),
  }),
  body: z.object({
    name: z.string().min(2).max(100).optional(),
    email: z.string().email().optional(),
  }).refine(data => Object.keys(data).length > 0, {
    message: 'At least one field must be provided',
  }),
});

Build a validation middleware that works with any schema:

// src/middleware/validate.ts
import { Request, Response, NextFunction } from 'express';
import { AnyZodObject } from 'zod';
 
export const validate = (schema: AnyZodObject) => {
  return (req: Request, _res: Response, next: NextFunction) => {
    schema.parse({
      body: req.body,
      query: req.query,
      params: req.params,
    });
    next(); // If parse succeeds, continue
    // If it throws, asyncHandler + errorHandler catches it
  };
};

Layer 4: Repository — Database Queries Only

The repository layer does ONE thing: talk to the database. No business logic, no HTTP concerns.

// src/modules/user/user.repository.ts
import { db } from '../../config/database';
import { User, CreateUserData } from './user.types';
 
export const userRepository = {
  async findById(id: number): Promise<User | null> {
    const [rows] = await db.query<User[]>(
      'SELECT id, name, email, created_at FROM users WHERE id = ?',
      [id]
    );
    return rows[0] ?? null;
  },
 
  async findByEmail(email: string): Promise<User | null> {
    const [rows] = await db.query<User[]>(
      'SELECT * FROM users WHERE email = ?',
      [email]
    );
    return rows[0] ?? null;
  },
 
  async create(data: CreateUserData): Promise<User> {
    const [result] = await db.query(
      'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
      [data.name, data.email, data.hashedPassword]
    );
    return { id: (result as any).insertId, name: data.name, email: data.email };
  },
 
  async update(id: number, data: Partial<Pick<User, 'name' | 'email'>>): Promise<void> {
    const fields = Object.entries(data)
      .filter(([, v]) => v !== undefined)
      .map(([k]) => `${k} = ?`);
    const values = Object.values(data).filter(v => v !== undefined);
 
    if (fields.length === 0) return;
 
    await db.query(
      `UPDATE users SET ${fields.join(', ')} WHERE id = ?`,
      [...values, id]
    );
  },
};

Layer 5: Service — Business Logic

The service layer contains all decisions: Is this email taken? Hash the password. Generate the token. It calls the repository but knows nothing about HTTP.

// src/modules/user/user.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { userRepository } from './user.repository';
import { ApiError } from '../../utils/ApiError';
import { env } from '../../config/env';
 
export const userService = {
  async createUser(name: string, email: string, password: string) {
    // Business rule: email must be unique
    const existing = await userRepository.findByEmail(email);
    if (existing) {
      throw ApiError.conflict('An account with this email already exists');
    }
 
    // Business logic: hash password
    const hashedPassword = await bcrypt.hash(password, 12);
 
    // Persist
    const user = await userRepository.create({ name, email, hashedPassword });
 
    // Generate token
    const token = jwt.sign({ id: user.id }, env.JWT_SECRET, {
      expiresIn: '7d',
    });
 
    return { user, token };
  },
 
  async getUserById(id: number) {
    const user = await userRepository.findById(id);
    if (!user) {
      throw ApiError.notFound(`User with id ${id} not found`);
    }
    return user;
  },
 
  async updateUser(id: number, data: { name?: string; email?: string }) {
    // Verify user exists
    await this.getUserById(id);
 
    // Business rule: if changing email, check uniqueness
    if (data.email) {
      const existing = await userRepository.findByEmail(data.email);
      if (existing && existing.id !== id) {
        throw ApiError.conflict('Email is already in use');
      }
    }
 
    await userRepository.update(id, data);
    return this.getUserById(id);
  },
};

Layer 6: Controller — HTTP Glue

The controller translates HTTP to service calls and service results to HTTP responses. It's thin — a few lines per method.

// src/modules/user/user.controller.ts
import { Request, Response } from 'express';
import { userService } from './user.service';
 
export const userController = {
  async create(req: Request, res: Response) {
    const { name, email, password } = req.body;
    const result = await userService.createUser(name, email, password);
    res.status(201).json(result);
  },
 
  async getById(req: Request, res: Response) {
    const id = Number(req.params.id);
    const user = await userService.getUserById(id);
    res.json(user);
  },
 
  async update(req: Request, res: Response) {
    const id = Number(req.params.id);
    const user = await userService.updateUser(id, req.body);
    res.json(user);
  },
};

That's it. No try/catch. No validation. No database queries. Just "receive request, call service, send response."

Layer 7: Routes — Wiring It Together

// src/modules/user/user.routes.ts
import { Router } from 'express';
import { userController } from './user.controller';
import { asyncHandler } from '../../utils/asyncHandler';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth';
import { createUserSchema, getUserSchema, updateUserSchema } from './user.schema';
 
const router = Router();
 
router.post(
  '/',
  validate(createUserSchema),
  asyncHandler(userController.create)
);
 
router.get(
  '/:id',
  authenticate,
  validate(getUserSchema),
  asyncHandler(userController.getById)
);
 
router.patch(
  '/:id',
  authenticate,
  validate(updateUserSchema),
  asyncHandler(userController.update)
);
 
export default router;

Clean. Readable. Every route is one line of middleware chain. You can see at a glance what's validated, what's authenticated, and what handler runs.

Putting It All Together

// src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import userRoutes from './modules/user/user.routes';
import { errorHandler } from './middleware/errorHandler';
 
const app = express();
 
// Security & parsing
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10kb' })); // Limit body size
 
// Routes
app.use('/api/users', userRoutes);
// app.use('/api/products', productRoutes);
// app.use('/api/orders', orderRoutes);
 
// Health check
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
 
// Global error handler (must be last)
app.use(errorHandler);
 
export default app;

Before and After

Here's what changes when you adopt this structure:

| Concern | Tutorial Style | Production Style | |---------|---------------|-----------------| | Validation | if (!name) in handler | Zod schema + middleware | | Errors | try/catch everywhere | Global handler + ApiError | | Database | SQL in route handler | Repository layer | | Business logic | Mixed into handler | Service layer | | Config | process.env.X scattered | Validated env object | | Route files | 200 lines each | 10-15 lines each | | Testability | Impossible without HTTP | Each layer testable in isolation |

Testing Each Layer

The real payoff: every layer is independently testable.

// Test the service without HTTP
describe('userService.createUser', () => {
  it('throws conflict if email exists', async () => {
    // Mock repository
    jest.spyOn(userRepository, 'findByEmail').mockResolvedValue({ id: 1 } as User);
 
    await expect(
      userService.createUser('John', 'john@test.com', 'password123')
    ).rejects.toThrow(ApiError);
  });
 
  it('hashes password before storing', async () => {
    jest.spyOn(userRepository, 'findByEmail').mockResolvedValue(null);
    jest.spyOn(userRepository, 'create').mockImplementation(async (data) => {
      expect(data.hashedPassword).not.toBe('password123');
      return { id: 1, name: 'John', email: 'john@test.com' };
    });
 
    await userService.createUser('John', 'john@test.com', 'password123');
  });
});

No need to spin up Express, make HTTP requests, or set up a test database to verify business logic.

Key Takeaways

  • Separate concerns into layers. Routes → Controllers → Services → Repositories.
  • Validate environment variables at startup. Don't let bad config reach production.
  • Use Zod schemas for validation. One middleware, automatic error formatting.
  • Build a global error handler. Never write try/catch in a route handler.
  • Keep controllers thin. They should only translate between HTTP and service calls.
  • Put business logic in services. This is where decisions live — testable without HTTP.
  • Put SQL in repositories. Swap databases without touching business logic.

The difference between a junior and senior backend developer isn't knowing more frameworks — it's knowing how to structure code so that a team can maintain it. Get the layers right, and everything else falls into place.

Find me at: itszain.dev

Related Articles

How to Run Background Jobs in Node.js Without Setting Up Infrastructure

You need to send emails after signup, process images, or sync data — but you don't want RabbitMQ, Redis, or a queue service. Here's how to run reliable background jobs in Node.js using BullMQ, simple in-process queues, and serverless-friendly alternatives.

8 min read

Your Next.js API Routes Are Wide Open — How to Add Rate Limiting Before You Get Abused

Most Next.js apps deploy API routes with zero protection against abuse. Here's how to add production-grade rate limiting using in-memory stores, Redis, and middleware — before someone hammers your endpoints.

8 min read

How to Show Real File Upload Progress in Flutter (Not Fake Progress Bars)

Most Flutter upload tutorials fake the progress bar. Here's how to track actual byte-level upload progress using Dio's multipart requests and stream transformers — with a production-ready implementation.

5 min read