DEV Community

Teguh Coding
Teguh Coding

Posted on

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

Every developer has been there. You start a new Node.js project, spin up Express, and everything feels clean. Then three months later, your routes/index.js is 800 lines of spaghetti and you dread opening it.

This is the story of how most Express apps die — not from bad intentions, but from the absence of a clear structure from day one.

Today, I want to walk you through a practical architecture that scales. One that won't make your future self cry.

The Problem With "Just Express"

Express is famously un-opinionated. That's its superpower and its curse. It gives you maximum flexibility, which means it gives you maximum rope to hang yourself with.

A typical early-stage Express app looks like this:

// app.js — the everything file
const express = require('express');
const app = express();

app.get('/users', async (req, res) => {
  const db = require('./db');
  const users = await db.query('SELECT * FROM users');
  // business logic mixed in...
  const filtered = users.filter(u => u.active);
  res.json(filtered);
});

app.post('/users', async (req, res) => {
  // validation, DB calls, email sending — all in one place
  // 60 more lines...
});

// 20 more routes...
Enter fullscreen mode Exit fullscreen mode

Everything is tangled. Routes do too much. Testing is painful. Onboarding a new dev is a nightmare.

The Architecture That Actually Scales

Here is the folder structure I use for every serious Express project:

src/
  config/
    index.js         # env vars, constants
  controllers/
    user.controller.js
  services/
    user.service.js
  repositories/
    user.repository.js
  routes/
    index.js
    user.routes.js
  middlewares/
    auth.middleware.js
    validate.middleware.js
    error.middleware.js
  models/
    user.model.js
  utils/
    logger.js
    response.js
  app.js
  server.js
Enter fullscreen mode Exit fullscreen mode

Let me break down what each layer does and why it matters.

Layer 1: Routes — Just Routing, Nothing Else

Your route files should be boring. They declare endpoints and delegate to controllers. That is it.

// routes/user.routes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/user.controller');
const { authenticate } = require('../middlewares/auth.middleware');
const { validate } = require('../middlewares/validate.middleware');
const { createUserSchema } = require('./schemas/user.schema');

router.get('/', authenticate, userController.getAll);
router.get('/:id', authenticate, userController.getById);
router.post('/', validate(createUserSchema), userController.create);
router.put('/:id', authenticate, userController.update);
router.delete('/:id', authenticate, userController.remove);

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

Readable in 10 seconds. You know exactly what each route does, what middleware runs, and where the logic lives.

Layer 2: Controllers — Handle HTTP, Nothing Else

Controllers translate HTTP requests into service calls. They should not contain business logic.

// controllers/user.controller.js
const userService = require('../services/user.service');
const { successResponse, errorResponse } = require('../utils/response');

const getAll = async (req, res, next) => {
  try {
    const { page = 1, limit = 20 } = req.query;
    const users = await userService.getAllUsers({ page, limit });
    return successResponse(res, users);
  } catch (err) {
    next(err);
  }
};

const create = async (req, res, next) => {
  try {
    const user = await userService.createUser(req.body);
    return successResponse(res, user, 201);
  } catch (err) {
    next(err);
  }
};

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

Notice: no SQL, no business rules, no email sending. Just HTTP in, service call, HTTP out.

Layer 3: Services — Where the Real Logic Lives

This is the heart of your app. Services contain business rules, orchestrate multiple operations, and are completely independent of HTTP.

// services/user.service.js
const userRepository = require('../repositories/user.repository');
const emailService = require('./email.service');
const { AppError } = require('../utils/errors');

const createUser = async (data) => {
  const existing = await userRepository.findByEmail(data.email);
  if (existing) {
    throw new AppError('Email already in use', 409);
  }

  const user = await userRepository.create(data);
  await emailService.sendWelcomeEmail(user.email, user.name);

  return user;
};

const getAllUsers = async ({ page, limit }) => {
  const offset = (page - 1) * limit;
  return userRepository.findAll({ limit, offset });
};

module.exports = { createUser, getAllUsers };
Enter fullscreen mode Exit fullscreen mode

This layer is the easiest to unit test because it has zero HTTP coupling. You can call userService.createUser({...}) directly in tests without spinning up a server.

Layer 4: Repositories — Database Isolation

Repositories abstract your database access. If you ever switch from MySQL to PostgreSQL, or add a caching layer, you only change the repository.

// repositories/user.repository.js
const db = require('../config/database');

const findAll = async ({ limit, offset }) => {
  return db('users')
    .select('id', 'name', 'email', 'created_at')
    .where('active', true)
    .limit(limit)
    .offset(offset);
};

const findByEmail = async (email) => {
  return db('users').where({ email }).first();
};

const create = async (data) => {
  const [id] = await db('users').insert(data);
  return findById(id);
};

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

The Error Middleware That Saves Lives

A centralized error handler is non-negotiable. Every next(err) call in your controllers flows here:

// middlewares/error.middleware.js
const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');

const errorHandler = (err, req, res, next) => {
  logger.error({
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
  });

  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      success: false,
      message: err.message,
    });
  }

  // Unexpected errors — don't leak details
  return res.status(500).json({
    success: false,
    message: 'Internal server error',
  });
};

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

One place. All errors. No scattered res.status(500).json(...) calls across 30 controllers.

Wiring It All Together

// app.js
const express = require('express');
const routes = require('./routes');
const errorHandler = require('./middlewares/error.middleware');
const { requestLogger } = require('./middlewares/logger.middleware');

const app = express();

app.use(express.json());
app.use(requestLogger);

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

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

module.exports = app;
Enter fullscreen mode Exit fullscreen mode
// routes/index.js
const express = require('express');
const router = express.Router();

router.use('/users', require('./user.routes'));
router.use('/products', require('./product.routes'));
router.use('/auth', require('./auth.routes'));

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

The Payoff

This might feel like more files upfront. And it is. But here is what you gain:

Testing becomes trivial. You can test services without HTTP. You can mock repositories without a real database.

Onboarding is fast. A new developer looks at the folder structure and immediately knows where to add a feature.

Changes are isolated. Swapping your ORM? Only the repository layer changes. Changing email providers? Only the email service.

Debugging is easier. You know exactly which layer an error belongs to. Is it HTTP? Controller. Is it business logic? Service. Is it a query? Repository.

One Rule to Summarize Everything

Each layer should only talk to the layer directly below it. Routes call controllers. Controllers call services. Services call repositories. Never skip layers. Never go sideways.

That single rule, consistently applied, is the difference between a codebase you enjoy working in and one you dread.

Start your next Express project with this structure. Your future self will thank you.


Have a different approach you prefer? I would love to hear it in the comments. Architecture is one of those things where there is rarely one right answer — only trade-offs worth discussing.

Top comments (0)