Project Structure
How to organize your backend code so you (and your team) can actually find things.
The Messy Drawer Problem#
We've all done it: started a project with a few files, then added more, and suddenly everything's in one folder. Finding anything takes forever. Making changes is scary because you don't know what might break.
Good project structure is like organizing your kitchen. Utensils in one drawer, plates in another, food in the pantry. You know where everything is, and so does anyone else who walks in.
The Layered Architecture#
Production backends use layers. Each layer has one job:
┌─────────────────────────────────────┐
│ Routes (URLs) │ → Maps URLs to handlers
├─────────────────────────────────────┤
│ Controllers (HTTP) │ → Handles requests/responses
├─────────────────────────────────────┤
│ Services (Logic) │ → Business logic
├─────────────────────────────────────┤
│ Models (Data) │ → Database schemas
├─────────────────────────────────────┤
│ Database (Storage) │ → MongoDB, Redis, etc.
└─────────────────────────────────────┘
Think of it like a restaurant:
- Routes = Host who directs you to a table
- Controllers = Waiter who takes your order
- Services = Chef who cooks the food
- Models = Pantry that stores ingredients
The waiter doesn't cook. The chef doesn't seat customers. Each has one job.
The Standard Structure#
Here's how production APIs organize their code:
my-api/
├── src/
│ ├── index.js # Entry point - starts server
│ ├── app.js # Express configuration
│ │
│ ├── config/ # Configuration
│ │ ├── index.js # Main config object
│ │ ├── database.js # Database connection
│ │ └── redis.js # Redis connection
│ │
│ ├── routes/ # URL definitions
│ │ ├── index.js # Route aggregator
│ │ ├── auth.js # /api/auth/*
│ │ └── users.js # /api/users/*
│ │
│ ├── controllers/ # Request handlers
│ │ ├── auth.js
│ │ └── users.js
│ │
│ ├── services/ # Business logic
│ │ ├── auth.js
│ │ └── users.js
│ │
│ ├── models/ # Database schemas
│ │ └── User.js
│ │
│ ├── middleware/ # Express middleware
│ │ ├── auth.js # Authentication
│ │ ├── validate.js # Input validation
│ │ └── error.js # Error handling
│ │
│ ├── validators/ # Validation schemas
│ │ ├── auth.js
│ │ └── users.js
│ │
│ └── utils/ # Helper functions
│ ├── errors.js # Custom error classes
│ ├── response.js # Response helpers
│ ├── logger.js # Logging setup
│ └── cache.js # Cache utilities
│
├── scripts/ # Utility scripts
│ ├── seed.js # Seed database
│ └── migrate.js # Migrations
│
├── tests/ # Tests
│ ├── unit/
│ └── integration/
│
├── .env.example # Environment template
├── .gitignore
├── package.json
└── README.md
Why This Structure?#
1. Separation of Concerns#
Each folder has one purpose:
| Folder | Purpose | Changes When |
|---|---|---|
| routes/ | URL patterns | API endpoints change |
| controllers/ | HTTP handling | Request/response format changes |
| services/ | Business logic | Business rules change |
| models/ | Data structure | Database schema changes |
| middleware/ | Cross-cutting | Auth, validation, logging changes |
2. Easy Navigation#
Need to fix a bug in user creation?
- Start at
routes/users.jsto find the endpoint - Follow to
controllers/users.jsfor the handler - Check
services/users.jsfor the logic - Look at
models/User.jsfor the schema
3. Testability#
Each layer can be tested independently:
// Test service without HTTP
import { createUser } from '../services/users.js';
test('createUser hashes password', async () => {
const user = await createUser({ email: 'test@test.com', password: 'secret' });
expect(user.password).not.toBe('secret');
});
The Entry Points#
index.js - Server Startup#
// src/index.js
import 'dotenv/config';
import { app } from './app.js';
import { connectDB } from './config/database.js';
import { logger } from './utils/logger.js';
import { config } from './config/index.js';
async function start() {
await connectDB();
app.listen(config.port, () => {
logger.info(`Server running on port ${config.port}`);
});
}
start();
app.js - Express Config#
// src/app.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import routes from './routes/index.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
const app = express();
// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json());
// Routes
app.use('/api', routes);
// Health check
app.get('/health', (req, res) => res.json({ status: 'ok' }));
// Error handling
app.use(notFoundHandler);
app.use(errorHandler);
export { app };
Route Aggregation#
Routes are split by resource and combined in an index:
// src/routes/index.js
import { Router } from 'express';
import authRoutes from './auth.js';
import userRoutes from './users.js';
import productRoutes from './products.js';
const router = Router();
router.use('/auth', authRoutes); // /api/auth/*
router.use('/users', userRoutes); // /api/users/*
router.use('/products', productRoutes); // /api/products/*
export default router;
// src/routes/users.js
import { Router } from 'express';
import * as userController from '../controllers/users.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { createUserSchema, updateUserSchema } from '../validators/users.js';
const router = Router();
router.get('/', userController.list);
router.get('/:id', userController.get);
router.post('/', validate(createUserSchema), userController.create);
router.patch('/:id', authenticate, validate(updateUserSchema), userController.update);
router.delete('/:id', authenticate, userController.remove);
export default router;
Feature-Based Alternative#
For larger apps, organize by feature instead of layer:
src/
├── features/
│ ├── auth/
│ │ ├── auth.routes.js
│ │ ├── auth.controller.js
│ │ ├── auth.service.js
│ │ ├── auth.validator.js
│ │ └── index.js
│ │
│ ├── users/
│ │ ├── users.routes.js
│ │ ├── users.controller.js
│ │ ├── users.service.js
│ │ ├── users.validator.js
│ │ ├── User.model.js
│ │ └── index.js
│ │
│ └── products/
│ └── ...
│
├── shared/
│ ├── middleware/
│ ├── utils/
│ └── config/
│
├── app.js
└── index.js
When to Use Feature-Based#
| Layer-Based | Feature-Based |
|---|---|
| Small-medium apps | Large apps |
| Few developers | Many developers |
| Simple domain | Complex domain |
| Learning/starting | Scaling up |
Start Simple
Start with layer-based. It's easier to understand and reorganize later. Feature-based adds complexity that small projects don't need.
File Naming Conventions#
Be consistent:
# Files: lowercase with hyphens or dots
user.controller.js # or users.controller.js
auth.service.js
validation.middleware.js
# Models: PascalCase (they export classes)
User.js
Product.js
OrderItem.js
# Test files
users.test.js
users.spec.js
Index Files#
Use index files to create clean imports:
// src/models/index.js
export { User } from './User.js';
export { Product } from './Product.js';
export { Order } from './Order.js';
// Now you can import like this:
import { User, Product, Order } from '../models/index.js';
// Instead of:
import { User } from '../models/User.js';
import { Product } from '../models/Product.js';
import { Order } from '../models/Order.js';
Common Mistakes#
1. Fat Controllers#
// BAD: Business logic in controller
export async function createUser(req, res) {
const { email, password } = req.body;
// All this logic should be in a service
const existing = await User.findOne({ email });
if (existing) throw new ConflictError('Email exists');
const hashed = await bcrypt.hash(password, 10);
const user = await User.create({ email, password: hashed });
await sendWelcomeEmail(user.email);
res.status(201).json({ data: user });
}
// GOOD: Controller just coordinates
export async function createUser(req, res) {
const user = await userService.create(req.body);
res.status(201).json({ data: user });
}
2. Circular Dependencies#
// user.service.js imports order.service.js
// order.service.js imports user.service.js
// BOOM: Circular dependency
Fix: Create a shared service or restructure.
3. Deep Nesting#
// BAD
src/api/v1/resources/users/handlers/create.js
// GOOD
src/controllers/users.js
Keep it flat. Deep nesting makes navigation painful.
Key Takeaways#
- Separate concerns - Routes, controllers, services, models each have one job
- Group by layer for small/medium apps, by feature for large apps
- Index files create clean imports
- Keep controllers thin - They coordinate, services do the work
- Stay consistent - Pick conventions and stick to them
The Principle
If someone joins your team tomorrow, can they find what they're looking for in under 30 seconds? Good structure makes that possible.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.