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.
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
- Pagination:
/users?page=2&limit=20 - Filtering:
/products?category=electronics&minPrice=100 - Sorting:
/posts?sortBy=date&order=desc - Searching:
/search?q=express+routing&exact=true - Expanding/Including related data:
/users/123?include=posts,comments
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
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;
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:
- Resource-based URLs: Use nouns, not verbs, in endpoints (e.g.,
/usersnot/getUsers) - HTTP methods for operations: Use appropriate HTTP methods for different operations:
- GET for retrieving data
- POST for creating resources
- PUT or PATCH for updating resources
- DELETE for removing resources
- Consistent response formats: Return data in a consistent format (usually JSON)
- Proper HTTP status codes: Use appropriate status codes:
- 200 OK for successful operations
- 201 Created for resource creation
- 400 Bad Request for client errors
- 401 Unauthorized for authentication issues
- 404 Not Found for missing resources
- 500 Internal Server Error for server errors
- Hierarchical relationships: Use nested routes for related resources (e.g.,
/users/:userId/posts) - Versioning: Include API version in URL or headers (e.g.,
/api/v1/users) - Pagination: Implement pagination for large collections
- Filtering, sorting, and searching: Support query parameters for data manipulation
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
- Advanced routing techniques allow for flexible URL matching and parameter handling
- Route parameters capture values from the URL path, while query parameters provide additional filtering and options
- Express Router enables modular route organization and hierarchical route structures
- The Controller pattern separates route definitions from implementation logic, improving maintainability
- Following RESTful design principles creates consistent, intuitive APIs
- Advanced patterns like Service Layers and Controller Factories can further improve code organization
- Well-designed routing and controller architecture scales effectively as applications grow
In our next lecture, we'll explore RESTful API development with Express, focusing on request validation, response formatting, and comprehensive error handling.