DEV Community

Cover image for Stop Writing Spaghetti API Routes — Structure Your Express.js App Like a Pro
Teguh Coding
Teguh Coding

Posted on

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

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 am going to walk you through a battle-tested folder structure for Express.js that scales — from weekend side project to production application — without turning into a maintenance nightmare.

Why Structure Matters More Than You Think

Bad structure is not just an aesthetic problem. It actively slows down your team, makes onboarding new developers painful, and turns "add a simple feature" into a three-hour archaeological dig through the codebase.

Good structure does the opposite. It makes the right place for any piece of code obvious. It separates concerns so changes in one area don't ripple unexpectedly into another. And it 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're going to 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
Enter fullscreen mode Exit fullscreen mode

Let me break down each layer and why it exists.

Layer 1: Config

All your 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,
  },
};
Enter fullscreen mode Exit fullscreen mode

This means when your database URL changes, you update one file. Done.

Layer 2: Routes

Routes are just traffic directors. They should not contain any logic. Their only job is to map HTTP methods and 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;
Enter fullscreen mode Exit fullscreen mode

Clean. Readable. No business logic in sight.

Then you compose all routes 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;
Enter fullscreen mode Exit fullscreen mode

Layer 3: Controllers

Controllers handle the HTTP layer. They receive the request, call the appropriate service, and send back a response. That's it. 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);
  }
};
Enter fullscreen mode Exit fullscreen mode

Notice how thin this is. Controllers are the glue layer, not the logic layer.

Layer 4: Services

This is where your actual business logic lives. Services are plain JavaScript — no req, no res, no HTTP concepts. This makes them incredibly 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'] },
  });
};
Enter fullscreen mode Exit fullscreen mode

Because services have no HTTP knowledge, you can call them from a cron job, a queue worker, a test suite, or a CLI script — with zero changes.

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' });
  }
};
Enter fullscreen mode Exit fullscreen mode
// 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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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 handles formatting 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;
Enter fullscreen mode Exit fullscreen mode
// 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`);
});
Enter fullscreen mode Exit fullscreen 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:

  1. Testing becomes easy. Services have no HTTP dependencies, so unit tests are just function calls.
  2. New developers onboard fast. The folder names tell the story of the application.
  3. Features are isolated. Adding a new resource means adding files to each layer — you never have to touch something unrelated.
  4. 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.

Top comments (0)