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.
| Layer | Responsibility | Knows about HTTP? | Knows about the DB? |
|---|---|---|---|
| Route | Maps a path/method to a controller | Yes | No |
| Controller | Parses input, calls a service, shapes the response | Yes | No |
| Service | Business logic, orchestration, validation | No | No |
| Repository | Queries and persists data | No | Yes |
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 fromserver.js(which callslisten). 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
| Approach | Best when | Strength | Weakness |
|---|---|---|---|
| By layer | Small/medium apps, few domains | Easy to learn, clear layers | Features scatter across folders |
| By feature | Large apps, many domains, teams | High cohesion, easy to delete a feature | Risk 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.