Modularity in Express.js

02 Mins

As applications grow beyond a few routes, keeping all logic inside a single file quickly becomes difficult to maintain. Modularity is the practice of splitting an application into smaller, focused components where each file has a clear responsibility.

In Express.js applications, code is commonly separated into:

  • Routes → Define API endpoints
  • Controllers → Handle request/response logic
  • Services → Contain business logic
  • Models → Interact with the database

Typical Project Structure

project/

├── routes/
│   └── users.routes.js

├── controllers/
│   └── users.controller.js

├── services/
│   └── users.service.js

├── models/
│   └── schema.prisma
│   └── db.js

└── app.js

Example: Users Module

Step 1: Database Configuration

// models/schema.prisma
datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  name      String?
  email     String   @unique
  age       Int?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

Run migrations

npx prisma migrate dev --name init

Database Client

// models/db.js
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

export default prisma;

The database client is shared across services.

Step 2: Defining Routes

Routes define the API endpoints and map them to controller functions.

// routes/users.routes.js
import express from "express";
import { getUsers, createUser } from "../controllers/users.controller.js";

const router = express.Router();

router.get("/", getUsers);
router.post("/", createUser);

export default router;

Step 3: Creating Controllers

Controllers handle incoming requests and outgoing responses.

// controllers/users.controller.js
import * as userService from "../services/users.service.js";

export const getUsers = async (req, res, next) => {
    try {
        const users = await userService.listUsers();

        res.json(users);

    } catch (err) {
        next(err);
    }
};

export const createUser = async (req, res, next) => {
    try {
        const newUser = await userService.addUser(req.body);

        res.status(201).json(newUser);

    } catch (err) {
        next(err);
    }
};

Controllers should remain lightweight and primarily coordinate request handling

Step 4: Creating Services

Services contain the core business logic and database operations.

// services/users.service.js
import prisma from "../models/db.js";

export const listUsers = async () => {
    return prisma.user.findMany({
        select: {
            id: true,
            name: true,
            email: true,
            age: true,
        },
    });
};

export const addUser = async (data) => {
    return prisma.user.create({
        data,
    });
};

Separating business logic into services improves code reuse and testing

Step 5: Connecting Everything in app.js

// app.js
import express from "express";
import dotenv from "dotenv";

import userRoutes from "./routes/users.routes.js";

dotenv.config();

const app = express();

// Middleware
app.use(express.json());

// Routes
app.use("/users", userRoutes);

// Global error handler
app.use((err, req, res, next) => {
    console.error(err.stack);

    res.status(err.status || 500).json({
        message: err.message || "Something went wrong!",
    });
});

app.listen(3000, () => {
    console.log("Server running on port 3000");
});

Request Flow

A request typically flows through the application like this:

Client Request

Route

Controller

Service

Database

The response then travels back through the same chain.