DEV Community

Cover image for Understanding Backend Architecture: A Complete Guide to Request Lifecycle
Yukti Sahu
Yukti Sahu

Posted on

Understanding Backend Architecture: A Complete Guide to Request Lifecycle

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);
Enter fullscreen mode Exit fullscreen mode
// Go example
http.HandleFunc("/api/users", createUserHandler)
http.HandleFunc("/api/users/", getUserHandler)
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode
// 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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Extract data from the request
  2. Validate and transform input
  3. Call service layer with processed data
  4. 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' 
        });
    }
}
Enter fullscreen mode Exit fullscreen mode
# 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
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode
# 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
        ]
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode
# 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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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];
};
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Separation of concerns makes your code maintainable and testable
  2. Middlewares handle cross-cutting concerns like auth, logging, and validation
  3. Controllers manage HTTP concerns and orchestrate the flow
  4. Services contain business logic and should be framework-agnostic
  5. Repositories abstract database operations
  6. Request context shares metadata across the request lifecycle
  7. 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)