Backend Architecture — The Layer Cake of Backend Development
Think of your backend as a well-organized kitchen where each station has a specific job. This document breaks down the major backend layers and examples (JavaScript / Node/Express code) for each.
┌─────────────────────────────────────┐
│ Middleware (Validation) │ ← Guards the entrance
├─────────────────────────────────────┤
│ Controllers (HTTP Handlers) │ ← Manages requests/responses
├─────────────────────────────────────┤
│ Service Layer (Business Logic) │ ← Where the magic happens
├─────────────────────────────────────┤
│ Repository (Database Access) │ ← Talks to the database
└─────────────────────────────────────┘
1) Repository Layer (Bottom Layer)
This is the foundation — it directly talks to your database.
Why separate this layer?
- Keeps SQL or DB queries in one place
- Easier to switch databases (MySQL → PostgreSQL) by only changing repository code
- Easier to unit test database operations independently
Example: User Repository
// Example: UserRepository
class UserRepository {
// This method ONLY deals with database operations
async findUserByEmail(email) {
// Using a database query to fetch user
return await db.query('SELECT * FROM users WHERE email = ?', [email]);
}
async createUser(userData) {
// Insert new user into database
return await db.query(
'INSERT INTO users (name, email, password) VALUES (?, ?, ?)',
[userData.name, userData.email, userData.password]
);
}
async updateUserLastLogin(userId) {
// Update user's last login timestamp
return await db.query(
'UPDATE users SET last_login = NOW() WHERE id = ?',
[userId]
);
}
}
2) Service Layer (Business Logic Layer)
This is where your application's brain lives. It orchestrates multiple operations and calls repository functions, external services, etc.
Why this layer is important:
- Reusability: calling the same business logic from different controllers or handlers
- Easier to unit test business rules without HTTP semantics
- Keeps controller thin
Example: User Service
class UserService {
constructor(userRepository, emailService, webhookService) {
this.userRepository = userRepository;
this.emailService = emailService;
this.webhookService = webhookService;
}
async registerUser(userData) {
const existingUser = await this.userRepository.findUserByEmail(userData.email);
if (existingUser) {
throw new Error('User already exists with this email');
}
const hashedPassword = await bcrypt.hash(userData.password, 10);
const newUser = await this.userRepository.createUser({
...userData,
password: hashedPassword
});
await this.emailService.sendWelcomeEmail(newUser.email, newUser.name);
await this.webhookService.notifyUserCreated(newUser.id);
return {
id: newUser.id,
name: newUser.name,
email: newUser.email
};
}
async loginUser(email, password) {
const user = await this.userRepository.findUserByEmail(email);
if (!user) {
throw new Error('Invalid credentials');
}
const isPasswordValid = await bcrypt.compare(password, user.password);
if (!isPasswordValid) {
throw new Error('Invalid credentials');
}
await this.userRepository.updateUserLastLogin(user.id);
const token = jwt.sign({ userId: user.id }, process.env.JWT_SECRET);
return { token, user: { id: user.id, name: user.name, email: user.email } };
}
}
3) Controller Layer (HTTP Handler)
This layer handles Express-specific logic: reading req, sending res, HTTP status codes, and calling services.
Controller Example: User Controller
class UserController {
constructor(userService) {
this.userService = userService;
}
// Handle user registration endpoint
async register(req, res) {
try {
const { name, email, password } = req.body;
const result = await this.userService.registerUser({ name, email, password });
return res.status(201).json({
success: true,
message: 'User registered successfully',
data: result
});
} catch (error) {
return res.status(400).json({
success: false,
message: error.message
});
}
}
// Handle user login endpoint
async login(req, res) {
try {
const { email, password } = req.body;
const result = await this.userService.loginUser(email, password);
return res.status(200).json({
success: true,
message: 'Login successful',
data: result
});
} catch (error) {
return res.status(401).json({
success: false,
message: error.message
});
}
}
// Handle getting user profile
async getProfile(req, res) {
try {
const userId = req.user.id;
const user = await this.userService.getUserById(userId);
return res.status(200).json({ success: true, data: user });
} catch (error) {
return res.status(404).json({ success: false, message: 'User not found' });
}
}
}
4) Middleware Layer (The Gatekeeper)
Middleware sits before controllers to enforce security, validation, and transformation.
Example: Authentication Middleware
const authMiddleware = (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ success: false, message: 'No token provided, authorization denied' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ success: false, message: 'Invalid token' });
}
};
Example: Validation Middleware
const validateUserRegistration = (req, res, next) => {
const { name, email, password } = req.body;
if (!name || !email || !password) {
return res.status(400).json({ success: false, message: 'Name, email, and password are required' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ success: false, message: 'Invalid email format' });
}
if (password.length < 8) {
return res.status(400).json({ success: false, message: 'Password must be at least 8 characters long' });
}
next();
};
// Using middleware in routes
app.post('/register', validateUserRegistration, userController.register);
app.get('/profile', authMiddleware, userController.getProfile);
Why use middleware?
- Security: stop unauthorized access BEFORE business logic
- Data validation: catch bad data early
- Reuse logic across routes
- Keep controllers focused on business flow
Understanding Errors: The Three Types
Errors encountered while validating/processing data typically fall into these categories: Syntactic, Type validation, and Semantic validation.
1) Syntactic Errors (Structure Problems)
These are errors where the data format itself is wrong.
const validateJsonSyntax = (req, res, next) => {
if (!req.is('application/json')) {
return res.status(400).json({ error: 'Syntactic Error', message: 'Content-Type must be application/json' });
}
next();
};
Common syntactic issues:
- Missing quotes:
{name: John}(invalid) vs{"name":"John"}(valid) - Trailing commas
- Using single quotes for JSON
2) Type Validation Errors (Wrong Data Type)
The structure is correct, but data types are incorrect.
const validateUserTypes = (req, res, next) => {
const { name, age, email, isActive } = req.body;
if (typeof name !== 'string') return res.status(400).json({ error: 'Type Validation Error', message: 'Name must be a string' });
if (typeof age !== 'number') return res.status(400).json({ error: 'Type Validation Error', message: 'Age must be a number' });
if (typeof email !== 'string') return res.status(400).json({ error: 'Type Validation Error', message: 'Email must be a string' });
if (typeof isActive !== 'boolean') return res.status(400).json({ error: 'Type Validation Error', message: 'isActive must be a boolean' });
next();
};
3) Semantic Validation Errors (Wrong Meaning/Logic)
Type is correct, but the value doesn't make sense in your business context.
const validateUserSemantics = (req, res, next) => {
const { age, email, birthDate, country } = req.body;
if (age < 0 || age > 150) {
return res.status(400).json({ error: 'Semantic Validation Error', message: 'Age must be between 0 and 150' });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Semantic Validation Error', message: 'Email format is invalid' });
}
const birth = new Date(birthDate);
if (birth > new Date()) {
return res.status(400).json({ error: 'Semantic Validation Error', message: 'Birth date cannot be in the future' });
}
const validCountries = ['US', 'UK', 'IN', 'CA', 'AU'];
if (!validCountries.includes(country)) {
return res.status(400).json({ error: 'Semantic Validation Error', message: 'Invalid country code', allowedValues: validCountries });
}
next();
};
Transformations: Shaping Data Into What You Need
Transformations convert incoming data into a consistent format suitable for your business logic and storage.
Why transform?
- Normalize casing
- Trim whitespace
- Convert strings to numbers, booleans, dates
- Remove special chars (e.g., phone formatting)
Frontend client example
Input:
{
"name": " John Doe ",
"email": "JOHN@EXAMPLE.COM",
"phone": "123-456-7890",
"age": "25"
}
After server transform:
{
"name": "John Doe",
"email": "john@example.com",
"phone": "1234567890",
"age": 25
}
Example: Transformation Middleware
const transformUserInput = (req, res, next) => {
const { name, email, phone, age } = req.body;
const transformedData = {};
if (name) transformedData.name = name.trim().split(' ').map(w => w.charAt(0).toUpperCase()+w.slice(1).toLowerCase()).join(' ');
if (email) transformedData.email = email.trim().toLowerCase();
if (phone) transformedData.phone = phone.replace(/\D/g, '');
if (age) transformedData.age = parseInt(age, 10);
req.body = transformedData;
next();
};
// Usage
app.post('/users', transformUserInput, validateUserTypes, userController.register);
Query Parameter Transformation
const transformQueryParams = (req, res, next) => {
const { page, limit, sortBy, minPrice, inStock } = req.query;
req.queryParams = {
page: page ? parseInt(page, 10) : 1,
limit: limit ? parseInt(limit, 10) : 20,
sortBy: sortBy || 'createdAt',
minPrice: minPrice ? parseFloat(minPrice) : 0,
inStock: inStock === 'true'
};
if (req.queryParams.page < 1) return res.status(400).json({ error: 'Invalid page number' });
if (req.queryParams.limit < 1 || req.queryParams.limit > 100) return res.status(400).json({ error: 'Invalid limit' });
next();
};
Date Transformation & Validation
const transformDates = (req, res, next) => {
const { startDate, endDate, birthDate } = req.body;
if (startDate) {
req.body.startDate = new Date(startDate);
if (isNaN(req.body.startDate.getTime())) return res.status(400).json({ error: 'Invalid startDate format' });
}
if (endDate) {
req.body.endDate = new Date(endDate);
if (isNaN(req.body.endDate.getTime())) return res.status(400).json({ error: 'Invalid endDate format' });
}
if (birthDate) {
const birth = new Date(birthDate);
if (isNaN(birth.getTime())) return res.status(400).json({ error: 'Invalid birthDate format' });
const today = new Date();
let age = today.getFullYear() - birth.getFullYear();
const monthDiff = today.getMonth() - birth.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birth.getDate())) age--;
req.body.birthDate = birth;
req.body.age = age;
}
next();
};
Client-Side vs Server-Side Validation
Think of client-side validation as a helpful sign (UX) and server-side validation as security screening — both are necessary.
Client-Side (React example)
function RegistrationForm() {
const [errors, setErrors] = useState({});
const validateForm = (formData) => {
const newErrors = {};
if (!formData.name) newErrors.name = 'Name is required';
if (!formData.email) newErrors.email = 'Email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) newErrors.email = 'Invalid format';
if (!formData.password) newErrors.password = 'Password is required';
else if (formData.password.length < 8) newErrors.password = 'Password must be at least 8 characters';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// ...handle submit, fetch API
}
Pros:
- Better UX, instant feedback
- Reduces server load
- Faster response
Limitations:
- Not secure (easily bypassed)
- Can't verify database constraints
Server-Side (Node/Express example)
const validateRegistration = async (req, res, next) => {
const { name, email, password } = req.body;
const errors = {};
if (!name || !name.trim()) errors.name = 'Name is required and cannot be empty';
if (!email) errors.email = 'Email is required';
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = 'Invalid email format';
else {
const existingUser = await userRepository.findByEmail(email);
if (existingUser) errors.email = 'Email is already registered';
}
if (!password) errors.password = 'Password is required';
else if (password.length < 8) errors.password = 'Password must be at least 8 characters';
if (Object.keys(errors).length > 0) return res.status(400).json({ success: false, errors });
next();
};
Pros:
- Security and data integrity
- Database checks and complex business rules
The Golden Rule: Always Do Both — client for UX, server for security.
How Developers Should Approach This (DRY principle)
- Define validation rules in a single place and reuse them across frontend and backend
- Create a generic validator function and re-use it across layers
Example: Single Source Validation Rules
const validationRules = {
name: { required: true, minLength: 2, maxLength: 50, pattern: /^[a-zA-Z\s]+$/ },
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/ },
password: { required: true, minLength: 8, maxLength: 100, pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/ }
};
function validateField(value, rules) {
if (rules.required && !value) return 'Field is required';
if (rules.minLength && value.length < rules.minLength) return `Must be at least ${rules.minLength} characters`;
if (rules.maxLength && value.length > rules.maxLength) return `Must be at most ${rules.maxLength} characters`;
if (rules.pattern && !rules.pattern.test(value)) return 'Invalid format';
return null;
}
// Reuse the same functions on both frontend and backend
Real-World Example: Complete User Registration Flow
A complete example: shared validation rules, transform middleware, validation middleware, repository, service, controller, and router.
1) Validation Rules (shared)
const userValidationRules = {
name: { required: true, minLength: 2, maxLength: 50, errorMessages: { required: 'Name is required', minLength: 'Name must be at least 2 characters', maxLength: 'Name cannot exceed 50 characters' } },
email: { required: true, pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, errorMessages: { required: 'Email is required', pattern: 'Please enter a valid email address' } },
password: { required: true, minLength: 8, pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])/, errorMessages: { required: 'Password is required', minLength: 'Password must be at least 8 characters', pattern: 'Password must contain uppercase, lowercase, number, and special character' } },
age: { required: true, min: 18, max: 120, errorMessages: { required: 'Age is required', min: 'You must be at least 18 years old', max: 'Please enter a valid age' } }
};
2) Transformation Middleware
const transformUserInput = (req, res, next) => {
try {
const { name, email, password, age } = req.body;
req.body = {
name: name ? name.trim() : '',
email: email ? email.trim().toLowerCase() : '',
password: password || '',
age: age ? parseInt(age, 10) : null
};
next();
} catch (error) {
return res.status(400).json({ success: false, message: 'Error processing input data', error: error.message });
}
};
3) Validation Middleware
const validateUserRegistration = async (req, res, next) => {
const { name, email, password, age } = req.body;
const errors = {};
// Name
if (!name) errors.name = userValidationRules.name.errorMessages.required;
else if (name.length < userValidationRules.name.minLength) errors.name = userValidationRules.name.errorMessages.minLength;
else if (name.length > userValidationRules.name.maxLength) errors.name = userValidationRules.name.errorMessages.maxLength;
// Email
if (!email) errors.email = userValidationRules.email.errorMessages.required;
else if (!userValidationRules.email.pattern.test(email)) errors.email = userValidationRules.email.errorMessages.pattern;
else {
const existingUser = await userRepository.findByEmail(email);
if (existingUser) errors.email = 'This email is already registered';
}
// Password
if (!password) errors.password = userValidationRules.password.errorMessages.required;
else if (password.length < userValidationRules.password.minLength) errors.password = userValidationRules.password.errorMessages.minLength;
else if (!userValidationRules.password.pattern.test(password)) errors.password = userValidationRules.password.errorMessages.pattern;
// Age
if (!age) errors.age = userValidationRules.age.errorMessages.required;
else if (age < userValidationRules.age.min) errors.age = userValidationRules.age.errorMessages.min;
else if (age > userValidationRules.age.max) errors.age = userValidationRules.age.errorMessages.max;
if (Object.keys(errors).length > 0) return res.status(400).json({ success: false, message: 'Validation failed', errors });
next();
};
4) Repository Layer (simplified)
class UserRepository {
async findByEmail(email) {
return await db.query('SELECT * FROM users WHERE email = ?', [email]);
}
async create(userData) {
const result = await db.query('INSERT INTO users (name, email, password, age, created_at) VALUES (?, ?, ?, ?, NOW())', [userData.name, userData.email, userData.password, userData.age]);
return { id: result.insertId, ...userData };
}
}
5) Service Layer
class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async registerUser(userData) {
const hashedPassword = await bcrypt.hash(userData.password, 10);
const newUser = await this.userRepository.create({ ...userData, password: hashedPassword });
// Send welcome email (async)
this.emailService.sendWelcomeEmail(newUser.email, newUser.name).catch(err => console.error('Email error:', err));
return { id: newUser.id, name: newUser.name, email: newUser.email, age: newUser.age };
}
}
6) Controller Layer
class UserController {
constructor(userService) {
this.userService = userService;
}
async register(req, res) {
try {
const result = await this.userService.registerUser(req.body);
return res.status(201).json({ success: true, message: 'User registered successfully', data: result });
} catch (error) {
console.error('Registration error:', error);
return res.status(500).json({ success: false, message: 'Internal server error', error: process.env.NODE_ENV === 'development' ? error.message : undefined });
}
}
}
7) Routes (Putting it together)
const express = require('express');
const router = express.Router();
const userRepository = new UserRepository();
const emailService = new EmailService();
const userService = new UserService(userRepository, emailService);
const userController = new UserController(userService);
router.post('/register', transformUserInput, validateUserRegistration, userController.register.bind(userController));
module.exports = router;
Key Takeaways ✅
- Layer Separation is critical: each layer has one job (Repository, Service, Controller, Middleware)
- Validation prevents disasters: syntactic, type-based, and semantic checks are all necessary
- Transform data early: normalize types and formats before they reach your business logic
- Client + Server validation = complete protection (UX + Security)
- Middleware is your friend: reusable, consistent, and keeps controllers focused
By following these patterns, you can build robust, secure, and maintainable backend systems. Remember: validate early, transform carefully, and never trust user input. 🛡️
Top comments (0)