I spent last Tuesday staring at a single file named routes.js that was three thousand lines long. It had database queries mixed with validation logic, third-party API calls nested inside if statements, and zero comments. It was a masterpiece of chaos.

We’ve all been there. You start a new Express project with the best intentions. “I’ll keep it simple,” you tell yourself. “Just a few endpoints.” Fast forward six months, and your nice little API has turned into a sprawling mess that makes you afraid to deploy on Fridays.

Even here in 2026, with all the fancy meta-frameworks and edge runtimes trying to kill plain old Node.js, Express refuses to die. It’s still the default. It’s boring, reliable, and frankly, it lets you get away with murder structurally. That’s the problem. Express is unopinionated, which means its opinion is “do whatever you want, I don’t care.”

So, let’s fix it. If you’re building REST APIs today, you need to stop dumping logic into your route handlers. Seriously. Stop it.

The “Fat Route” Anti-Pattern

This is what I see in code reviews constantly. It works. It passes tests. But it’s impossible to maintain or reuse.

// The "I'll refactor this later" approach
import express from 'express';
import db from './db.js';

const router = express.Router();

router.post('/orders', async (req, res) => {
  try {
    const { userId, items } = req.body;

    // Validation logic stuck in the route
    if (!userId || !items || items.length === 0) {
      return res.status(400).json({ error: 'Missing data' });
    }

    // Direct DB access? Check.
    const user = await db.users.findById(userId);
    if (!user) {
      return res.status(404).json({ error: 'User not found' });
    }

    // Business logic mixed with HTTP concerns
    let total = 0;
    for (const item of items) {
      const product = await db.products.findById(item.id);
      if (product.stock < item.qty) {
        return res.status(400).json({ error: Not enough stock for ${product.name} });
      }
      total += product.price * item.qty;
    }

    const order = await db.orders.create({ userId, items, total, status: 'pending' });
    
    // Sending email inside the request cycle? Why not.
    await fetch('https://api.emailservice.com/send', {
      method: 'POST',
      body: JSON.stringify({ to: user.email, subject: 'Order Received' })
    });

    res.status(201).json(order);
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

See the problem? This handler knows everything. It knows about the database schema, it knows about the email provider’s API, and it knows how to calculate order totals. If you want to create an order from a background job or a CLI script later, you can’t reuse this logic because it’s tightly coupled to req and res.

Node.js code screen - How to create and read QR codes in Node.js - LogRocket Blog
Node.js code screen – How to create and read QR codes in Node.js – LogRocket Blog

The Three-Layer Architecture

I’m not reinventing the wheel here. This pattern is older than most JavaScript frameworks, but for some reason, we keep forgetting it. I separate my concerns into three distinct buckets:

  • Controller: Handles HTTP stuff (req/res, status codes). That’s it.
  • Service: Handles business logic. Doesn’t know what “HTTP” is.
  • Data Access (Repository): Talks to the DB.

Here is how I structure it now. It’s cleaner, testable, and doesn’t make me want to cry when I open the file six months later.

1. The Controller

The controller is just a traffic cop. It takes the request, hands it off, and returns the result. Note that with Express 5 (which we’ve happily been using as stable for a while now), we don’t need those messy try/catch blocks in every route anymore—async errors propagate automatically.

// controllers/orderController.js
import * as orderService from '../services/orderService.js';

export const createOrder = async (req, res, next) => {
  // Input comes from req
  const { userId, items } = req.body;

  // Business logic happens in the service
  const order = await orderService.placeOrder(userId, items);

  // Output goes to res
  res.status(201).json({
    success: true,
    data: order
  });
};

2. The Service

This is where the magic happens. Notice there is no req or res here. This function just takes data and returns data. If something goes wrong, it throws an error. This makes unit testing trivial because you don’t have to mock Express objects.

// services/orderService.js
import * as userRepo from '../repos/userRepo.js';
import * as productRepo from '../repos/productRepo.js';
import * as orderRepo from '../repos/orderRepo.js';

export const placeOrder = async (userId, items) => {
  if (!userId || !items?.length) {
    throw new Error('Invalid order data'); // Custom error classes are better, but you get the idea
  }

  const user = await userRepo.findById(userId);
  if (!user) throw new Error('User not found');

  let total = 0;
  
  // Validate stock and calculate total
  for (const item of items) {
    const product = await productRepo.findById(item.id);
    if (product.stock < item.qty) {
      throw new Error(Stock insufficient for ${product.name});
    }
    total += product.price * item.qty;
  }

  // Transaction logic could go here
  const order = await orderRepo.create({ userId, items, total });
  
  // Fire and forget email - don't block the return
  sendConfirmationEmail(user.email).catch(console.error);

  return order;
};

Standardizing Responses

Node.js code screen - JavaScript & Node.js course for Testers, QA and SDETs - 2026 | Udemy
Node.js code screen – JavaScript & Node.js course for Testers, QA and SDETs – 2026 | Udemy

Another thing that drives me nuts is inconsistent API responses. One endpoint returns { "data": [...] }, another returns just an array [...], and errors are a wild mix of strings and objects.

I force consistency using a simple middleware. If I’m building a public API, I usually stick to something JSend-ish. It’s predictable. Front-end devs love predictable.

// middleware/responseHandler.js

// Success handler isn't middleware per se, but a helper
export const sendSuccess = (res, data, code = 200) => {
  res.status(code).json({
    status: 'success',
    data: data
  });
};

// Global Error Handler (Express 5 style)
export const errorHandler = (err, req, res, next) => {
  console.error(err.stack); // Log it properly in real life

  const statusCode = err.statusCode || 500;
  const message = err.message || 'Internal Server Error';

  res.status(statusCode).json({
    status: 'error',
    message: message,
    // Only show stack in dev, obviously
    ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
  });
};

Then in your app.js, you just plug it in at the end.

// app.js
import express from 'express';
import routes from './routes/index.js';
import { errorHandler } from './middleware/responseHandler.js';

const app = express();

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

// This catches anything thrown in async routes automatically in Express 5
app.use(errorHandler);

export default app;

Why This Still Matters in 2026

JavaScript programming - The JavaScript programming language: differences with Java
JavaScript programming – The JavaScript programming language: differences with Java

I know, I know. “Why aren’t you using [Insert Framework of the Month]?”

Because architecture isn’t about the tool; it’s about the boundaries. Whether you are using Express, Fastify, or running raw code on V8 isolates, mixing your transport layer (HTTP) with your business logic is always a bad idea. It makes refactoring a nightmare.

I recently had to migrate a project from a REST API to a GraphQL setup. Because we had all our logic in “Services” and not locked inside Express route handlers, the migration took three days instead of three months. We just wrote new GraphQL resolvers that called the existing services. Easy.

So, do yourself a favor. Open your routes folder. If you see database queries, start extracting. Your future self—the one debugging production at 2 AM—will thank you.

By Akari Sato

Akari thrives on optimizing React applications, often finding elegant solutions to complex rendering challenges that others miss. She firmly believes that perfect component reusability is an achievable dream and has an extensive collection of quirky mechanical keyboard keycaps.