Application Structure

Difficulty: Beginner

As Express applications grow beyond a simple hello world, organizing your code becomes critical. A well-structured project is easier to understand, maintain, test, and extend. While Express does not enforce any particular project structure, the community has converged on several proven patterns.

The most common pattern is the MVC-like structure adapted for APIs: Models define your data schema and database interactions, Controllers handle HTTP request/response logic (parsing input, calling services, sending responses), and Services contain the business logic (data processing, validation rules, external API calls). Routes define which URL patterns map to which controllers. This separation of concerns means each file has one clear responsibility.

A typical Express project structure looks like this: the root contains package.json, .env, and configuration files. The src/ directory contains your application code, organized into subdirectories: routes/ for route definitions, controllers/ for request handlers, services/ for business logic, models/ for database schemas, middleware/ for custom middleware, utils/ for helper functions, and config/ for configuration management.

The key principle is that each layer only communicates with its adjacent layers. Routes call controllers, controllers call services, services call models. A controller should never access the database directly - it delegates that to a service. This makes each layer independently testable and replaceable. You can swap your database from PostgreSQL to MongoDB by only changing the model layer, without touching controllers or routes.

In practice, many teams organize by feature (module) rather than by type. Instead of having all routes in one directory and all controllers in another, you group everything related to a feature together: users/users.routes.js, users/users.controller.js, users/users.service.js. This approach scales better for large applications because related code stays together, and teams can work on different features without merge conflicts.

Code examples

Project Directory Structure

// Typical Express project structure:
//
// my-app/
// ├── src/
// │   ├── index.js              # App entry point
// │   ├── app.js                # Express app configuration
// │   ├── routes/
// │   │   ├── index.js           # Route aggregator
// │   │   └── users.routes.js    # User routes
// │   ├── controllers/
// │   │   └── users.controller.js
// │   ├── services/
// │   │   └── users.service.js
// │   ├── models/
// │   │   └── user.model.js
// │   ├── middleware/
// │   │   ├── auth.js
// │   │   └── errorHandler.js
// │   ├── utils/
// │   │   └── logger.js
// │   └── config/
// │       └── index.js
// ├── package.json
// ├── .env
// └── .gitignore

// --- src/app.js ---
const express = require('express');
const routes = require('./routes');

const app = express();

app.use(express.json());
app.use('/api', routes);

module.exports = app;

Separating app configuration (app.js) from server startup (index.js) allows you to import the app in tests without starting the server. The routes aggregator collects all route modules and mounts them under a common prefix.

Separation of Concerns: Route, Controller, Service

// --- routes/users.routes.js ---
const express = require('express');
const router = express.Router();
const usersController = require('../controllers/users.controller');

router.get('/', usersController.getAllUsers);
router.get('/:id', usersController.getUserById);
router.post('/', usersController.createUser);

module.exports = router;

// --- controllers/users.controller.js ---
const usersService = require('../services/users.service');

exports.getAllUsers = (req, res) => {
  const users = usersService.getAll();
  res.json(users);
};

exports.getUserById = (req, res) => {
  const user = usersService.getById(parseInt(req.params.id));
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
};

exports.createUser = (req, res) => {
  const user = usersService.create(req.body);
  res.status(201).json(user);
};

// --- services/users.service.js ---
let users = [{ id: 1, name: 'Alice', email: 'alice@example.com' }];
let nextId = 2;

exports.getAll = () => users;

exports.getById = (id) => users.find(u => u.id === id);

exports.create = (data) => {
  const user = { id: nextId++, name: data.name, email: data.email };
  users.push(user);
  return user;
};

Routes only define URL patterns and map them to controller methods. Controllers handle HTTP concerns (request parsing, response formatting). Services contain pure business logic with no knowledge of HTTP. This separation makes each layer independently testable.

Entry Point and App Separation

// --- src/app.js (Express configuration) ---
const express = require('express');
const cors = require('cors');
const usersRoutes = require('./routes/users.routes');
const errorHandler = require('./middleware/errorHandler');

const app = express();

// Global middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/users', usersRoutes);

// Error handling (must be last)
app.use(errorHandler);

module.exports = app;

// --- src/index.js (Server startup) ---
const app = require('./app');
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

// --- middleware/errorHandler.js ---
module.exports = (err, req, res, next) => {
  console.error(err.stack);
  res.status(err.status || 500).json({
    error: err.message || 'Internal Server Error'
  });
};

app.js configures middleware and routes but does not start the server. index.js imports the app and starts listening. This separation is essential for testing - you can import app.js in your test files and use supertest to make requests without starting a real server.

Key points

Concepts covered

Project Layout, Separation of Concerns, MVC Pattern, Routes, Controllers, Services