Building a full-stack application is more than just connecting a frontend to a backend. It's about creating a sustainable, scalable architecture that your team can maintain and grow for years. In this guide, I'll share the patterns and practices I've refined over years of building production MERN applications.
The Foundation: Project Structure
A well-organized project structure is the backbone of any scalable application. The structure should make it easy to find code, understand dependencies, and onboard new team members. Here's the architecture I use for production MERN apps:
text
project-root/
├── client/ # React frontend
│ ├── src/
│ │ ├── components/ # Reusable UI components
│ │ │ ├── common/ # Buttons, inputs, modals
│ │ │ ├── layout/ # Header, footer, sidebar
│ │ │ └── features/ # Feature-specific components
│ │ ├── pages/ # Route-level components
│ │ ├── hooks/ # Custom React hooks
│ │ ├── services/ # API communication layer
│ │ ├── store/ # State management (Redux/Zustand)
│ │ ├── types/ # TypeScript type definitions
│ │ └── utils/ # Helper functions
│ └── public/
├── server/ # Node.js backend
│ ├── controllers/ # Request handlers (thin layer)
│ ├── models/ # Database schemas
│ ├── routes/ # API routes definitions
│ ├── middleware/ # Auth, validation, error handling
│ ├── services/ # Business logic (thick layer)
│ ├── repositories/ # Data access layer
│ ├── validators/ # Request validation schemas
│ └── utils/ # Helper functions
├── shared/ # Shared code between client/server
│ ├── types/ # Shared TypeScript types
│ └── constants/ # Shared constants
└── docker/ # Docker configuration filesThe Clean Architecture Approach
Clean architecture separates your application into layers with clear dependencies. The key rule: dependencies point inward. Your business logic should never depend on frameworks, databases, or external services directly.
javascript
// The flow: Route → Controller → Service → Repository → Database
// routes/userRoutes.js
const express = require('express');
const router = express.Router();
const userController = require('../controllers/userController');
const { validateUser } = require('../validators/userValidator');
const { authenticate } = require('../middleware/auth');
router.post('/', validateUser, userController.createUser);
router.get('/:id', authenticate, userController.getUser);
router.put('/:id', authenticate, validateUser, userController.updateUser);
router.delete('/:id', authenticate, userController.deleteUser);
module.exports = router;Separation of Concerns in Practice
Each layer should have a single responsibility. Controllers handle HTTP concerns, services contain business logic, and repositories handle data access. This separation makes testing easy and changes isolated.
javascript
// controllers/userController.js - Thin layer, handles HTTP only
const userService = require('../services/userService');
exports.createUser = async (req, res, next) => {
try {
const user = await userService.create(req.body);
res.status(201).json({
success: true,
data: user,
message: 'User created successfully'
});
} catch (error) {
next(error); // Let error middleware handle it
}
};
exports.getUser = async (req, res, next) => {
try {
const user = await userService.findById(req.params.id);
if (!user) {
return res.status(404).json({
success: false,
message: 'User not found'
});
}
res.json({ success: true, data: user });
} catch (error) {
next(error);
}
};javascript
// services/userService.js - Business logic lives here
const userRepository = require('../repositories/userRepository');
const { hashPassword, comparePassword } = require('../utils/auth');
const { sendWelcomeEmail } = require('../utils/email');
const AppError = require('../utils/AppError');
class UserService {
async create(userData) {
// Check for existing user
const existing = await userRepository.findByEmail(userData.email);
if (existing) {
throw new AppError('Email already registered', 400);
}
// Hash password
userData.password = await hashPassword(userData.password);
// Create user
const user = await userRepository.create(userData);
// Send welcome email (don't await - fire and forget)
sendWelcomeEmail(user.email, user.name).catch(console.error);
// Return user without password
const { password, ...safeUser } = user.toObject();
return safeUser;
}
async findById(id) {
return userRepository.findById(id);
}
}
module.exports = new UserService();Centralized Error Handling
A robust error handling strategy prevents code duplication and ensures consistent API responses. Create custom error classes and a global error handler middleware.
javascript
// utils/AppError.js
class AppError extends Error {
constructor(message, statusCode, errorCode = null) {
super(message);
this.statusCode = statusCode;
this.errorCode = errorCode;
this.isOperational = true; // Distinguishes from programming errors
Error.captureStackTrace(this, this.constructor);
}
}
module.exports = AppError;
// middleware/errorHandler.js
const errorHandler = (err, req, res, next) => {
// Log error for debugging
console.error('Error:', {
message: err.message,
stack: err.stack,
url: req.url,
method: req.method
});
// Mongoose validation error
if (err.name === 'ValidationError') {
const errors = Object.values(err.errors).map(e => e.message);
return res.status(400).json({
success: false,
message: 'Validation failed',
errors
});
}
// Mongoose duplicate key
if (err.code === 11000) {
return res.status(400).json({
success: false,
message: 'Duplicate field value'
});
}
// Operational errors (expected)
if (err.isOperational) {
return res.status(err.statusCode).json({
success: false,
message: err.message,
errorCode: err.errorCode
});
}
// Programming errors (unexpected)
res.status(500).json({
success: false,
message: 'Something went wrong',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
});
};
module.exports = errorHandler;Environment Configuration
Never hardcode configuration values. Use environment variables with proper validation and defaults. This makes deployment across different environments seamless.
javascript
// config/index.js
require('dotenv').config();
const config = {
env: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT, 10) || 3000,
database: {
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/myapp',
options: {
maxPoolSize: 10,
serverSelectionTimeoutMS: 5000,
socketTimeoutMS: 45000,
}
},
jwt: {
secret: process.env.JWT_SECRET,
accessExpiresIn: '15m',
refreshExpiresIn: '7d'
},
cors: {
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:3000'],
credentials: true
}
};
// Validate required env vars
const required = ['JWT_SECRET', 'MONGODB_URI'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
module.exports = config;Deployment Architecture
- Use environment variables for all configuration - never commit secrets
- Implement health check endpoints for load balancer and monitoring
- Set up proper logging with structured output (JSON format in production)
- Use PM2 or similar for Node.js process management and auto-restart
- Configure reverse proxy with Nginx for SSL termination and static files
- Implement graceful shutdown handling for zero-downtime deployments
- Set up CI/CD pipelines for automated testing and deployment
javascript
// Graceful shutdown handling
const gracefulShutdown = async (signal) => {
console.log(`${signal} received. Starting graceful shutdown...`);
// Stop accepting new connections
server.close(async () => {
console.log('HTTP server closed');
// Close database connections
await mongoose.connection.close();
console.log('Database connection closed');
// Close Redis connections
await redisClient.quit();
console.log('Redis connection closed');
process.exit(0);
});
// Force shutdown after 30 seconds
setTimeout(() => {
console.error('Forced shutdown after timeout');
process.exit(1);
}, 30000);
};
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));This architecture has powered applications serving millions of users. The investment in proper structure pays dividends in maintainability, team productivity, and the ability to scale both your application and your team.