Module 16: JavaScript Backend

Express.js Routing & Controllers

Advanced Routing Concepts

Routing is the mechanism by which Express matches HTTP requests to specific handler functions. While we covered basic routing in our first lecture, today we'll dive deeper into advanced routing concepts and techniques for organizing routes into a maintainable architecture.

Analogy: Think of Express routing as a sophisticated mail sorting system. When letters (HTTP requests) arrive, they need to be directed to the right department (route handlers) based on their address (URL), type (HTTP method), and sometimes even their contents (parameters, headers). A well-designed routing system ensures that each letter reaches exactly the right person who can handle it properly.

graph TD A[Client Request] --> B{Router} B -->|GET /users| C[List Users] B -->|POST /users| D[Create User] B -->|GET /users/:id| E[Get User Details] B -->|PUT /users/:id| F[Update User] B -->|DELETE /users/:id| G[Delete User] B -->|No Match| H[404 Handler] C & D & E & F & G & H --> I[Response] I --> J[Client]

Route Parameters

Route parameters are named URL segments used to capture values specified at their position in the URL. The captured values are stored in the req.params object.


// Basic route parameter
app.get('/users/:userId', (req, res) => {
  const userId = req.params.userId;
  res.send(`Fetching user with ID: ${userId}`);
});

// Multiple route parameters
app.get('/users/:userId/posts/:postId', (req, res) => {
  const { userId, postId } = req.params;
  res.send(`Fetching post ${postId} for user ${userId}`);
});

// Optional parameters (with ?)
app.get('/products/:category/:productId?', (req, res) => {
  const { category, productId } = req.params;
  
  if (productId) {
    res.send(`Fetching product ${productId} in category ${category}`);
  } else {
    res.send(`Fetching all products in category ${category}`);
  }
});
            

Parameter Validation with Regular Expressions

You can use regular expressions to restrict what parameter values are accepted:


// Only accept numeric IDs
app.get('/users/:userId(\\d+)', (req, res) => {
  const userId = req.params.userId; // Will only be digits
  res.send(`Fetching user with numeric ID: ${userId}`);
});

// Accept specific string patterns
app.get('/files/:filename([a-zA-Z0-9_\\.]+)', (req, res) => {
  const filename = req.params.filename;
  res.send(`Fetching file: ${filename}`);
});

// Accept specific values using alternation
app.get('/products/:category(electronics|books|clothing)', (req, res) => {
  const category = req.params.category; // Will only be one of the specified values
  res.send(`Browsing ${category} category`);
});
            

Real-world example: GitHub's API uses route parameters extensively. For instance, to access repository information, they use routes like /repos/:owner/:repo where :owner and :repo are parameters that match the username and repository name. This makes their API intuitive and RESTful, as the URL itself encodes the resource hierarchy.

Query Parameters

While route parameters are part of the URL path, query parameters are appended to the URL after a question mark and are stored in the req.query object.


// Basic query parameters
// URL: /search?q=express&limit=10
app.get('/search', (req, res) => {
  const { q, limit } = req.query;
  res.send(`Searching for "${q}" with limit ${limit || 'default'}`);
});

// Processing multiple values for the same parameter
// URL: /filter?tags=javascript&tags=express&tags=node
app.get('/filter', (req, res) => {
  const tags = req.query.tags;
  // tags can be a string or an array depending on how many values were provided
  const tagList = Array.isArray(tags) ? tags : [tags];
  
  res.send(`Filtering by tags: ${tagList.join(', ')}`);
});

// Parsing numeric values
// URL: /products?minPrice=10&maxPrice=100
app.get('/products', (req, res) => {
  const minPrice = parseInt(req.query.minPrice) || 0;
  const maxPrice = parseInt(req.query.maxPrice) || Infinity;
  
  res.send(`Finding products between $${minPrice} and $${maxPrice}`);
});
            

Practical Applications of Query Parameters

Analogy: If route parameters are like the address on an envelope, query parameters are like special handling instructions written on the envelope. They don't change the destination (route) but provide additional context about how the letter should be processed once it arrives.

Route Handlers

Route handlers are the functions that are executed when a route is matched. Express provides flexibility in how these handlers are defined and structured.

Multiple Handler Functions

You can specify multiple handler functions for a single route:


// Multiple handlers as individual arguments
app.get('/example',
  (req, res, next) => {
    console.log('First handler');
    req.customData = { timestamp: Date.now() };
    next();
  },
  (req, res, next) => {
    console.log('Second handler');
    req.customData.processed = true;
    next();
  },
  (req, res) => {
    console.log('Final handler');
    res.json({
      message: 'All handlers executed',
      data: req.customData
    });
  }
);
            

Array of Handler Functions

You can also use an array of handler functions:


// Define middleware functions
const validateUser = (req, res, next) => {
  // Check if user exists
  if (!req.query.userId) {
    return res.status(400).send('User ID required');
  }
  next();
};

const logRequest = (req, res, next) => {
  console.log(`Request to get user: ${req.query.userId}`);
  next();
};

const fetchUserData = (req, res, next) => {
  // Simulate fetching user data
  req.userData = { id: req.query.userId, name: 'Example User' };
  next();
};

// Use an array of handlers
app.get('/users',
  [validateUser, logRequest, fetchUserData],
  (req, res) => {
    res.json(req.userData);
  }
);
            

Route Handlers with Error Handling

Route handlers can include error handling logic:


// Async route handler with try/catch
app.get('/users/:id', async (req, res, next) => {
  try {
    const user = await UserModel.findById(req.params.id);
    
    if (!user) {
      return res.status(404).json({ message: 'User not found' });
    }
    
    res.json(user);
  } catch (error) {
    next(error); // Pass to error-handling middleware
  }
});
            

Real-world example: The popular payment gateway Stripe uses multiple handler functions in their Express-based API. They have separate handlers for authentication, rate limiting, request validation, business logic, and response formatting. This modular approach allows them to maintain a secure, robust API while keeping their codebase maintainable.

The Express Router

For larger applications, organizing all routes in a single file becomes unwieldy. Express provides the Router object to help modularize your routes.

The Express Router is a mini-Express application that provides routing capabilities without the full application functionality. It's essentially a middleware that can be used to define routes in separate files and then mount them on your main application.

Creating and Using a Router


// In a separate file: routes/users.js
const express = require('express');
const router = express.Router();

// Define routes on this router
router.get('/', (req, res) => {
  res.send('List of all users');
});

router.get('/:id', (req, res) => {
  res.send(`User with ID: ${req.params.id}`);
});

router.post('/', (req, res) => {
  res.send('Create a new user');
});

// Export the router
module.exports = router;
            

// In your main app.js file
const express = require('express');
const app = express();
const usersRouter = require('./routes/users');

// Mount the router on a specific path
app.use('/api/users', usersRouter);

// Now requests to /api/users and /api/users/:id will be handled by the usersRouter
            

Router-Level Middleware

You can apply middleware specifically to a router:


// In routes/admin.js
const express = require('express');
const router = express.Router();

// Router-specific middleware
router.use((req, res, next) => {
  if (!req.user || !req.user.isAdmin) {
    return res.status(403).send('Admin access required');
  }
  next();
});

router.get('/dashboard', (req, res) => {
  res.send('Admin Dashboard');
});

router.get('/users', (req, res) => {
  res.send('Admin User Management');
});

module.exports = router;
            

// In app.js
app.use('/admin', authenticate, adminRouter);
// The authenticate middleware will run first, followed by the router's middleware
            
graph TB A[Express Application] --> B["/api" Router] A --> C["/admin" Router] A --> D["/auth" Router] B --> E["/users" Sub-Router] B --> F["/products" Sub-Router] E --> G["GET /"] E --> H["GET /:id"] E --> I["POST /"] E --> J["PUT /:id"] E --> K["DELETE /:id"] F --> L["GET /"] F --> M["GET /:id"] F --> N["POST /"]

Analogy: The Express Router is like a departmental mail room within a large organization. Each department (router) handles its own subset of mail (requests), with its own sorting rules and processors. The main mail room (Express application) delegates letters to the appropriate department based on their address prefix (path), making the whole organization more efficient and manageable.

Router Parameters

The Router object can define parameters that are common to all routes defined on that router:


// In routes/users.js
const express = require('express');
const router = express.Router();

// Define a parameter handler for 'userId'
router.param('userId', (req, res, next, userId) => {
  console.log(`Looking up user: ${userId}`);
  
  // Fetch user data and attach it to the request
  // For demonstration, we're using a mock user
  req.user = { id: userId, name: 'Example User' };
  
  next();
});

// Routes using the 'userId' parameter
router.get('/:userId', (req, res) => {
  res.json(req.user);
});

router.get('/:userId/posts', (req, res) => {
  res.json({ user: req.user, posts: [] });
});

router.put('/:userId', (req, res) => {
  res.json({ message: `Updated user ${req.user.id}` });
});

module.exports = router;
            

In the example above, whenever a route contains :userId, the parameter handler will be executed before the route handler, fetching and attaching user data to the request object. This avoids code duplication across route handlers.

Multiple Router Parameters


// Define handlers for multiple parameters
router.param('userId', (req, res, next, userId) => {
  // Fetch user
  req.user = { id: userId, name: 'User Name' };
  next();
});

router.param('postId', (req, res, next, postId) => {
  // Fetch post
  req.post = { id: postId, title: 'Post Title', authorId: req.user.id };
  
  // Verify the post belongs to the user
  if (req.post.authorId !== req.user.id) {
    return res.status(403).json({ message: 'Not authorized to access this post' });
  }
  
  next();
});

// Route using both parameters
router.get('/:userId/posts/:postId', (req, res) => {
  res.json({ user: req.user, post: req.post });
});
            

Real-world example: Content management systems like WordPress use router parameters to fetch entities before processing routes. For example, when accessing a blog post edit page, a parameter handler would first fetch the post and its metadata, verify user permissions, and attach the post to the request object before rendering the edit interface.

The Controller Pattern

As your application grows, keeping all route handler logic in the route definitions can lead to bloated route files. The Controller pattern separates route definitions from their implementation logic, improving code organization and maintainability.

Basic Controller Structure


// controllers/userController.js
const User = require('../models/User');

// Controller object with methods for each route handler
const userController = {
  // Get all users
  getAllUsers: async (req, res, next) => {
    try {
      const users = await User.find();
      res.json(users);
    } catch (error) {
      next(error);
    }
  },
  
  // Get a single user by ID
  getUserById: async (req, res, next) => {
    try {
      const user = await User.findById(req.params.id);
      
      if (!user) {
        return res.status(404).json({ message: 'User not found' });
      }
      
      res.json(user);
    } catch (error) {
      next(error);
    }
  },
  
  // Create a new user
  createUser: async (req, res, next) => {
    try {
      const newUser = new User(req.body);
      const savedUser = await newUser.save();
      res.status(201).json(savedUser);
    } catch (error) {
      next(error);
    }
  },
  
  // Update a user
  updateUser: async (req, res, next) => {
    try {
      const updatedUser = await User.findByIdAndUpdate(
        req.params.id,
        req.body,
        { new: true, runValidators: true }
      );
      
      if (!updatedUser) {
        return res.status(404).json({ message: 'User not found' });
      }
      
      res.json(updatedUser);
    } catch (error) {
      next(error);
    }
  },
  
  // Delete a user
  deleteUser: async (req, res, next) => {
    try {
      const deletedUser = await User.findByIdAndDelete(req.params.id);
      
      if (!deletedUser) {
        return res.status(404).json({ message: 'User not found' });
      }
      
      res.json({ message: 'User deleted successfully' });
    } catch (error) {
      next(error);
    }
  }
};

module.exports = userController;
            

Using Controllers with Routes


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

// Map routes to controller methods
router.get('/', userController.getAllUsers);
router.get('/:id', userController.getUserById);
router.post('/', userController.createUser);
router.put('/:id', userController.updateUser);
router.delete('/:id', userController.deleteUser);

module.exports = router;
            
Express MVC Architecture Routes Define URL endpoints GET /users GET /users/:id POST /users PUT /users/:id DELETE /users/:id Controllers Handle request logic getAllUsers() getUserById() createUser() updateUser() deleteUser() Validation, Error handling Models Define data structure User Schema - username - email - password - createdAt Database Interactions

Analogy: The Controller pattern is like a restaurant where the front desk staff (Routes) take orders and direct customers, but the actual cooking (business logic) is handled by specialized chefs (Controllers) in the kitchen. The chefs follow recipes (Models) to consistently prepare dishes. This division of responsibilities allows each person to focus on their expertise, resulting in a more efficient and higher-quality experience.

Organizing Controllers by Resource Type

In a RESTful API, it's common to organize controllers by resource type, with each controller handling all operations for a specific resource.

Example Project Structure


project/
├── controllers/
│   ├── userController.js
│   ├── productController.js
│   ├── orderController.js
│   └── authController.js
├── routes/
│   ├── users.js
│   ├── products.js
│   ├── orders.js
│   └── auth.js
├── models/
│   ├── User.js
│   ├── Product.js
│   └── Order.js
├── middleware/
│   ├── auth.js
│   ├── validation.js
│   └── errorHandler.js
└── app.js
            

Controller Methods Naming Conventions

For consistency, it's helpful to follow a naming convention for controller methods:

HTTP Method URL Path Controller Method Description
GET /resources getAll[Resources] List all resources
GET /resources/:id get[Resource]ById Get a specific resource
POST /resources create[Resource] Create a new resource
PUT/PATCH /resources/:id update[Resource] Update a resource
DELETE /resources/:id delete[Resource] Delete a resource

Real-world example: The popular e-commerce platform Shopify organizes their Express-based API using a controller pattern. They have separate controllers for products, collections, orders, customers, etc. Each controller encapsulates all the business logic for interacting with that specific resource type, making their codebase more maintainable as they scale to support millions of stores worldwide.

Advanced Controller Techniques

Service Layer Pattern

For more complex applications, you might want to introduce a service layer between your controllers and models. The service layer contains business logic, while controllers focus on HTTP request handling.


// services/userService.js
const User = require('../models/User');

const userService = {
  getAllUsers: async () => {
    return await User.find();
  },
  
  getUserById: async (userId) => {
    return await User.findById(userId);
  },
  
  createUser: async (userData) => {
    const user = new User(userData);
    return await user.save();
  },
  
  // More business logic methods...
};

module.exports = userService;
            

// controllers/userController.js
const userService = require('../services/userService');

const userController = {
  getAllUsers: async (req, res, next) => {
    try {
      const users = await userService.getAllUsers();
      res.json(users);
    } catch (error) {
      next(error);
    }
  },
  
  getUserById: async (req, res, next) => {
    try {
      const user = await userService.getUserById(req.params.id);
      
      if (!user) {
        return res.status(404).json({ message: 'User not found' });
      }
      
      res.json(user);
    } catch (error) {
      next(error);
    }
  },
  
  // More controller methods...
};

module.exports = userController;
            

Controller Method Factories

To reduce boilerplate code for common CRUD operations, you can create controller method factories:


// utilities/controllerFactory.js
const createCrudController = (Model) => {
  return {
    getAll: async (req, res, next) => {
      try {
        const items = await Model.find();
        res.json(items);
      } catch (error) {
        next(error);
      }
    },
    
    getById: async (req, res, next) => {
      try {
        const item = await Model.findById(req.params.id);
        
        if (!item) {
          return res.status(404).json({ message: 'Item not found' });
        }
        
        res.json(item);
      } catch (error) {
        next(error);
      }
    },
    
    create: async (req, res, next) => {
      try {
        const newItem = new Model(req.body);
        const savedItem = await newItem.save();
        res.status(201).json(savedItem);
      } catch (error) {
        next(error);
      }
    },
    
    update: async (req, res, next) => {
      try {
        const updatedItem = await Model.findByIdAndUpdate(
          req.params.id,
          req.body,
          { new: true, runValidators: true }
        );
        
        if (!updatedItem) {
          return res.status(404).json({ message: 'Item not found' });
        }
        
        res.json(updatedItem);
      } catch (error) {
        next(error);
      }
    },
    
    delete: async (req, res, next) => {
      try {
        const deletedItem = await Model.findByIdAndDelete(req.params.id);
        
        if (!deletedItem) {
          return res.status(404).json({ message: 'Item not found' });
        }
        
        res.json({ message: 'Item deleted successfully' });
      } catch (error) {
        next(error);
      }
    }
  };
};

module.exports = { createCrudController };
            

// controllers/productController.js
const Product = require('../models/Product');
const { createCrudController } = require('../utilities/controllerFactory');

// Create base CRUD methods
const productController = createCrudController(Product);

// Add custom methods specific to products
productController.getByCategory = async (req, res, next) => {
  try {
    const products = await Product.find({ category: req.params.category });
    res.json(products);
  } catch (error) {
    next(error);
  }
};

productController.getTopRated = async (req, res, next) => {
  try {
    const products = await Product.find().sort({ rating: -1 }).limit(10);
    res.json(products);
  } catch (error) {
    next(error);
  }
};

module.exports = productController;
            

RESTful API Design Principles

When designing an Express API using the Router and Controller pattern, it's valuable to follow RESTful design principles:

graph LR A[Client] -->|Request| B[Express App] B --> C{Route Matching} C -->|/api/users| D[User Router] C -->|/api/products| E[Product Router] C -->|/api/orders| F[Order Router] D --> G[User Controller] E --> H[Product Controller] F --> I[Order Controller] G --> J[User Model] H --> K[Product Model] I --> L[Order Model] J & K & L --> M[(Database)] J & K & L -->|Response Data| G & H & I G & H & I -->|Formatted Response| D & E & F D & E & F --> B B -->|Response| A

Practice Activities

Activity 1: RESTful Resource API

Create a complete RESTful API for a resource of your choice (e.g., books, tasks, events) with:

  • A Router module defining all standard RESTful routes
  • A Controller with all CRUD operations
  • Support for query parameters (filtering, sorting, pagination)
  • Proper error handling
  • Appropriate HTTP status codes for responses

Test your API with tools like Postman or curl.

Activity 2: Nested Resources API

Extend your API to include a related resource (e.g., authors for books, categories for tasks) with:

  • Nested routes for the related resource (e.g., /books/:bookId/authors)
  • Controllers for both the main and related resources
  • Parameter handlers to load resources and verify relationships
  • Proper error handling for invalid relationships

Test the API's ability to maintain data integrity and relationships.

Activity 3: Controller Factory Pattern

Implement a controller factory pattern for your application:

  • Create a generic CRUD controller factory
  • Implement model-specific controllers that extend the base functionality
  • Add custom methods for specific business logic
  • Use the controllers with routers for at least three different resources

Compare the code quantity and quality before and after implementing the factory pattern.

Key Takeaways

In our next lecture, we'll explore RESTful API development with Express, focusing on request validation, response formatting, and comprehensive error handling.