Putting It Together
See how all the pieces connect - build a production-ready API structure that you'll use for real projects.
From Pieces to Puzzle#
You've learned a lot of separate things: Express routes, MongoDB queries, Redis caching, Git workflows. But learning the pieces isn't the same as understanding how they fit together.
It's like knowing how to use flour, eggs, and sugar separately, but not knowing how to bake a cake.
This article shows you the complete picture - how real production APIs are structured and why.
The Layered Architecture#
Think of a restaurant:
- Host (Routes) - Receives customers, directs them to the right place
- Waiter (Controllers) - Takes orders, coordinates between kitchen and customers
- Chef (Services) - Does the actual cooking, knows the recipes
- Pantry (Models) - Stores ingredients, knows what's available
Your API works the same way:
Request → Routes → Controller → Service → Model → Database
↓
Redis (cache)
Each layer has one job. The waiter doesn't cook. The chef doesn't seat customers. This separation makes code easier to understand, test, and change.
Project Structure#
Here's how a production API is typically organized:
my-api/
├── src/
│ ├── index.js # Starts the server
│ ├── app.js # Configures Express
│ ├── config/ # Environment settings
│ │ ├── index.js
│ │ ├── database.js
│ │ └── redis.js
│ ├── routes/ # URL definitions
│ ├── controllers/ # Request handlers
│ ├── services/ # Business logic
│ ├── models/ # Database schemas
│ ├── middleware/ # Auth, validation, errors
│ ├── validators/ # Input validation schemas
│ └── utils/ # Helper functions
├── .env.example # Environment template
├── .gitignore
└── package.json
Let's walk through each piece and see how they connect.
The Entry Point#
Everything starts here:
// src/index.js
import 'dotenv/config';
import { app } from './app.js';
import { connectDB } from './config/database.js';
import { connectRedis } from './config/redis.js';
import { config } from './config/index.js';
async function start() {
try {
// Connect to services before accepting requests
await connectDB();
await connectRedis();
// Now we're ready
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
});
} catch (error) {
console.error('Failed to start:', error);
process.exit(1);
}
}
start();
Notice the order: connect to databases first, then start accepting requests. If the database is down, there's no point starting the server.
The Express App#
This is where Express gets configured:
// src/app.js
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import routes from './routes/index.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import { config } from './config/index.js';
const app = express();
// Security headers (helmet) - protect against common attacks
app.use(helmet());
// CORS - allow cross-origin requests from your frontend
app.use(cors({ origin: config.corsOrigin, credentials: true }));
// Compress responses - faster for clients
app.use(compression());
// Parse JSON request bodies
app.use(express.json({ limit: '10mb' }));
// Your API routes
app.use('/api', routes);
// Simple health check for monitoring
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Handle 404s (must be after all routes)
app.use(notFoundHandler);
// Handle errors (must be last)
app.use(errorHandler);
export { app };
The order matters. Security first, then parsing, then routes, then error handling.
Configuration#
Keep all settings in one place:
// src/config/index.js
export const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
mongoUri: process.env.MONGODB_URI,
redisUrl: process.env.REDIS_URL,
jwtSecret: process.env.JWT_SECRET,
jwtExpiresIn: process.env.JWT_EXPIRES_IN || '7d',
corsOrigin: process.env.CORS_ORIGIN || '*',
};
// Fail fast if required config is missing
const required = ['mongoUri', 'jwtSecret'];
for (const key of required) {
if (!config[key]) {
throw new Error(`Missing required config: ${key}`);
}
}
The required check catches configuration problems immediately - not after your app is running and someone tries to log in.
Routes: The URL Map#
Routes define what URLs your API responds to:
// src/routes/index.js
import { Router } from 'express';
import authRoutes from './auth.js';
import userRoutes from './users.js';
const router = Router();
// Mount route files at their paths
router.use('/auth', authRoutes); // /api/auth/*
router.use('/users', userRoutes); // /api/users/*
export default router;
// src/routes/users.js
import { Router } from 'express';
import * as userController from '../controllers/users.js';
import { authenticate } from '../middleware/auth.js';
import { validate } from '../middleware/validate.js';
import { updateUserSchema } from '../validators/users.js';
const router = Router();
// Public routes
router.get('/', userController.getUsers);
router.get('/:id', userController.getUser);
// Protected routes (require authentication)
router.patch('/:id',
authenticate, // Must be logged in
validate(updateUserSchema), // Must send valid data
userController.updateUser
);
router.delete('/:id',
authenticate,
userController.deleteUser
);
export default router;
Notice how middleware chains work. For PATCH /users/:id, the request goes through authenticate, then validate, then finally updateUser. If any middleware fails, the chain stops.
Controllers: Handling HTTP#
Controllers receive requests and send responses. They don't contain business logic - they coordinate:
// src/controllers/users.js
import * as userService from '../services/users.js';
import { success, paginated, noContent } from '../utils/response.js';
import { NotFoundError, ForbiddenError } from '../utils/errors.js';
export async function getUsers(req, res) {
const { page = 1, limit = 20 } = req.query;
// Let the service do the work
const result = await userService.findAll({
page: Number(page),
limit: Number(limit)
});
// Send paginated response
paginated(res, result.users, {
page: Number(page),
limit: Number(limit),
total: result.total,
pages: Math.ceil(result.total / limit)
});
}
export async function getUser(req, res) {
const user = await userService.findById(req.params.id);
if (!user) {
throw new NotFoundError('User');
}
success(res, user);
}
export async function updateUser(req, res) {
// Authorization check: can only update yourself (unless admin)
if (req.user.id !== req.params.id && req.user.role !== 'admin') {
throw new ForbiddenError('Cannot update other users');
}
const user = await userService.update(req.params.id, req.body);
if (!user) {
throw new NotFoundError('User');
}
success(res, user);
}
export async function deleteUser(req, res) {
await userService.remove(req.params.id);
noContent(res);
}
Controllers are thin. They parse input, call services, and format output. The real work happens in services.
Services: Business Logic#
Services contain the actual logic. They work with models and handle caching:
// src/services/users.js
import { User } from '../models/User.js';
import { cache } from '../utils/cache.js';
const CACHE_TTL = 300; // 5 minutes
export async function findAll({ page = 1, limit = 20 }) {
const skip = (page - 1) * limit;
// Run both queries in parallel
const [users, total] = await Promise.all([
User.find()
.select('-password') // Never return passwords
.skip(skip)
.limit(limit)
.lean(), // Plain objects, not Mongoose documents
User.countDocuments()
]);
return { users, total };
}
export async function findById(id) {
const cacheKey = `user:${id}`;
// Check cache first
const cached = await cache.get(cacheKey);
if (cached) {
return cached;
}
// Not in cache - query database
const user = await User.findById(id)
.select('-password')
.lean();
// Store in cache for next time
if (user) {
await cache.set(cacheKey, user, CACHE_TTL);
}
return user;
}
export async function update(id, data) {
const user = await User.findByIdAndUpdate(id, data, {
new: true, // Return updated document
runValidators: true // Validate the update
})
.select('-password')
.lean();
// Invalidate cache - old data is now stale
await cache.del(`user:${id}`);
return user;
}
export async function remove(id) {
await User.findByIdAndDelete(id);
await cache.del(`user:${id}`);
}
Services are where you see the cache-aside pattern in action: check cache → miss → query database → store in cache → return.
Middleware: Cross-Cutting Concerns#
Middleware handles things that apply across many routes:
// src/middleware/auth.js
import jwt from 'jsonwebtoken';
import { config } from '../config/index.js';
import { UnauthorizedError } from '../utils/errors.js';
export function authenticate(req, res, next) {
// Get token from Authorization header
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw new UnauthorizedError('No token provided');
}
const token = authHeader.split(' ')[1];
try {
// Verify and decode the token
const decoded = jwt.verify(token, config.jwtSecret);
req.user = decoded; // Attach user info to request
next();
} catch (error) {
throw new UnauthorizedError('Invalid token');
}
}
export function authorize(...allowedRoles) {
return (req, res, next) => {
if (!allowedRoles.includes(req.user.role)) {
throw new ForbiddenError('Insufficient permissions');
}
next();
};
}
// src/middleware/error.js
import { config } from '../config/index.js';
export function notFoundHandler(req, res) {
res.status(404).json({
error: {
code: 'NOT_FOUND',
message: `Cannot ${req.method} ${req.path}`
}
});
}
export function errorHandler(err, req, res, next) {
console.error(err);
// Custom errors (our AppError subclasses)
if (err.statusCode) {
return res.status(err.statusCode).json({
error: {
code: err.code,
message: err.message
}
});
}
// Mongoose validation errors
if (err.name === 'ValidationError') {
return res.status(400).json({
error: {
code: 'VALIDATION_ERROR',
message: 'Invalid input',
details: Object.values(err.errors).map(e => ({
field: e.path,
message: e.message
}))
}
});
}
// Unknown errors - don't leak details in production
res.status(500).json({
error: {
code: 'INTERNAL_ERROR',
message: config.env === 'production'
? 'Something went wrong'
: err.message
}
});
}
Custom Errors: Clean Error Handling#
Define your errors once, use everywhere:
// src/utils/errors.js
export class AppError extends Error {
constructor(message, statusCode, code) {
super(message);
this.statusCode = statusCode;
this.code = code;
}
}
export class NotFoundError extends AppError {
constructor(resource = 'Resource') {
super(`${resource} not found`, 404, 'NOT_FOUND');
}
}
export class UnauthorizedError extends AppError {
constructor(message = 'Unauthorized') {
super(message, 401, 'UNAUTHORIZED');
}
}
export class ForbiddenError extends AppError {
constructor(message = 'Forbidden') {
super(message, 403, 'FORBIDDEN');
}
}
export class ValidationError extends AppError {
constructor(message = 'Validation failed') {
super(message, 400, 'VALIDATION_ERROR');
}
}
Now your code reads naturally: throw new NotFoundError('User') instead of manually setting status codes and messages everywhere.
Cache Utilities: Fail Gracefully#
Cache operations should never break your app:
// src/utils/cache.js
import { redis } from '../config/redis.js';
export const cache = {
async get(key) {
try {
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error('Cache get error:', error.message);
return null; // Fail gracefully - just return null
}
},
async set(key, value, ttl = 300) {
try {
await redis.set(key, JSON.stringify(value), { EX: ttl });
} catch (error) {
console.error('Cache set error:', error.message);
// Don't throw - caching is optional
}
},
async del(key) {
try {
await redis.del(key);
} catch (error) {
console.error('Cache del error:', error.message);
}
}
};
If Redis is down, your app still works - it just hits the database every time.
Response Helpers: Consistent Output#
Keep responses consistent across all endpoints:
// src/utils/response.js
export function success(res, data, statusCode = 200) {
res.status(statusCode).json({ data });
}
export function created(res, data) {
res.status(201).json({ data });
}
export function paginated(res, data, pagination) {
res.json({ data, pagination });
}
export function noContent(res) {
res.status(204).send();
}
Environment Variables#
Never hardcode secrets:
# .env.example (commit this)
NODE_ENV=development
PORT=3000
# Database
MONGODB_URI=mongodb://localhost:27017/myapp
# Redis
REDIS_URL=redis://localhost:6379
# Auth
JWT_SECRET=change-this-in-production
JWT_EXPIRES_IN=7d
# CORS
CORS_ORIGIN=http://localhost:3000
# .env (never commit this!)
# Copy from .env.example and fill in real values
The Request Flow#
Let's trace a request through the system:
Client: GET /api/users/123
1. Express receives request
2. Helmet adds security headers
3. CORS checks origin
4. express.json() parses body (none for GET)
5. Routes match /api → routes/index.js → /users/:id → getUser
6. Controller calls userService.findById('123')
7. Service checks cache → miss → queries MongoDB → caches result
8. Controller receives user, calls success(res, user)
9. Client receives { data: { id: '123', name: '...', ... } }
For an error:
Client: GET /api/users/999 (doesn't exist)
1-6. Same as above
7. Service returns null
8. Controller throws NotFoundError('User')
9. Error middleware catches it
10. Client receives { error: { code: 'NOT_FOUND', message: 'User not found' } }
Why This Structure Works#
| Layer | Responsibility | Why Separate? |
|---|---|---|
| Routes | URL → Handler mapping | Change URLs without changing logic |
| Controllers | HTTP in/out | Test business logic without HTTP |
| Services | Business logic | Reuse across controllers, easy to test |
| Models | Data structure | Change database without changing logic |
| Middleware | Cross-cutting | Write once, apply everywhere |
What's Next?#
You now have the structure for production APIs. To make it truly production-ready, consider adding:
- Testing - Jest or Vitest for unit and integration tests
- Validation - Zod schemas in your validators folder
- Logging - Structured logging with pino or winston
- API Documentation - OpenAPI/Swagger specs
- Deployment - Docker, CI/CD, cloud hosting
You're Ready
You have the foundation. The best way to learn more is to build something real. Start a project, make mistakes, debug them, and iterate. Every production API you see started as someone's learning project.
Ready to level up your skills?
Explore more guides and tutorials to deepen your understanding and become a better developer.