DEV Community

Cover image for Stop Writing Spaghetti API Routes: A Practical Guide to Clean Express.js Architecture
Teguh Coding
Teguh Coding

Posted on

Stop Writing Spaghetti API Routes: A Practical Guide to Clean Express.js Architecture

Most Node.js projects start clean. Then six months pass, your team adds features under pressure, and suddenly your routes/index.js file is 1,200 lines long. Nobody wants to touch it. Reviews take forever. Bugs hide in plain sight.

I have been there. And I want to show you exactly how to get out of it.

This guide walks through a practical, battle-tested architecture pattern for Express.js APIs that scales without turning into a maze. No over-engineering. No 15 abstraction layers. Just clean, readable, maintainable code.

The Problem with "Just Slap It in Routes"

Here is what a typical Express.js project looks like after a few months of fast iteration:

// routes/users.js - the horror
router.post('/register', async (req, res) => {
  const { email, password, name } = req.body;

  // validate inline
  if (!email || !email.includes('@')) {
    return res.status(400).json({ error: 'Invalid email' });
  }

  // hash password inline
  const hashed = await bcrypt.hash(password, 10);

  // query database inline
  const existing = await db.query('SELECT * FROM users WHERE email = ?', [email]);
  if (existing.length > 0) {
    return res.status(409).json({ error: 'User exists' });
  }

  const user = await db.query(
    'INSERT INTO users (email, password, name) VALUES (?, ?, ?)',
    [email, hashed, name]
  );

  // send email inline
  await transporter.sendMail({
    to: email,
    subject: 'Welcome!',
    text: `Hi ${name}, welcome aboard.`
  });

  const token = jwt.sign({ id: user.insertId }, process.env.JWT_SECRET);
  res.json({ token });
});
Enter fullscreen mode Exit fullscreen mode

This works. Until:

  • You need to reuse the registration logic in an admin endpoint
  • You want to unit test just the validation
  • A bug in email sending breaks registration entirely
  • A new dev needs to understand what this endpoint does

The Architecture That Actually Works

Here is the structure I have settled on after years of building Node.js backends:

src/
  routes/          # HTTP layer only — routing + request/response
  controllers/     # Coordinate the flow, call services
  services/        # Business logic, reusable across controllers
  repositories/    # Database access only
  middleware/      # Auth, validation, error handling
  validators/      # Input schemas (Joi, Zod, etc.)
  utils/           # Pure helper functions
Enter fullscreen mode Exit fullscreen mode

Each layer has one job. Let me show you how this looks in practice.

Layer 1: Routes — Just Map URLs to Controllers

// routes/users.js
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/usersController');
const { validateBody } = require('../middleware/validate');
const { registerSchema } = require('../validators/userValidators');
const { authenticate } = require('../middleware/auth');

router.post('/register', validateBody(registerSchema), usersController.register);
router.get('/me', authenticate, usersController.getProfile);
router.patch('/me', authenticate, validateBody(updateSchema), usersController.updateProfile);

module.exports = router;
Enter fullscreen mode Exit fullscreen mode

The route file is now a table of contents. You can scan it in seconds and understand every endpoint.

Layer 2: Controllers — Coordinate, Don't Compute

// controllers/usersController.js
const usersService = require('../services/usersService');
const { AppError } = require('../utils/errors');

const register = async (req, res, next) => {
  try {
    const { email, password, name } = req.body;
    const result = await usersService.registerUser({ email, password, name });
    res.status(201).json(result);
  } catch (err) {
    next(err);
  }
};

const getProfile = async (req, res, next) => {
  try {
    const user = await usersService.getUserById(req.user.id);
    res.json(user);
  } catch (err) {
    next(err);
  }
};

module.exports = { register, getProfile };
Enter fullscreen mode Exit fullscreen mode

Controllers are thin. They extract data from the request, pass it to services, and shape the response. No business logic lives here.

Layer 3: Services — Where the Real Logic Lives

// services/usersService.js
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const usersRepository = require('../repositories/usersRepository');
const emailService = require('./emailService');
const { AppError } = require('../utils/errors');

const registerUser = async ({ email, password, name }) => {
  const existing = await usersRepository.findByEmail(email);
  if (existing) {
    throw new AppError('Email already registered', 409);
  }

  const hashedPassword = await bcrypt.hash(password, 10);
  const user = await usersRepository.create({ email, password: hashedPassword, name });

  // email failure should not break registration
  emailService.sendWelcome(user).catch(err => {
    console.error('Welcome email failed:', err.message);
  });

  const token = jwt.sign({ id: user.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
  return { token, user: { id: user.id, email: user.email, name: user.name } };
};

const getUserById = async (id) => {
  const user = await usersRepository.findById(id);
  if (!user) throw new AppError('User not found', 404);
  return user;
};

module.exports = { registerUser, getUserById };
Enter fullscreen mode Exit fullscreen mode

Notice the email failure handling. Business decisions live here — like "a welcome email failure should not block registration."

Layer 4: Repositories — Speak Only SQL (or ORM)

// repositories/usersRepository.js
const db = require('../db');

const findByEmail = async (email) => {
  const [rows] = await db.execute('SELECT * FROM users WHERE email = ?', [email]);
  return rows[0] || null;
};

const findById = async (id) => {
  const [rows] = await db.execute('SELECT id, email, name, created_at FROM users WHERE id = ?', [id]);
  return rows[0] || null;
};

const create = async ({ email, password, name }) => {
  const [result] = await db.execute(
    'INSERT INTO users (email, password, name) VALUES (?, ?, ?)',
    [email, password, name]
  );
  return findById(result.insertId);
};

module.exports = { findByEmail, findById, create };
Enter fullscreen mode Exit fullscreen mode

Swapping MySQL for PostgreSQL? Only this file changes. Your service layer never knows the difference.

The Glue: Validation and Error Handling Middleware

// middleware/validate.js
const validateBody = (schema) => (req, res, next) => {
  const { error, value } = schema.validate(req.body, { abortEarly: false });
  if (error) {
    const messages = error.details.map(d => d.message);
    return res.status(400).json({ error: 'Validation failed', details: messages });
  }
  req.body = value; // use sanitized values
  next();
};

module.exports = { validateBody };
Enter fullscreen mode Exit fullscreen mode
// middleware/errorHandler.js
const { AppError } = require('../utils/errors');

const errorHandler = (err, req, res, next) => {
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({ error: err.message });
  }

  console.error('Unhandled error:', err);
  res.status(500).json({ error: 'Something went wrong' });
};

module.exports = errorHandler;
Enter fullscreen mode Exit fullscreen mode
// utils/errors.js
class AppError extends Error {
  constructor(message, statusCode = 500) {
    super(message);
    this.statusCode = statusCode;
  }
}

module.exports = { AppError };
Enter fullscreen mode Exit fullscreen mode

Every thrown AppError bubbles up cleanly. No more scattered res.status(500).json(...) calls across your codebase.

Why This Architecture Wins

Testing becomes trivial. You can unit test a service by mocking the repository. No HTTP server needed, no database needed.

// tests/services/usersService.test.js
jest.mock('../../repositories/usersRepository');
const usersRepository = require('../../repositories/usersRepository');
const { registerUser } = require('../../services/usersService');

test('throws 409 if email already exists', async () => {
  usersRepository.findByEmail.mockResolvedValue({ id: 1, email: 'test@test.com' });
  await expect(registerUser({ email: 'test@test.com', password: 'pass', name: 'Test' }))
    .rejects.toMatchObject({ statusCode: 409 });
});
Enter fullscreen mode Exit fullscreen mode

Onboarding is fast. New developers look at routes to understand the API surface. They look at services to understand the business rules. They never have to read 400 lines to find one bug.

Refactoring is safe. When you add a new auth provider (say, Google OAuth), you call usersService.registerUser() from a new controller. The business logic is already there.

Common Mistakes to Avoid

  1. Putting database calls in controllers. The moment your controller imports db, you have broken the pattern.

  2. Fat repositories. Repositories should not contain business logic. If you are writing if statements based on user roles inside a repository, stop.

  3. Services calling other services in circles. If usersService calls ordersService which calls usersService, you have a design problem. Extract shared logic to a utility or rethink your domain boundaries.

  4. Ignoring async error handling. Wrap all async controller functions with a try/catch or use an async wrapper utility. Unhandled promise rejections will crash your server.

The Payoff

I have applied this pattern to projects ranging from small side projects to APIs serving millions of requests per day. The initial setup takes maybe an extra hour. The payoff is measured in days of saved debugging time over the life of the project.

Your routes file becomes a map. Your controllers become traffic directors. Your services become the single source of truth for how your application behaves. Your repositories become a clean data access layer you can swap or mock at will.

Clean architecture is not about being clever. It is about making the next developer (often future you) able to understand what is happening without a guide.

Start with one endpoint. Refactor it into this pattern. See how it feels. I think you will not go back.


Have you built an Express.js API that grew out of control? What patterns helped you tame it? Drop your experience in the comments.

Top comments (0)