DEV Community

Cover image for Stop Writing Spaghetti API Routes: How I Restructured a Node.js Backend with Clean Architecture
Teguh Coding
Teguh Coding

Posted on

Stop Writing Spaghetti API Routes: How I Restructured a Node.js Backend with Clean Architecture

Stop Writing Spaghetti API Routes: How I Restructured a Node.js Backend with Clean Architecture

Eighteen months ago, I inherited a Node.js Express codebase that made me genuinely uncomfortable every time I opened it. Routes were doing database queries. Database queries were scattered across controllers. Business logic was hiding inside middleware. And there was one legendary utils.js file that had somehow become the graveyard for everything nobody knew where to put.

If you have ever opened a file and thought "I don't know what this file is responsible for anymore," you already understand the problem.

This article is about how I untangled that mess using Clean Architecture principles, and the practical patterns that actually stuck with the team.


The Problem With "Just Express"

Express is minimal by design. That is its greatest strength and its most dangerous trap. When you start a new project, Express gives you zero opinions about structure. And for a while, that feels great. You move fast, routes go in, features ship.

Then the codebase grows. A new developer joins. They add a route that calls a service that calls a repository that also calls another service that calls... something. Nobody is sure.

Here is what "just Express" spaghetti looks like:

// routes/orders.js - a cautionary tale
router.post('/orders', async (req, res) => {
  const { userId, items } = req.body;

  // validation mixed with logic
  if (!userId || !items || items.length === 0) {
    return res.status(400).json({ error: 'Invalid input' });
  }

  // database query right here in the route
  const user = await db.query('SELECT * FROM users WHERE id = ?', [userId]);
  if (!user.rows.length) {
    return res.status(404).json({ error: 'User not found' });
  }

  // business logic buried inside a route handler
  let total = 0;
  for (const item of items) {
    const product = await db.query('SELECT * FROM products WHERE id = ?', [item.productId]);
    total += product.rows[0].price * item.quantity;
  }

  // side effects everywhere
  await db.query('INSERT INTO orders (user_id, total) VALUES (?, ?)', [userId, total]);
  await emailService.send(user.rows[0].email, 'Order confirmed');

  res.status(201).json({ message: 'Order created', total });
});
Enter fullscreen mode Exit fullscreen mode

This works. It ships. And in six months it will be the reason someone quits.


The Architecture That Saved Us

Clean Architecture, popularized by Robert C. Martin, is fundamentally about one idea: your business logic should not know or care about your framework, your database, or your HTTP layer.

We adapted this into four clear layers:

src/
  routes/          <- HTTP only: parse request, call controller, send response
  controllers/     <- Orchestration: validate input, call use cases, format output
  use-cases/       <- Business logic: the actual rules of your domain
  repositories/    <- Data access: all database interaction lives here
Enter fullscreen mode Exit fullscreen mode

Each layer depends only on the layer below it. Routes know about controllers. Controllers know about use cases. Use cases know about repositories. Repositories know about the database. Nothing knows about what is above it.

Let me show you what that order endpoint looks like when restructured.


Layer 1: The Route (HTTP only)

// routes/orders.js
const express = require('express');
const { createOrder } = require('../controllers/order-controller');

const router = express.Router();

router.post('/orders', createOrder);

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

Nothing else. The route's only job is wiring an HTTP path to a controller.


Layer 2: The Controller (Orchestration)

// controllers/order-controller.js
const { CreateOrderUseCase } = require('../use-cases/create-order');
const { OrderRepository } = require('../repositories/order-repository');
const { UserRepository } = require('../repositories/user-repository');
const { validateCreateOrder } = require('../validators/order-validator');

async function createOrder(req, res) {
  const { error, value } = validateCreateOrder(req.body);
  if (error) {
    return res.status(400).json({ error: error.message });
  }

  const useCase = new CreateOrderUseCase(
    new OrderRepository(),
    new UserRepository()
  );

  const result = await useCase.execute(value);

  if (!result.success) {
    return res.status(result.statusCode).json({ error: result.error });
  }

  return res.status(201).json(result.data);
}

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

The controller validates input, assembles dependencies, calls the use case, and translates the result back to HTTP. It does not care how orders are stored or what the business rules are.


Layer 3: The Use Case (Business Logic)

// use-cases/create-order.js
class CreateOrderUseCase {
  constructor(orderRepository, userRepository) {
    this.orderRepository = orderRepository;
    this.userRepository = userRepository;
  }

  async execute({ userId, items }) {
    const user = await this.userRepository.findById(userId);
    if (!user) {
      return { success: false, statusCode: 404, error: 'User not found' };
    }

    const total = await this.orderRepository.calculateTotal(items);

    const order = await this.orderRepository.create({
      userId,
      items,
      total
    });

    return { success: true, data: { orderId: order.id, total } };
  }
}

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

This is the heart of your application. Pure logic. No Express. No database drivers. No HTTP status codes. This is also the easiest layer to unit test, because you can mock both repositories trivially.


Layer 4: The Repository (Data Access)

// repositories/order-repository.js
const db = require('../database/connection');

class OrderRepository {
  async create({ userId, items, total }) {
    const result = await db.query(
      'INSERT INTO orders (user_id, total) VALUES ($1, $2) RETURNING id',
      [userId, total]
    );
    return { id: result.rows[0].id };
  }

  async calculateTotal(items) {
    let total = 0;
    for (const item of items) {
      const result = await db.query(
        'SELECT price FROM products WHERE id = $1',
        [item.productId]
      );
      if (result.rows.length) {
        total += result.rows[0].price * item.quantity;
      }
    }
    return total;
  }
}

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

All SQL lives here. If you switch from PostgreSQL to MongoDB, you update this file only. Nothing above it changes.


The Payoff: Testing Becomes Trivial

The best outcome of this structure is how easy testing becomes. Here is a unit test for the use case:

// tests/use-cases/create-order.test.js
const { CreateOrderUseCase } = require('../../use-cases/create-order');

const mockOrderRepo = {
  create: jest.fn().mockResolvedValue({ id: 42 }),
  calculateTotal: jest.fn().mockResolvedValue(99.99)
};

const mockUserRepo = {
  findById: jest.fn().mockResolvedValue({ id: 1, email: 'test@example.com' })
};

test('creates an order successfully', async () => {
  const useCase = new CreateOrderUseCase(mockOrderRepo, mockUserRepo);
  const result = await useCase.execute({
    userId: 1,
    items: [{ productId: 10, quantity: 2 }]
  });

  expect(result.success).toBe(true);
  expect(result.data.orderId).toBe(42);
  expect(result.data.total).toBe(99.99);
});

test('returns error when user does not exist', async () => {
  mockUserRepo.findById.mockResolvedValueOnce(null);
  const useCase = new CreateOrderUseCase(mockOrderRepo, mockUserRepo);
  const result = await useCase.execute({ userId: 999, items: [] });

  expect(result.success).toBe(false);
  expect(result.statusCode).toBe(404);
});
Enter fullscreen mode Exit fullscreen mode

No database. No HTTP server. Just logic being verified in milliseconds.


What Changed After the Refactor

After three months of gradually migrating the codebase to this structure:

  • New developers could orient themselves in under an hour. The layers made the question "where does this code go" have an obvious answer.
  • Test coverage went from 12% to 71% because tests were finally easy to write.
  • We swapped our ORM from Sequelize to Prisma in one week, touching only repository files.
  • Debugging production issues became faster because the stacktrace pointed directly to the layer that failed.

When This Might Be Overkill

I want to be honest. If you are building a 5-route CRUD API for an internal tool, this is probably too much ceremony. Express spaghetti ships faster.

This structure earns its complexity when:

  • The team has more than two people
  • The project is expected to live longer than one year
  • You need testable business logic
  • You anticipate swapping infrastructure (databases, queues, email providers)

For small projects, a simpler services-plus-routes split is often enough.


The Takeaway

Clean Architecture is not about being clever. It is about making change cheap. When requirements shift, when the database needs to change, when a new developer joins and needs to find where to add a feature, the structure pays for itself.

The goal is always the same: make the right thing easy to find and the wrong thing hard to do by accident.

Start small. Separate your database calls from your business logic. Put your business logic in dedicated files. Keep your route handlers thin. The rest follows naturally.

Your future self, debugging a production issue at 2 AM, will be grateful.

Top comments (0)