Module P-1·18 min read

Layered architecture (routes → controllers → services → data), feature folders vs layer folders, the service layer pattern, and dependency injection basics.

Written by Jatin Jain Saraf · Architect of SupraScan

Module P-1 — Application Architecture and Project Structure

What this module covers: The Blog API in F-8 worked, but it had a problem: route handlers were doing everything — validating input, querying the database, and formatting responses. At fifty routes that becomes unmaintainable. This module introduces the layered architecture pattern that separates concerns cleanly, explains why each layer exists, and gives you a project structure that scales to hundreds of endpoints without becoming a mess. Every production Node.js codebase uses some version of this.


Why Architecture Matters

Consider a route handler that does everything:

router.post('/orders', async (req, res, next) => {
  try {
    const { userId, items } = req.body;
 
    // Validation
    if (!userId || !items?.length) {
      return res.status(400).json({ error: 'userId and items required' });
    }
 
    // Business logic
    const user = await prisma.user.findUnique({ where: { id: userId } });
    if (!user) return res.status(404).json({ error: 'User not found' });
    if (user.status === 'banned') return res.status(403).json({ error: 'Account suspended' });
 
    // More business logic
    let total = 0;
    const orderItems = [];
    for (const item of items) {
      const product = await prisma.product.findUnique({ where: { id: item.productId } });
      if (!product) return res.status(404).json({ error: `Product ${item.productId} not found` });
      if (product.stock < item.quantity) return res.status(409).json({ error: `Insufficient stock for ${product.name}` });
      total += product.price * item.quantity;
      orderItems.push({ productId: product.id, quantity: item.quantity, price: product.price });
    }
 
    // Database writes
    const order = await prisma.order.create({
      data: { userId, total, items: { create: orderItems } },
      include: { items: true },
    });
 
    // Side effects
    await sendOrderConfirmationEmail(user.email, order);
    await updateInventory(orderItems);
 
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
});

This is 40 lines doing six different jobs. Problems:

  • Untestable in isolation — to test the "banned user" rule you must mock HTTP, the database, email, and inventory
  • Impossible to reuse — if a mobile app and a webhook both need to place orders, this logic is duplicated or copy-pasted
  • Opaque — reading it you cannot tell where HTTP ends and business logic begins

The solution is layers.


The Four-Layer Architecture

HTTP Request
     ↓
┌─────────────────┐
│   Router/Route  │  Handles HTTP: reads req, calls controller, sets res
└────────┬────────┘
         ↓
┌─────────────────┐
│   Controller    │  Orchestrates: validates input, calls services, formats response
└────────┬────────┘
         ↓
┌─────────────────┐
│    Service      │  Business logic: rules, calculations, orchestrates data access
└────────┬────────┘
         ↓
┌─────────────────┐
│  Data Access    │  Database queries: only place that talks to the DB/ORM
└─────────────────┘

Analogy: think of the four layers like a restaurant: the waiter (route) takes the order and never touches a pan, the line cook (controller) plates what the kitchen produces, the executive chef (service) decides the recipe and the rules, and the pantry runner (repository) is the only one allowed into the walk-in fridge.

Each layer has one job and knows only about the layer below it. A service never reads req.body. A route handler never writes SQL.


Project Structure

src/
├── routes/               # Express routers — thin, just wire up controllers
│   ├── users.routes.js
│   ├── posts.routes.js
│   └── orders.routes.js
│
├── controllers/          # Request/response handling — no business logic
│   ├── users.controller.js
│   ├── posts.controller.js
│   └── orders.controller.js
│
├── services/             # Business logic — no HTTP, no req/res
│   ├── users.service.js
│   ├── posts.service.js
│   └── orders.service.js
│
├── repositories/         # Data access — only place that calls prisma/pool
│   ├── users.repository.js
│   ├── posts.repository.js
│   └── orders.repository.js
│
├── middleware/           # Cross-cutting concerns
│   ├── auth.js           # JWT verification
│   ├── validate.js       # Request validation
│   └── errorHandler.js   # Global error handler
│
├── errors/               # Custom error classes
│   └── AppError.js
│
├── db/
│   └── prisma.js         # Prisma client singleton
│
└── index.js              # Entry point — wires everything together

This is the feature-based slice vs layer-based choice. The structure above groups by layer (all services together). For large apps you may prefer grouping by feature (users/users.route.js, users/users.service.js, etc.). Either works — the important thing is the layer separation, not the folder names.


Layer 1: Routes

Routes are pure wiring. They register a URL pattern, apply middleware, and delegate to a controller. Nothing else.

// src/routes/orders.routes.js
import { Router } from 'express';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createOrderSchema } from '../validators/orders.schema.js';
import * as ordersController from '../controllers/orders.controller.js';
 
const router = Router();
 
router.post(
  '/',
  authenticate,                          // middleware: verify JWT
  validate(createOrderSchema),           // middleware: validate body
  ordersController.createOrder,          // controller: handle the request
);
 
router.get('/:id', authenticate, ordersController.getOrderById);
router.get('/', authenticate, ordersController.listOrders);
 
export default router;

No logic. No database calls. Just: middleware chain → controller.


Layer 2: Controllers

Controllers handle the HTTP boundary. They read from req, call services, and write to res. They contain no business logic — they orchestrate.

// src/controllers/orders.controller.js
import * as ordersService from '../services/orders.service.js';
 
export async function createOrder(req, res, next) {
  try {
    const order = await ordersService.createOrder({
      userId: req.user.id,       // set by authenticate middleware
      items: req.body.items,
    });
    res.status(201).json(order);
  } catch (err) {
    next(err);
  }
}
 
export async function getOrderById(req, res, next) {
  try {
    const order = await ordersService.getOrderById(
      parseInt(req.params.id),
      req.user.id,
    );
    res.json(order);
  } catch (err) {
    next(err);
  }
}
 
export async function listOrders(req, res, next) {
  try {
    const { page = '1', limit = '20' } = req.query;
    const orders = await ordersService.listOrdersByUser(req.user.id, {
      page: parseInt(page),
      limit: parseInt(limit),
    });
    res.json(orders);
  } catch (err) {
    next(err);
  }
}

Notice: the controller knows about req, res, and next. The service knows nothing about them.


Layer 3: Services

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.