Stop Writing Spaghetti API Routes — Structure Your Express.js App Like a Pro

Published: (March 1, 2026 at 07:06 AM EST)
8 min read
Source: Dev.to

Source: Dev.to

The Spaghetti API Problem

Every developer has been there. You start a new Express.js project with the best intentions: a clean index.js, maybe two routes, and a dream. Then three months later you open the file and there are 800 lines of route handlers, middleware piled on top of each other, and database calls scattered everywhere like confetti after a bad party.

This is the spaghetti API problem, and it kills more projects than bugs do.

In this article I’ll walk you through a battle‑tested folder structure for Express.js that scales—from a weekend side‑project to a production application—without turning into a maintenance nightmare.

Why Structure Matters More Than You Think

  • Bad structure isn’t just an aesthetic problem. It actively slows down your team, makes onboarding painful, and turns “add a simple feature” into a three‑hour archaeological dig.
  • Good structure does the opposite. It makes the right place for any piece of code obvious, separates concerns so changes in one area don’t ripple unexpectedly into another, and lets you scale the team and the codebase without everything falling apart.

Let’s build it from the ground up.

The Folder Structure

Here’s the structure we’ll implement:

my-api/
  src/
    config/
      index.js
      database.js
    controllers/
      userController.js
      postController.js
    middlewares/
      auth.js
      errorHandler.js
      validate.js
    models/
      User.js
      Post.js
    routes/
      index.js
      userRoutes.js
      postRoutes.js
    services/
      userService.js
      postService.js
    utils/
      logger.js
      response.js
  app.js
  server.js

Layer 1 – Config

All environment variables and configuration live here. No more process.env.DATABASE_URL scattered across 15 files.

// src/config/index.js
module.exports = {
  port: process.env.PORT || 3000,
  nodeEnv: process.env.NODE_ENV || 'development',
  jwtSecret: process.env.JWT_SECRET,
  db: {
    url: process.env.DATABASE_URL,
    poolSize: parseInt(process.env.DB_POOL_SIZE, 10) || 5,
  },
};

When your database URL changes, you update one file. Done.

Layer 2 – Routes

Routes are traffic directors. They should contain no business logic—only mapping of HTTP methods/paths to controller functions.

// src/routes/userRoutes.js
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middlewares/auth');
const { validateCreateUser } = require('../middlewares/validate');
const userController = require('../controllers/userController');

router.get('/', authenticate, userController.getUsers);
router.post('/', validateCreateUser, userController.createUser);
router.get('/:id', authenticate, userController.getUserById);
router.put('/:id', authenticate, userController.updateUser);
router.delete('/:id', authenticate, userController.deleteUser);

module.exports = router;

All route groups are composed in a single index file:

// src/routes/index.js
const express = require('express');
const router = express.Router();
const userRoutes = require('./userRoutes');
const postRoutes = require('./postRoutes');

router.use('/users', userRoutes);
router.use('/posts', postRoutes);

module.exports = router;

Layer 3 – Controllers

Controllers handle the HTTP layer: they receive the request, call the appropriate service, and send back a response. No database calls, no complex logic.

// src/controllers/userController.js
const userService = require('../services/userService');
const { sendSuccess, sendError } = require('../utils/response');

exports.getUsers = async (req, res, next) => {
  try {
    const { page = 1, limit = 20 } = req.query;
    const users = await userService.getUsers({ page, limit });
    return sendSuccess(res, users);
  } catch (error) {
    next(error);
  }
};

exports.createUser = async (req, res, next) => {
  try {
    const user = await userService.createUser(req.body);
    return sendSuccess(res, user, 201);
  } catch (error) {
    next(error);
  }
};

exports.getUserById = async (req, res, next) => {
  try {
    const user = await userService.getUserById(req.params.id);
    if (!user) return sendError(res, 'User not found', 404);
    return sendSuccess(res, user);
  } catch (error) {
    next(error);
  }
};

Layer 4 – Services

Business logic lives here. Services are plain JavaScript—no req, no res, no HTTP concepts—making them extremely easy to test.

// src/services/userService.js
const User = require('../models/User');
const { hashPassword } = require('../utils/crypto');

exports.getUsers = async ({ page, limit }) => {
  const offset = (page - 1) * limit;
  const users = await User.findAll({
    limit: parseInt(limit, 10),
    offset,
    attributes: { exclude: ['password'] },
  });
  return users;
};

exports.createUser = async (userData) => {
  const existing = await User.findOne({ where: { email: userData.email } });
  if (existing) {
    const error = new Error('Email already in use');
    error.statusCode = 409;
    throw error;
  }

  const hashed = await hashPassword(userData.password);
  const user = await User.create({ ...userData, password: hashed });

  const { password, ...userWithoutPassword } = user.toJSON();
  return userWithoutPassword;
};

exports.getUserById = async (id) => {
  return User.findByPk(id, {
    attributes: { exclude: ['password'] },
  });
};

Layer 5 – Models

Define your data structures and ORM mappings (e.g., Sequelize, Mongoose). Keep them free of request‑related code.

// src/models/User.js
const { DataTypes } = require('sequelize');
const sequelize = require('../config/database');

const User = sequelize.define('User', {
  id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
  email: { type: DataTypes.STRING, unique: true, allowNull: false },
  password: { type: DataTypes.STRING, allowNull: false },
  name: { type: DataTypes.STRING },
  // ...other fields
});

module.exports = User;

Layer 6 – Middlewares

Reusable pieces of logic that sit between the request and the controller (authentication, validation, error handling, etc.).

// src/middlewares/auth.js
module.exports.authenticate = (req, res, next) => {
  // verify JWT, attach user to req, etc.
  next();
};
// src/middlewares/validate.js
module.exports.validateCreateUser = (req, res, next) => {
  // perform Joi/Yup validation
  next();
};
// src/middlewares/errorHandler.js
module.exports = (err, req, res, next) => {
  const status = err.statusCode || 500;
  res.status(status).json({ error: err.message });
};

Layer 7 – Utils

Helper functions that don’t belong anywhere else (logging, response formatting, crypto helpers, etc.).

// src/utils/response.js
exports.sendSuccess = (res, data, status = 200) => {
  res.status(status).json({ success: true, data });
};

exports.sendError = (res, message, status = 500) => {
  res.status(status).json({ success: false, error: message });
};
// src/utils/logger.js
const winston = require('winston');
module.exports = winston.createLogger({
  transports: [new winston.transports.Console()],
});

Wiring It All Together

// app.js
const express = require('express');
const config = require('./src/config');
const routes = require('./src/routes');
const errorHandler = require('./src/middlewares/errorHandler');

const app = express();

app.use(express.json());
app.use('/api', routes);
app.use(errorHandler);

module.exports = app;
// server.js
const http = require('http');
const app = require('./app');
const config = require('./src/config');

const server = http.createServer(app);

server.listen(config.port, () => {
  console.log(`🚀 Server running on http://localhost:${config.port}`);
});

TL;DR

  1. Config – Centralised environment settings.
  2. Routes – Pure HTTP path → controller mapping.
  3. Controllers – Thin request/response handlers.
  4. Services – Business logic, completely framework‑agnostic.
  5. Models – Database schemas / ORM definitions.
  6. Middlewares – Reusable request‑processing utilities.
  7. Utils – Miscellaneous helpers (logging, response formatting, etc.).

Stick to this layered approach and you’ll keep your Express.js codebase clean, testable, and scalable—no more spaghetti.

// Example of a Sequelize query with excluded fields
User.findAll({
  attributes: { exclude: ['password'] },
});

Layer 5: Middlewares

Middlewares handle cross‑cutting concerns: authentication, validation, rate limiting, logging. Keep them small and focused.

// src/middlewares/auth.js
const jwt = require('jsonwebtoken');
const config = require('../config');

exports.authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) {
    return res.status(401).json({ message: 'No token provided' });
  }

  try {
    const decoded = jwt.verify(token, config.jwtSecret);
    req.user = decoded;
    next();
  } catch {
    return res.status(401).json({ message: 'Invalid token' });
  }
};
// src/middlewares/errorHandler.js
module.exports = (err, req, res, next) => {
  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  if (process.env.NODE_ENV !== 'production') {
    console.error(err.stack);
  }

  res.status(statusCode).json({
    success: false,
    message,
  });
};

The global error handler is the unsung hero of this setup. Notice how every controller just calls next(error) — this single middleware catches them all and formats the response consistently.

Tying It All Together

// app.js
const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const routes = require('./src/routes');
const errorHandler = require('./src/middlewares/errorHandler');

const app = express();

// Security and parsing
app.use(helmet());
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// All routes under /api/v1
app.use('/api/v1', routes);

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// Error handler must be last
app.use(errorHandler);

module.exports = app;
// server.js
const app = require('./app');
const config = require('./src/config');

app.listen(config.port, () => {
  console.log(`Server running on port ${config.port} in ${config.nodeEnv} mode`);
});

Separating app.js from server.js is a small but powerful move. It means your test suite can import app directly without actually starting a server, making integration tests much simpler.

The Rule of Thumb

When you’re not sure where something belongs, ask yourself:

  • Does it touch req or res? → Controller or middleware.
  • Does it contain business logic? → Service.
  • Does it talk to the database and map data? → Model.
  • Is it an HTTP path? → Route.
  • Is it used in multiple places and has no side effects? → Utility.

Follow those five questions and you will almost always land in the right place.

What This Gets You

When your Express.js app follows this structure:

  • Testing becomes easy. Services have no HTTP dependencies, so unit tests are just function calls.
  • New developers onboard fast. The folder names tell the story of the application.
  • Features are isolated. Adding a new resource means adding files to each layer — you never have to touch something unrelated.
  • Debugging is faster. When a bug occurs, you know exactly which layer to look at based on the type of problem.

Structure is not bureaucracy. It’s how you build things that last.

Start refactoring your next Express.js project with this layout and watch how much easier everything gets. You’ll spend less time searching for code and more time writing it.

0 views
Back to Blog

Related posts

Read more »

Top 5 Node.js REST API Frameworks

A Quick Overview Have you wondered why everyone in the tech industry is going gaga over Node? From Netflix to Uber and LinkedIn, companies are striving to succ...