Full-StackDec 10, 202515 min read

Full-Stack Architecture: Building Scalable MERN Applications

From monolith to microservices - a practical guide

MongoDBExpressReactNode.js
Full-Stack Architecture: Building Scalable MERN Applications

Key Takeaways

  • Clean architecture patterns
  • Scalable folder structures
  • Production deployment strategies
  • Error handling best practices
  • Testing strategies for full-stack apps

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.

Network architecture visualization
A well-designed architecture is the foundation of scalable 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 files

The 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.

Code on screen
Clean code and clear separation of concerns make maintenance a breeze
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

Server infrastructure
Modern deployment architecture with load balancing and containerization
  • 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.

HR

Written by Hammas Rashid

Full-Stack Developer passionate about building scalable web applications and sharing knowledge with the developer community.

Chat on WhatsApp