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
.env
to a.gitignore
file.
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
.env
file: In the very root of your project, create a file named exactly.env
. - Add secrets to
.env
: This file uses a simpleKEY=VALUE
format.NODE_ENV=development PORT=3000 API_KEY=your_secret_api_key_here
- IMPORTANT: Ignore the
.env
file: You must add.env
to your.gitignore
file. 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 configuredotenv
at 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.js
file that has two routes:GET /posts
andPOST /posts
. - Create
routes
andcontrollers
directories. - Refactor the routes and logic into
routes/postRoutes.js
andcontrollers/postController.js
. - Update your main
server.js
to import and mount the post router at the/api/posts
path.
Activity 2: Externalize Configuration
- Install
dotenv
. - Create a
.env
file and define aPORT
variable (e.g.,PORT=8080
). - Create a
.gitignore
file and add.env
andnode_modules
to it. - Modify your main
server.js
to loaddotenv
at the top and useprocess.env.PORT
when 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.