Project Structure & Configuration
Workplace Context
As your application grows from a single file into a complex project, keeping everything in one server.js file becomes unmanageable. Furthermore, the application needs to behave differently in a local development environment versus a live production environment. It needs a different database connection string and a secret API key that must never be committed to source control. Your tech lead has asked you to refactor the current server into a professional, maintainable, and secure structure.
Learning Objectives
By the end of this lesson, you will be able to:
- Explain the “separation of concerns” principle.
- Refactor routes into separate files using
express.Router. - Organize business logic into “controller” functions.
- Securely manage configuration and secrets using environment variables with
dotenv. - Explain the importance of adding
.envto a.gitignorefile.
The Problem with a Single File
So far, we’ve been writing small servers in a single server.js file. This is fine for examples, but a real application with dozens of routes, database logic, and configuration would become thousands of lines long. This is often called “spaghetti code” because the logic is tangled and hard to follow.
The solution is separation of concerns: each part of the application should have its own distinct responsibility and should not know or care about how other parts are implemented.
A common and effective structure for an Express application separates these concerns into directories:
config/: Contains configuration files. We won’t use this today, but it’s good to know.routes/: Defines the API routes and which controller function handles each route. Its job is to be the “switchboard.”controllers/: Contains the actual business logic. It handles the request, interacts with data, and sends the response.server.js(orindex.js): The main entry point. Its only job is to start the server and wire up the middleware and routes.
Refactoring to a Professional Structure
1. Modular Routing with express.Router
Express provides express.Router to create modular, mountable route handlers. It acts like a “mini-application,” capable of having its own routes and middleware.
Let’s refactor a messy server.js file.
Before:
// server.js
app.get('/users', (req, res) => { /* ...database logic... */ });
app.post('/users', (req, res) => { /* ...database logic... */ });
app.get('/products', (req, res) => { /* ...database logic... */ });After: We create a dedicated router for user-related endpoints.
const express = require('express');
const router = express.Router();
// This path is now `/` because it's relative to where
// it will be mounted ('/api/users').
router.get('/', (req, res) => {
res.send('Get all users');
});
router.post('/', (req, res) => {
res.send('Create a new user');
});
module.exports = router;2. Separating Logic with Controllers
Next, we pull the callback functions out of the router and into a controller. This is powerful because the router only cares about the path and HTTP method, while the controller only cares about the business logic.
const getAllUsers = (req, res) => {
// In a real app, you'd fetch this from a database
const users = [{ id: 1, name: 'John Doe' }];
res.json(users);
};
const createUser = (req, res) => {
// Logic to create a user...
res.status(201).json({ message: 'User created' });
};
// Export an object containing the functions
module.exports = {
getAllUsers,
createUser,
};3. Wiring It All Together
Now, the router file becomes even simpler. It just imports the controller functions and assigns them to the routes.
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController'); // Import controller
// Assign the function reference directly to the route
router.get('/', userController.getAllUsers);
router.post('/', userController.createUser);
module.exports = router;Finally, the main server.js file becomes incredibly clean. Its only job is to set up the server and “mount” the routers.
const express = require('express');
const app = express();
const userRoutes = require('./routes/userRoutes');
// Mount the router on a specific path, e.g., '/api/users'
app.use('/api/users', userRoutes);
// You could have another one for products
// app.use('/api/products', productRoutes);
const PORT = 3000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));Configuration with dotenv
Another critical part of project structure is managing configuration. You should never hardcode values like database passwords or API keys in your code. This is a major security risk.
The industry standard is to use environment variables. The dotenv package makes this easy for local development.
- Install
dotenv:npm install dotenv - Create a
.envfile: In the very root of your project, create a file named exactly.env. - Add secrets to
.env: This file uses a simpleKEY=VALUEformat.NODE_ENV=development PORT=3000 API_KEY=your_secret_api_key_here - IMPORTANT: Ignore the
.envfile: You must add.envto your.gitignorefile. This tells Git to never track this file or commit your secrets to a repository.node_modules .env - Load the variables: In your main
server.js, require and configuredotenvat the very top.
// This line should be at the absolute top of your entry file.
// It loads the variables from .env into Node's process.env object.
require('dotenv').config();
const express = require('express');
const app = express();
const userRoutes = require('./routes/userRoutes');
app.use('/api/users', userRoutes);
// Use the PORT from the .env file, with a fallback for production
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => console.log(`Server running on port ${PORT}`));Now, your configuration is safely separated from your code. When you deploy, you won’t upload the .env file; instead, you’ll set the environment variables in your hosting provider’s dashboard.
Activities
Activity 1: Refactor Your Server
- Start with a simple server in a single
server.jsfile that has two routes:GET /postsandPOST /posts. - Create
routesandcontrollersdirectories. - Refactor the routes and logic into
routes/postRoutes.jsandcontrollers/postController.js. - Update your main
server.jsto import and mount the post router at the/api/postspath.
Activity 2: Externalize Configuration
- Install
dotenv. - Create a
.envfile and define aPORTvariable (e.g.,PORT=8080). - Create a
.gitignorefile and add.envandnode_modulesto it. - Modify your main
server.jsto loaddotenvat the top and useprocess.env.PORTwhen starting the server.
Knowledge Check
What is the primary benefit of separating code into routes and controllers directories?
- Select an answer to view feedback.
Why is it critical to add the .env file to .gitignore?
- Select an answer to view feedback.
Summary
In this lesson, you learned how to transition from a single-file application to a well-structured, professional project. You used express.Router to separate your routes and “controller” functions to separate your business logic. Most importantly, you learned how to securely manage configuration and secrets using environment variables with dotenv, a cornerstone of writing maintainable, scalable, and secure backend applications.