Skip to Content
Lesson 7

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 (or index.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.

routes/userRoutes.js
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.

controllers/userController.js
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.

routes/userRoutes.js
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.

server.js
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.

  1. Install dotenv: npm install dotenv
  2. Create a .env file: In the very root of your project, create a file named exactly .env.
  3. Add secrets to .env: This file uses a simple KEY=VALUE format.
    NODE_ENV=development PORT=3000 API_KEY=your_secret_api_key_here
  4. 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
  5. Load the variables: In your main server.js, require and configure dotenv at the very top.
server.js
// 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

  1. Start with a simple server in a single server.js file that has two routes: GET /posts and POST /posts.
  2. Create routes and controllers directories.
  3. Refactor the routes and logic into routes/postRoutes.js and controllers/postController.js.
  4. Update your main server.js to import and mount the post router at the /api/posts path.

Activity 2: Externalize Configuration

  1. Install dotenv.
  2. Create a .env file and define a PORT variable (e.g., PORT=8080).
  3. Create a .gitignore file and add .env and node_modules to it.
  4. Modify your main server.js to load dotenv at the top and use process.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.


References