When building scalable and maintainable backend applications, proper architecture isn't just a nice-to-have—it's essential. In this article, we'll dive deep into the journey of an HTTP request through a well-structured backend system, exploring each layer's responsibility and how they work together.
Why Separate Concerns?
Before we jump into the request lifecycle, let's understand why we organize code into different layers:
- Maintainability: Each layer has a single responsibility, making bugs easier to locate and fix
- Scalability: You can optimize or scale individual layers without touching others
- Testability: Isolated layers are easier to unit test
- Reusability: Business logic in services can be reused across different controllers
- Security: Clear separation helps implement security at appropriate layers
The Request Lifecycle: A Journey Through Layers
Let's follow a typical request from the moment it hits your server until a response is sent back.
1. Routing: The Entry Point
When a client sends a request, it first hits your server on a specific port and route:
// Node.js/Express example
app.post('/api/users', createUserController);
app.get('/api/users/:id', getUserController);
// Go example
http.HandleFunc("/api/users", createUserHandler)
http.HandleFunc("/api/users/", getUserHandler)
The router matches the URL pattern and HTTP method, then directs the request to the appropriate handler.
2. Middlewares: The Gatekeepers
Middlewares are functions that execute before your controller. They have access to the request, response, and a special next() function that passes control to the next middleware or controller.
Key characteristics of middlewares:
- They execute in the order they're defined (order matters!)
- They can modify the request/response objects
- They can end the request-response cycle early
- They're optional but powerful
Common middleware use cases:
// Authentication middleware
function authMiddleware(req, res, next) {
const token = req.headers.authorization;
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, SECRET_KEY);
req.user = decoded; // Add user info to request context
next(); // Pass control to next middleware or controller
} catch (error) {
return res.status(401).json({ error: 'Invalid token' });
}
}
// Logging middleware
function loggingMiddleware(req, res, next) {
console.log(`${req.method} ${req.url} - ${new Date().toISOString()}`);
next();
}
// Request body parsing middleware (built-in in Express)
app.use(express.json()); // Parses JSON bodies
app.use(express.urlencoded({ extended: true })); // Parses URL-encoded bodies
// Using middlewares
app.post('/api/users',
loggingMiddleware,
authMiddleware,
createUserController
);
# Python/Flask example
from functools import wraps
def require_auth(f):
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.headers.get('Authorization')
if not token:
return jsonify({'error': 'No token provided'}), 401
try:
decoded = jwt.decode(token, SECRET_KEY)
g.user = decoded # Store in request context
return f(*args, **kwargs)
except:
return jsonify({'error': 'Invalid token'}), 401
return decorated_function
@app.route('/api/users', methods=['POST'])
@require_auth
def create_user():
# Controller code here
pass
3. Request Context: Shared State for a Request
Request context is a storage mechanism that holds metadata for a specific request throughout its lifecycle. Think of it as a backpack that travels with the request, carrying useful information.
Why use request context?
- Avoid passing the same data through every function parameter
- Store authentication info, user roles, request IDs
- Share data between middlewares and controllers
// Node.js example - using res.locals or custom middleware
function authMiddleware(req, res, next) {
const token = req.headers.authorization;
const decoded = jwt.verify(token, SECRET_KEY);
// Store in request context
req.user = {
id: decoded.userId,
email: decoded.email,
role: decoded.role
};
next();
}
function createUserController(req, res) {
// Access user info from context instead of decoding token again
console.log(`Request by user: ${req.user.email}`);
// ... rest of controller logic
}
# Python/Flask example - using 'g' object
from flask import g
@app.before_request
def load_user():
token = request.headers.get('Authorization')
if token:
decoded = jwt.decode(token, SECRET_KEY)
g.user_id = decoded['user_id']
g.user_role = decoded['role']
@app.route('/api/posts', methods=['POST'])
def create_post():
# Access from context
user_id = g.user_id
# ... rest of controller logic
// Go example - using context
func authMiddleware(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := r.Header.Get("Authorization")
decoded, err := verifyToken(token)
if err != nil {
http.Error(w, "Unauthorized", 401)
return
}
// Store in request context
ctx := context.WithValue(r.Context(), "user_id", decoded.UserID)
ctx = context.WithValue(ctx, "user_role", decoded.Role)
// Pass new context to next handler
next(w, r.WithContext(ctx))
}
}
func createPostHandler(w http.ResponseWriter, r *http.Request) {
// Retrieve from context
userID := r.Context().Value("user_id").(string)
userRole := r.Context().Value("user_role").(string)
// ... rest of handler logic
}
4. Controllers: Request Handlers
Controllers are the traffic directors of your application. They handle HTTP-specific concerns and orchestrate the flow of data.
Controller responsibilities:
- Extract data from the request
- Validate and transform input
- Call service layer with processed data
- Format and send HTTP response
// Node.js/Express example
async function createUserController(req, res) {
try {
// Step 1: Extract data from request
const { name, email, password } = req.body;
const userRole = req.user.role; // From request context
// Step 2: Validate input
if (!name || !email || !password) {
return res.status(400).json({
error: 'Missing required fields'
});
}
if (!isValidEmail(email)) {
return res.status(400).json({
error: 'Invalid email format'
});
}
// Step 3: Transform data (set defaults, normalize)
const userData = {
name: name.trim(),
email: email.toLowerCase(),
password: password,
role: 'user', // default role
createdBy: req.user.id // from context
};
// Step 4: Call service layer
const newUser = await userService.createUser(userData);
// Step 5: Send HTTP response
return res.status(201).json({
success: true,
data: newUser
});
} catch (error) {
// Handle errors appropriately
if (error.code === 'EMAIL_EXISTS') {
return res.status(409).json({
error: 'Email already registered'
});
}
return res.status(500).json({
error: 'Internal server error'
});
}
}
// GET request with query parameters
async function getUsersController(req, res) {
try {
// Extract from query params
const {
page = 1,
limit = 10,
sortBy = 'createdAt', // default sort
order = 'desc' // default order
} = req.query;
// Validate and transform
const options = {
page: parseInt(page),
limit: Math.min(parseInt(limit), 100), // max 100
sortBy,
order: order.toLowerCase()
};
// Call service
const users = await userService.getUsers(options);
res.status(200).json({
success: true,
data: users
});
} catch (error) {
res.status(500).json({
error: 'Failed to fetch users'
});
}
}
# Python/Flask example
@app.route('/api/users', methods=['POST'])
@require_auth
def create_user_controller():
try:
# Step 1: Extract data (Flask automatically parses JSON)
data = request.get_json()
name = data.get('name')
email = data.get('email')
password = data.get('password')
# Step 2: Validate
if not all([name, email, password]):
return jsonify({'error': 'Missing required fields'}), 400
if not is_valid_email(email):
return jsonify({'error': 'Invalid email format'}), 400
# Step 3: Transform
user_data = {
'name': name.strip(),
'email': email.lower(),
'password': password,
'role': 'user',
'created_by': g.user_id # from context
}
# Step 4: Call service
new_user = user_service.create_user(user_data)
# Step 5: Send response
return jsonify({
'success': True,
'data': new_user
}), 201
except EmailExistsError:
return jsonify({'error': 'Email already registered'}), 409
except Exception as e:
return jsonify({'error': 'Internal server error'}), 500
Important: Controllers should NOT contain business logic. They're just coordinators between HTTP and your business layer.
5. Services: Business Logic Layer
The service layer is where your application's business logic lives. Services should be framework-agnostic—they shouldn't know anything about HTTP, requests, or responses.
Service layer principles:
- Pure functions focused on business logic
- No HTTP-specific code
- Can be called from controllers, background jobs, CLI commands, etc.
- Orchestrates multiple repository calls if needed
// userService.js
class UserService {
constructor(userRepository, emailService, hashService) {
this.userRepository = userRepository;
this.emailService = emailService;
this.hashService = hashService;
}
async createUser(userData) {
// Business logic validation
const existingUser = await this.userRepository.findByEmail(
userData.email
);
if (existingUser) {
throw new Error('EMAIL_EXISTS');
}
// Business rule: hash password before storing
const hashedPassword = await this.hashService.hash(
userData.password
);
// Create user
const user = await this.userRepository.create({
...userData,
password: hashedPassword
});
// Business logic: send welcome email
await this.emailService.sendWelcomeEmail(user.email, user.name);
// Don't return sensitive data
const { password, ...safeUser } = user;
return safeUser;
}
async getUsers(options) {
// Business rule: users can only see active accounts
const filters = {
...options,
status: 'active'
};
const users = await this.userRepository.findAll(filters);
// Remove sensitive fields
return users.map(user => {
const { password, ...safeUser } = user;
return safeUser;
});
}
async updateUserProfile(userId, updates) {
// Business validation
if (updates.email) {
const existingUser = await this.userRepository.findByEmail(
updates.email
);
if (existingUser && existingUser.id !== userId) {
throw new Error('EMAIL_TAKEN');
}
}
// Business rule: can't change role through profile update
delete updates.role;
const updatedUser = await this.userRepository.update(
userId,
updates
);
const { password, ...safeUser } = updatedUser;
return safeUser;
}
}
module.exports = new UserService(
userRepository,
emailService,
hashService
);
# user_service.py
class UserService:
def __init__(self, user_repository, email_service, hash_service):
self.user_repository = user_repository
self.email_service = email_service
self.hash_service = hash_service
def create_user(self, user_data):
# Business logic validation
existing_user = self.user_repository.find_by_email(
user_data['email']
)
if existing_user:
raise EmailExistsError('Email already registered')
# Hash password
hashed_password = self.hash_service.hash(user_data['password'])
# Create user
user = self.user_repository.create({
**user_data,
'password': hashed_password
})
# Send welcome email
self.email_service.send_welcome_email(
user['email'],
user['name']
)
# Remove sensitive data
safe_user = {k: v for k, v in user.items() if k != 'password'}
return safe_user
def get_users(self, options):
filters = {**options, 'status': 'active'}
users = self.user_repository.find_all(filters)
return [
{k: v for k, v in user.items() if k != 'password'}
for user in users
]
6. Repositories: Database Abstraction Layer
Repositories handle all database operations. They abstract away the database implementation details, making it easy to switch databases or ORMs.
Repository responsibilities:
- Construct database queries
- Execute queries
- Map database results to application models
- Handle database-specific errors
// userRepository.js
class UserRepository {
constructor(database) {
this.db = database;
}
async create(userData) {
const query = `
INSERT INTO users (name, email, password, role, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
`;
const values = [
userData.name,
userData.email,
userData.password,
userData.role,
userData.createdBy
];
try {
const result = await this.db.query(query, values);
return result.rows[0];
} catch (error) {
if (error.code === '23505') { // Unique violation
throw new Error('EMAIL_EXISTS');
}
throw error;
}
}
async findByEmail(email) {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await this.db.query(query, [email]);
return result.rows[0] || null;
}
async findAll(options) {
const { page, limit, sortBy, order, status } = options;
const offset = (page - 1) * limit;
const query = `
SELECT * FROM users
WHERE status = $1
ORDER BY ${sortBy} ${order}
LIMIT $2 OFFSET $3
`;
const result = await this.db.query(query, [status, limit, offset]);
return result.rows;
}
async update(userId, updates) {
const fields = Object.keys(updates);
const values = Object.values(updates);
const setClause = fields
.map((field, index) => `${field} = $${index + 2}`)
.join(', ');
const query = `
UPDATE users
SET ${setClause}, updated_at = NOW()
WHERE id = $1
RETURNING *
`;
const result = await this.db.query(query, [userId, ...values]);
return result.rows[0];
}
async delete(userId) {
const query = 'DELETE FROM users WHERE id = $1';
await this.db.query(query, [userId]);
}
}
module.exports = new UserRepository(database);
# user_repository.py
class UserRepository:
def __init__(self, database):
self.db = database
def create(self, user_data):
query = """
INSERT INTO users (name, email, password, role, created_by)
VALUES (%s, %s, %s, %s, %s)
RETURNING *
"""
values = (
user_data['name'],
user_data['email'],
user_data['password'],
user_data['role'],
user_data['created_by']
)
try:
cursor = self.db.execute(query, values)
return cursor.fetchone()
except IntegrityError:
raise EmailExistsError('Email already exists')
def find_by_email(self, email):
query = "SELECT * FROM users WHERE email = %s"
cursor = self.db.execute(query, (email,))
return cursor.fetchone()
def find_all(self, options):
page = options['page']
limit = options['limit']
offset = (page - 1) * limit
query = f"""
SELECT * FROM users
WHERE status = %s
ORDER BY {options['sortBy']} {options['order']}
LIMIT %s OFFSET %s
"""
cursor = self.db.execute(
query,
(options['status'], limit, offset)
)
return cursor.fetchall()
Putting It All Together: Folder Structure
Here's how to organize your codebase:
project/
├── src/
│ ├── controllers/
│ │ ├── userController.js
│ │ ├── postController.js
│ │ └── authController.js
│ │
│ ├── services/
│ │ ├── userService.js
│ │ ├── postService.js
│ │ └── emailService.js
│ │
│ ├── repositories/
│ │ ├── userRepository.js
│ │ ├── postRepository.js
│ │ └── commentRepository.js
│ │
│ ├── middlewares/
│ │ ├── authMiddleware.js
│ │ ├── errorHandler.js
│ │ ├── validation.js
│ │ └── rateLimiter.js
│ │
│ ├── routes/
│ │ ├── userRoutes.js
│ │ ├── postRoutes.js
│ │ └── index.js
│ │
│ ├── models/
│ │ ├── User.js
│ │ └── Post.js
│ │
│ ├── utils/
│ │ ├── validators.js
│ │ └── helpers.js
│ │
│ └── app.js
│
├── tests/
├── config/
└── package.json
Complete Example: User Registration Flow
Let's see how all layers work together for a user registration request:
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { validateRegistration } = require('../middlewares/validation');
const rateLimiter = require('../middlewares/rateLimiter');
router.post(
'/register',
rateLimiter, // Middleware 1: Rate limiting
validateRegistration, // Middleware 2: Input validation
userController.register // Controller
);
module.exports = router;
// middlewares/validation.js
function validateRegistration(req, res, next) {
const { name, email, password } = req.body;
if (!name || name.length < 2) {
return res.status(400).json({
error: 'Name must be at least 2 characters'
});
}
if (!email || !isValidEmail(email)) {
return res.status(400).json({
error: 'Invalid email address'
});
}
if (!password || password.length < 8) {
return res.status(400).json({
error: 'Password must be at least 8 characters'
});
}
next(); // Pass to next middleware or controller
}
// controllers/userController.js
const userService = require('../services/userService');
exports.register = async (req, res) => {
try {
const { name, email, password } = req.body;
// Transform data
const userData = {
name: name.trim(),
email: email.toLowerCase(),
password: password
};
// Call service layer
const user = await userService.registerUser(userData);
// Send response
res.status(201).json({
success: true,
message: 'Registration successful',
data: user
});
} catch (error) {
if (error.message === 'EMAIL_EXISTS') {
return res.status(409).json({
error: 'Email already registered'
});
}
res.status(500).json({
error: 'Registration failed'
});
}
};
// services/userService.js
const userRepository = require('../repositories/userRepository');
const emailService = require('./emailService');
const bcrypt = require('bcrypt');
exports.registerUser = async (userData) => {
// Check if user exists
const existingUser = await userRepository.findByEmail(userData.email);
if (existingUser) {
throw new Error('EMAIL_EXISTS');
}
// Hash password (business logic)
const hashedPassword = await bcrypt.hash(userData.password, 10);
// Create user
const user = await userRepository.create({
...userData,
password: hashedPassword,
role: 'user',
status: 'active'
});
// Send welcome email (business logic)
await emailService.sendWelcomeEmail(user.email, user.name);
// Return safe user object
const { password, ...safeUser } = user;
return safeUser;
};
// repositories/userRepository.js
const db = require('../config/database');
exports.create = async (userData) => {
const query = `
INSERT INTO users (name, email, password, role, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, name, email, role, status, created_at
`;
const values = [
userData.name,
userData.email,
userData.password,
userData.role,
userData.status
];
const result = await db.query(query, values);
return result.rows[0];
};
exports.findByEmail = async (email) => {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await db.query(query, [email]);
return result.rows[0];
};
Key Takeaways
- Separation of concerns makes your code maintainable and testable
- Middlewares handle cross-cutting concerns like auth, logging, and validation
- Controllers manage HTTP concerns and orchestrate the flow
- Services contain business logic and should be framework-agnostic
- Repositories abstract database operations
- Request context shares metadata across the request lifecycle
- Order matters especially with middlewares
This architecture isn't just for large applications—even small projects benefit from this organization. It makes your code easier to understand, test, and scale as your application grows.
Happy coding! 🚀
Top comments (0)