Skip to content
Node.js best practices 5 min read

Project Structure Best Practices

How you lay out a Node.js project shapes how easily new people understand it, how cleanly responsibilities stay separated, and how painful change becomes as the codebase grows. There is no single blessed structure, but the strongest ones share a principle: keep HTTP concerns at the edges, business logic in the middle, and data access at the bottom, with each layer depending only on the one beneath it. This page describes a scalable layered layout, when to organize by feature instead, and the conventions that keep both approaches tidy.

A layered baseline

Most server-side Node applications benefit from a clear separation between four roles. Routes map URLs to handlers, controllers translate HTTP into plain function calls, services hold the business rules, and repositories own the database. The payoff is that each layer can be tested in isolation and swapped without rippling changes outward — your service logic never touches req/res, and your business rules never know which database you use.

LayerResponsibilityKnows about HTTP?Knows about the DB?
RouteMaps a path/method to a controllerYesNo
ControllerParses input, calls a service, shapes the responseYesNo
ServiceBusiness logic, orchestration, validationNoNo
RepositoryQueries and persists dataNoYes

A typical top-level tree for this style looks like the following.

my-app/
├── src/
│   ├── config/          # env parsing, constants, app config
│   ├── routes/          # route definitions, wire URLs to controllers
│   ├── controllers/     # HTTP request/response handling
│   ├── services/        # business logic
│   ├── repositories/    # data access
│   ├── middleware/      # auth, error handling, logging
│   ├── utils/           # small shared helpers
│   └── app.js           # builds the app (no listen())
├── tests/               # unit and integration tests
├── server.js            # entry point: imports app, calls listen()
└── package.json

Keep app.js (which builds the Express/Fastify app) separate from server.js (which calls listen). That single split lets your integration tests import the app and hit it in-process without binding a real port.

How the layers connect

Each layer receives its collaborators rather than importing them directly, which keeps the dependency arrows pointing inward and makes testing trivial. Here is a thin slice through all four layers.

// src/repositories/user.repository.js
export class UserRepository {
  constructor(db) {
    this.db = db;
  }

  async findById(id) {
    const { rows } = await this.db.query(
      "SELECT id, email FROM users WHERE id = $1",
      [id],
    );
    return rows[0] ?? null;
  }
}
// src/services/user.service.js
export class UserService {
  constructor(userRepository) {
    this.userRepository = userRepository;
  }

  async getUser(id) {
    const user = await this.userRepository.findById(id);
    if (!user) {
      throw new Error(`User ${id} not found`);
    }
    return user;
  }
}
// src/controllers/user.controller.js
export function makeUserController(userService) {
  return {
    async getUser(req, res, next) {
      try {
        const user = await userService.getUser(req.params.id);
        res.json(user);
      } catch (err) {
        next(err); // hand off to the error middleware
      }
    },
  };
}
// src/routes/user.routes.js
import { Router } from "express";

export function userRoutes(userController) {
  const router = Router();
  router.get("/:id", userController.getUser);
  return router;
}

The wiring — creating the repository, passing it into the service, and so on — happens once in app.js. This composition root is the only place that knows the concrete classes fit together.

// src/app.js
import express from "express";
import { UserRepository } from "./repositories/user.repository.js";
import { UserService } from "./services/user.service.js";
import { makeUserController } from "./controllers/user.controller.js";
import { userRoutes } from "./routes/user.routes.js";
import { errorHandler } from "./middleware/error-handler.js";

export function createApp(db) {
  const app = express();
  app.use(express.json());

  const userService = new UserService(new UserRepository(db));
  const userController = makeUserController(userService);

  app.use("/users", userRoutes(userController));
  app.use(errorHandler); // last, so it catches everything

  return app;
}

Centralizing configuration

Read environment variables in exactly one module and export a frozen, validated config object. Everywhere else imports that object, so nothing reaches into process.env directly and a missing variable fails loudly at startup rather than mysteriously at runtime.

// src/config/index.js
function required(name) {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required env var: ${name}`);
  }
  return value;
}

export const config = Object.freeze({
  port: Number(process.env.PORT ?? 3000),
  databaseUrl: required("DATABASE_URL"),
  nodeEnv: process.env.NODE_ENV ?? "development",
});

Output:

Error: Missing required env var: DATABASE_URL
    at required (src/config/index.js:4:11)

By layer vs by feature

The structure above groups files by their technical role. That reads well for small and medium apps, but once you have dozens of features, a single change means jumping between routes/, controllers/, and services/ folders. The alternative is to group by feature (sometimes called a “modular” or “vertical slice” layout), where everything for one domain lives together.

src/
├── features/
│   ├── user/
│   │   ├── user.routes.js
│   │   ├── user.controller.js
│   │   ├── user.service.js
│   │   └── user.repository.js
│   └── billing/
│       ├── billing.routes.js
│       └── billing.service.js
├── shared/              # cross-cutting code used by many features
└── app.js
ApproachBest whenStrengthWeakness
By layerSmall/medium apps, few domainsEasy to learn, clear layersFeatures scatter across folders
By featureLarge apps, many domains, teamsHigh cohesion, easy to delete a featureRisk of duplicated cross-cutting code

A practical rule: start by layer, and switch to by-feature when a single folder (say services/) starts holding files from unrelated domains. The two are not exclusive — many large apps group by feature at the top level and keep the same route/controller/service layers inside each feature.

Best practices

  • Keep each layer ignorant of the ones above it: services must never touch req/res, and repositories must never know about HTTP.
  • Inject dependencies through constructors or factories so layers can be tested with fakes; do the wiring in one composition root (app.js).
  • Split app construction (app.js) from process startup (server.js) to make integration tests painless.
  • Parse and validate configuration in a single config/ module and import the frozen result everywhere else.
  • Mirror your source tree in tests/ so a reader can find the test for any file by its path.
  • Start grouped by layer and migrate to grouping by feature only when folders begin mixing unrelated domains.
  • Keep utils//shared/ small and intentional; a sprawling “utils” folder is usually a missing abstraction in disguise.
Last updated June 14, 2026
Was this helpful?