NodeJSProject Structure

Project Structure

How you organize your files shapes how quickly new developers find things, how well concerns stay separated, and how painlessly the codebase scales. There's no single "correct" Node project layout, but there are two dominant strategies — feature-based (group by domain/feature) and layer-based (group by technical role) — and a handful of conventions that nearly every serious project shares: separating source from output, keeping entry points thin, co-locating tests, and giving each piece of the stack a predictable home. This page covers both strategies, the file and folder conventions that apply to either, and how to decide which to use.

The two main strategies

Text
LAYER-BASED (technical role)       FEATURE-BASED (domain / business concept)
src/                               src/
  routes/                            users/
    users.ts                           users.router.ts
    orders.ts                          users.service.ts
  controllers/                         users.repository.ts
    users.controller.ts                users.types.ts
    orders.controller.ts               users.test.ts
  services/                          orders/
    users.service.ts                   orders.router.ts
    orders.service.ts                  orders.service.ts
  repositories/                      ...
    users.repository.ts            shared/
  models/                            db.ts
    ...                              logger.ts
Layer-based groups by technical role; feature-based groups by domain — feature-based scales better as the codebase grows
**Layer-based** (all routes together, all services together) is intuitive for small projects because the categories are obvious, but it breaks down as you add features: adding a new entity requires touching five separate directories, and "all the user code" is spread across all of them. **Feature-based** (everything for `users` together) keeps related code co-located — a change to the users feature touches one directory, not five. It scales better, makes feature boundaries explicit, and maps naturally onto teams (one team owns the `orders/` module). The practical advice: start with layer-based for simple apps (it's fine up to ~5 routes), and switch to feature-based once the codebase grows or a second developer joins. Mixing is also common — feature modules internally layered, with a `shared/` directory for cross-cutting infrastructure.
A typical Express project layout

Text
src/
  app.ts           ← create the Express app (no .listen) — testable without a server
  server.ts        ← import app, call .listen() — the real entry point
  config.ts        ← validated, frozen config object
  routes/          ← (or feature folders) router files
  controllers/     ← thin: parse req, call service, send res
  services/        ← business logic, no HTTP knowledge
  repositories/    ← all database access, no business logic
  models/          ← TypeScript types / Zod schemas / ORM models
  middleware/      ← auth, logging, error handler, rate limiter
  utils/           ← pure helper functions
dist/              ← compiled output (git-ignored)
tests/             ← (or __tests__/ next to each module)
.env.example       ← documents required variables (no real values)
package.json
tsconfig.json
Split `app.ts` (Express setup) from `server.ts` (`.listen()`) so tests can import the app without binding a port
The most impactful structural decision for an Express project is separating **`app.ts`** (creates and configures the Express app — routes, middleware, error handlers) from **`server.ts`** (the actual entry point that calls `app.listen()`). Tests import `app.ts` directly and make requests with `supertest` — no port, no real server. This one split makes the whole app testable without network bindings. The rest of the conventional layout layers below it: routes call **controllers** (parse request, call service, send response — no business logic), controllers call **services** (pure business logic, no HTTP), services call **repositories** (all database access, no business logic). This layering is where [separation of concerns](/nodejs/separation-of-concerns) becomes concrete.
File and folder conventions
  • Co-locate tests next to the code they test (users.service.test.ts beside users.service.ts) — easier to find, less likely to go stale than a distant tests/ tree.

  • Keep entry points thin (server.ts should be ~10 lines: import app, call listen, handle startup errors).

  • One file, one concern — a 600-line file is a smell; split it along logical boundaries before it gets there.

  • index.ts barrels in each module folder export the public API, letting consumers import from users/ instead of users/users.service.

  • Name files consistently*.router.ts, *.service.ts, *.repository.ts, *.test.ts — so the type is obvious from the name.

  • shared/ or common/ for utilities, config, and middleware used across features — never import from a sibling feature directory.

Consistent naming, thin entry points, co-located tests, and barrel exports make a codebase navigable as it grows
Conventions compound: when every service is in `*.service.ts` and every router in `*.router.ts`, a developer new to the codebase finds the right file in seconds instead of minutes. **Co-locating tests** (`users.service.test.ts` next to `users.service.ts`) keeps them visible — a distant `tests/` directory is easy to forget when refactoring. **Barrel `index.ts`** files expose a feature's public interface (`export { UsersService } from './users.service'`) so importers don't need to know the internal file layout, and renaming an internal file doesn't break all importers. The goal is a structure where you can read a file path and immediately know what it does, where to add new code, and what it's safe to change — that predictability is the dividend of early structural discipline.
Next
See how those layers fit together architecturally: [Layered Architecture](/nodejs/layered-architecture).