Node.js Project Structure Best Practices
A poorly organized codebase slows down every single task. Adding a new feature requires tracing logic across random files. Onboarding a new engineer becomes a treasure hunt. And as the project grows, the friction compounds until even small changes feel risky.
The Node.js project structure you choose is not just about aesthetics—it’s about how fast your team can ship, how safely they can refactor, and how long your application remains maintainable. This guide gives you a battle-tested blueprint used by startups and enterprises alike to structure Node.js backends that scale gracefully from a single developer to a full engineering organization.
By the end, you’ll be able to:
- Design a folder layout that separates concerns clearly
- Apply layered architecture principles to any Node.js application
- Configure your project for multiple environments securely
- Organize tests, scripts, and deployment artifacts logically
- Avoid the 12 most common structural mistakes
Why Project Structure Matters
“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
— Martin Fowler
Your folder layout is the architecture. It signals how modules communicate, where business rules live, and where side effects are allowed. When that signal is clear, you get:
- Scalability: you can add features without destabilizing existing code.
- Maintainability: bugs are isolated and easier to fix.
- Team collaboration: developers can work in parallel without merge conflicts.
- Testing: test suites map naturally to source modules.
- Deployment: consistent Dockerfiles and CI/CD pipelines.
The chain reaction is straightforward:
Conversely, a chaotic structure leads to inconsistent patterns, tight coupling, and fear of change. A little discipline early on prevents enormous technical debt later.
Common Types of Node.js Projects
Not every Node.js project needs an enterprise monolith. Match the structure to the project’s complexity.
| Project Type | Typical Scope | Recommended Structure |
|---|---|---|
| Simple script | A single index.js, maybe 1–2 dependencies | One file; no folders needed |
| CLI tool | Local development utility | src/, bin/, lib/ with a clear entry point |
| REST API | Exposes resources over HTTP | Layered: controllers, services, repositories |
| GraphQL server | Schema-first API | Resolvers, schemas, loaders |
| WebSocket service | Real-time communication | Event handlers, rooms, connection management |
| Microservice | Single bounded context | Independent repo with the layered structure |
| Enterprise backend | Multiple domains, high scale | Monorepo with shared packages, or modular monolith |
[!TIP] Start with a modular monolith even if you eventually plan to break into microservices. A well-structured monolith is easier to split later than a messy one.
Recommended Enterprise Project Structure
Below is a production-ready layout that has proven itself in dozens of real-world systems. It’s opinionated, but every folder has a clear reason for existing.
project-root/
├── src/
│ ├── app.js # Express/Fastify app setup
│ ├── server.js # Server entry point (listen)
│ ├── config/ # Environment-based configuration
│ ├── controllers/ # Request handlers (thin)
│ ├── routes/ # Route definitions & middleware chains
│ ├── middleware/ # Custom middleware (auth, rate limiting)
│ ├── services/ # Business logic
│ ├── repositories/ # Data access (database queries)
│ ├── models/ # Data models (Mongoose schemas, Prisma)
│ ├── utils/ # Pure helper functions
│ ├── validators/ # Input validation schemas
│ ├── errors/ # Custom error classes
│ ├── events/ # Domain events (optional)
│ ├── jobs/ # Background job processors
│ └── types/ # TypeScript type definitions
├── tests/
│ ├── unit/
│ ├── integration/
│ └── fixtures/
├── scripts/ # DB migrations, seed scripts, dev helpers
├── docs/ # API documentation or architectural docs
├── public/ # Static assets (images, CSS, JS)
├── uploads/ # Temporary uploaded files (gitignored)
├── .env.example # Template for environment variables
├── .gitignore
├── package.json
├── Dockerfile
└── docker-compose.yml # (optional)
Folder Responsibilities
| Folder | Responsibility |
|---|---|
controllers/ | Parse request input, call services, send response. Keep them thin—no business logic. |
routes/ | Map HTTP methods and paths to controllers. Group by resource (e.g., users.routes.js). |
services/ | Orchestrate business rules, transactions, external API calls. Framework-agnostic. |
repositories/ | Encapsulate database queries. Swap databases without touching business logic. |
models/ | Schema definitions (Prisma, Mongoose, Sequelize models). |
middleware/ | Reusable middleware: authentication, authorization, request logging, compression. |
validators/ | Validation schemas (Joi, Zod). Separate from controllers for testability. |
errors/ | Custom error classes (e.g., NotFoundError, ValidationError) and centralized error handling. |
config/ | Load environment variables and export a typed config object. |
utils/ | Stateless helpers: string manipulation, date formatting, encryption. |
events/ | Domain events and their handlers (for eventual consistency patterns). |
jobs/ | Scheduled tasks or queue processors (Bull, Agenda). |
[!NOTE] Use this as a starting point, not a dogma. For a simple CRUD API, you may combine
controllers/andservices/initially—but keep the separation in mind as complexity grows.
Layered Architecture
The folder structure naturally enforces a layered architecture—one of the most effective patterns for backend applications.
Each layer depends only on the layer directly beneath it:
- Routes/Controllers → Services: never access databases or third-party APIs directly.
- Services → Repositories: call repository methods to fetch/persist data.
- Repositories → Database driver/ORM: abstract the query execution.
This separation means:
- You can swap Express for Fastify without rewriting business logic.
- Unit testing services becomes trivial—mock the repository layer.
- New team members know exactly where to look for a piece of logic.
Organizing Source Code
Controllers (Presentation)
Controllers should be thin. They extract data from the request, call the appropriate service, and format the response. No database calls, no complex conditionals.
// users.controller.js
const userService = require('../services/user.service');
async function getUser(req, res, next) {
try {
const user = await userService.findById(req.params.id);
res.json({ data: user });
} catch (err) {
next(err);
}
}
Services (Business Logic)
Services contain the core application rules. They are framework-agnostic and should not know about HTTP.
// user.service.js
const userRepository = require('../repositories/user.repository');
const NotFoundError = require('../errors/NotFoundError');
async function findById(id) {
const user = await userRepository.findById(id);
if (!user) throw new NotFoundError('User not found');
return user;
}
Repositories (Data Access)
Repositories encapsulate all database queries. If you ever migrate from MongoDB to PostgreSQL, you only change this layer.
// user.repository.js
const User = require('../models/User');
async function findById(id) {
return User.findById(id).exec();
}
Validation
Keep validation schemas in a dedicated folder. This allows reuse across routes and makes them testable independently.
// validators/user.validator.js
const Joi = require('joi');
const createUserSchema = Joi.object({
name: Joi.string().required(),
email: Joi.string().email().required(),
});
Error Handling
Centralize error handling with custom error classes and Express error middleware.
// errors/AppError.js
class AppError extends Error {
constructor(message, statusCode) {
super(message);
this.statusCode = statusCode;
}
}
Configuration Management
Follow the Twelve-Factor App methodology: store configuration in environment variables, not in files committed to the repository.
Create a config/index.js file that reads from process.env and provides sensible defaults.
// config/index.js
require('dotenv').config();
module.exports = {
port: process.env.PORT || 3000,
dbUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
nodeEnv: process.env.NODE_ENV || 'development',
};
Always provide an .env.example file so new developers know which variables are needed.
# .env.example
PORT=3000
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=change-me
NODE_ENV=development
[!WARNING] Never commit
.envfiles or hardcode secrets. Use secret management services (AWS Secrets Manager, HashiCorp Vault) for production.
Dependency Management
Keep package.json clean and intentional. Only install what you actually import. Audit regularly.
Best practices:
- Commit lock files (
package-lock.jsonorpnpm-lock.yaml) to Git. - Use
dependenciesvsdevDependenciesstrictly. - Pin exact versions for critical production packages if your team values stability over automatic patches.
- Define scripts for common development tasks:
{
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js",
"test": "jest",
"lint": "eslint src/",
"migrate": "node scripts/migrate.js"
}
}
Logging and Error Handling
Structure your logging so it’s consistent and easily searchable in production.
Recommended approach:
- Use a structured logger like
pinoorwinston. - Create a logger instance in a shared module (
src/utils/logger.js). - Centralize Express error handling with middleware.
// middleware/errorHandler.js
const logger = require('../utils/logger');
function errorHandler(err, req, res, next) {
logger.error({ err, reqId: req.id }, err.message);
const status = err.statusCode || 500;
res.status(status).json({ message: err.message || 'Internal Server Error' });
}
[!TIP] Never log sensitive data (passwords, tokens, PII). Redact or mask those fields.
API Organization
For REST APIs, group related endpoints by resource. Use versioning from day one (e.g., /api/v1/users).
Versioning
Prefix all routes with a version segment. This protects clients from breaking changes.
// app.js
app.use('/api/v1/users', userRoutes);
Route Files
Each resource gets its own route file.
// routes/user.routes.js
const router = require('express').Router();
const userController = require('../controllers/user.controller');
const { validate } = require('../middleware/validate');
const { createUserSchema } = require('../validators/user.validator');
router.post('/', validate(createUserSchema), userController.createUser);
router.get('/:id', userController.getUser);
module.exports = router;
Database Layer
Choose between an ORM (Prisma, Sequelize, TypeORM) or a query builder (Knex). Regardless, hide implementation details behind the repository pattern.
Migrations and seeds: keep them in a dedicated scripts/ or prisma/ folder.
prisma/
├── schema.prisma
├── migrations/
└── seed.js
Repositories should accept a transaction object for complex operations, allowing services to compose multiple repository calls atomically.
Testing Structure
Mirror your src structure inside tests/. This makes it trivial to find the test for any source file.
tests/
├── unit/
│ └── services/
│ └── user.service.test.js
├── integration/
│ └── routes/
│ └── user.routes.test.js
└── fixtures/
└── users.json
Use fixtures for reusable test data and factories (like faker) to generate dynamic records.
[!NOTE] Unit tests should cover services and utilities. Integration tests should hit the database (use a test database or in-memory instance).
Static Assets and File Uploads
- Place public assets (images, CSS, client-side JS) in a
public/folder, exposed viaexpress.static. - Use an
uploads/folder for temporary file storage, but never serve it directly. Process uploads through a service that saves to a cloud storage bucket (S3, GCS).
Add uploads/ to .gitignore:
uploads/
Scripts and Automation
Keep one-off scripts in a scripts/ folder:
| Script Type | Example |
|---|---|
| Database seed | scripts/seed.js |
| Database migration | scripts/migrate.js |
| Data import/export | scripts/import-users.js |
| Dev helpers | scripts/generate-component.js |
These scripts are run manually or in CI; they are not part of the application runtime.
Docker and Deployment
Structure your deployment artifacts at the root level.
Dockerfile
docker-compose.yml
.env.example
A multi-stage Dockerfile keeps your production image small:
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src/ ./src/
CMD ["node", "src/server.js"]
For Kubernetes, add a k8s/ directory with deployment and service manifests. For CI/CD, a .github/workflows/ folder keeps pipelines close to the code.
Scaling a Node.js Project
Your project structure should evolve as the system grows, not be optimized for “day 100” on day one.
- Small project (1 dev): simple
src/with a few files; maybe no service layer. - Startup (2–5 devs): add
services/,repositories/,config/. - Team scale (5–20 devs): introduce domain-based modules (
src/modules/users/,src/modules/orders/). - Enterprise: modular monolith or monorepo with shared libraries.
- Microservices: each service gets its own repository with the same internal structure.
[!WARNING] Don’t start with microservices. Premature distribution adds massive complexity. Extract services only when organizational boundaries or scaling requirements demand it.
Common Mistakes
Avoid these structural traps that plague Node.js codebases:
- Putting everything in one file – a 2000‑line
app.jsis impossible to navigate. - Mixing business logic with routes – makes logic untestable and coupled to Express.
- Hardcoded configuration – secrets in code get leaked and prevent environment switching.
- Ignoring environment variables – leads to “works on my machine” bugs.
- Overusing a generic
utils/folder – it becomes a dumping ground for unrelated code. - Circular dependencies – require chains that loop cause
undefinedexports and silent failures. - Poor naming conventions –
thing.js,helper.js,misc.jstell nobody anything. - Lack of modularity – entire domains coupled together; change one, break another.
- Missing tests – untested code cannot be refactored with confidence.
- Flat folder with 50 files – no grouping by feature or layer.
- Tight coupling to frameworks – wrapping every line in Express-specific code.
- Premature complexity – adding CQRS, event sourcing, or microservices before they’re needed.
Solution: apply the layered architecture described earlier. It naturally prevents most of these issues.
Best Practices Checklist
Use this as a code review checklist for your next project bootstrap.
- Folder structure follows layered architecture (controllers, services, repositories).
- Each file has a single, clear responsibility.
- Business logic lives in services, never in routes or controllers.
- Database access is behind a repository interface.
- Configuration is loaded from environment variables, with
.env.exampleprovided. - Error handling is centralized with custom error classes.
- Validation schemas are separated from controllers.
- Logging is structured and uses a shared logger.
- Tests mirror the source structure.
- Dockerfile uses multi-stage builds for smaller images.
- Lock files are committed;
node_modulesare ignored. - Scripts and migrations are kept in a dedicated folder.
Frequently Asked Questions
1. How many folders should a Node.js project have?
There is no magic number. Start with the minimum needed to separate concerns cleanly, then add folders as patterns emerge. Avoid creating empty folders “just in case.”
2. Should controllers access the database directly?
No. Controllers should call services, and services should call repositories. This keeps business logic centralized and testable.
3. Where should validation logic live?
In a dedicated validators/ folder, using libraries like Joi or Zod. It can be applied as middleware on routes.
4. Should I use the Repository Pattern?
Yes, for any non-trivial application. It abstracts the data source and makes swapping databases or mocking easier.
5. When should I split into microservices?
When a domain grows large enough to be managed by a separate team, or when you need independent scaling and deployment. Start with a well-structured monolith.
6. Where should environment variables be stored?
Locally in a .env file (never committed). In production, inject them via your deployment platform or a secrets manager.
7. What should I put in the utils/ folder?
Only pure, stateless helper functions that are used across multiple modules. Avoid turning it into a junk drawer.
8. How do I avoid circular dependencies?
Follow the dependency direction: routes → controllers → services → repositories → models. Never import something from a higher layer.
9. Should I use TypeScript?
TypeScript adds significant maintainability for larger teams. The folder structure remains nearly identical; add a types/ folder for shared types.
10. Where do I put database migrations?
In a scripts/ or prisma/ folder, depending on your ORM. Run them manually or as part of your CI/CD pipeline.
11. How do I structure a GraphQL API?
Use folders for schemas/, resolvers/, and loaders/ instead of routes/ and controllers/, but keep the service/repository layers the same.
12. Should I have a middleware/ folder even for a small project?
Yes. At minimum, your error-handling middleware should live there. It keeps app.js clean from the start.
Summary
A scalable Node.js project structure is one of the highest-leverage decisions you can make early in a project’s life. By separating concerns into controllers, services, and repositories, you create a codebase that is easy to test, easy to onboard, and easy to evolve.
The recommended enterprise layout presented here is not a rigid template—it’s a proven foundation. Adapt it to your team’s size and project needs, but always respect the direction of dependencies and the separation of business logic from infrastructure.
Now that your project is organized professionally, dive deeper into the core concepts that power every Node.js application:
- Understanding the Node.js Event Loop – the runtime engine behind your code
- Async Programming in Node.js – mastering non‑blocking execution
- Build a REST API with Express – put your structure to work
- Dockerize a Node.js Application – prepare for production deployment
A clean structure gives you the confidence to build, refactor, and ship features faster. Keep it clean from day one, and your future self (and your teammates) will thank you.