My Favorite Express Architecture Setup Guide
🛠️

My Favorite Express Architecture Setup Guide

Express.js Enterprise Architecture Setup Guide

A step-by-step tutorial for setting up a scalable, enterprise-ready Express.js application architecture from scratch. This guide focuses on the initial setup and foundational patterns that can be applied to any domain.

🎯 What You’ll Build

By the end of this guide, you’ll have a production-ready Express.js application with:
  • Domain-driven architecture for scalable code organization
  • Type-safe development with TypeScript
  • Database integration with MikroORM and MongoDB
  • Authentication & authorization system
  • Comprehensive middleware stack
  • Error handling strategy
  • Testing infrastructure
  • Development workflow with hot reloading

📋 Prerequisites

  • Node.js 18+ installed
  • MongoDB running locally or connection string to MongoDB Atlas
  • Basic knowledge of Express.js and TypeScript
  • Code editor (VS Code recommended)

🚀 Step 1: Project Initialization

ℹ️
Setting up a solid foundation is crucial for any enterprise application. This step establishes the project structure, installs essential dependencies, and configures TypeScript to ensure type safety throughout your application.

Create Project Structure

# Create project directory
mkdir my-express-app
cd my-express-app

# Initialize npm project
npm init -y

# Create basic directory structure
mkdir -p src/{shared/{entities,middleware,services,utils,errors,types},user,config}
mkdir -p src/shared/{middleware,services,utils,errors,types}
mkdir -p src/user/__tests__

Install Dependencies

# Core dependencies
npm install express typescript ts-node-dev
npm install @mikro-orm/core @mikro-orm/mongodb @mikro-orm/cli
npm install express-validator cors helmet morgan winston
npm install dotenv luxon zod

# Type definitions
npm install --save-dev @types/express @types/node @types/cors @types/morgan @types/luxon

# Development and testing
npm install --save-dev jest @types/jest ts-jest supertest @types/supertest
npm install --save-dev eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm install --save-dev cross-env

TypeScript Configuration

Create tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "resolveJsonModule": true,
    "declaration": true,
    "sourceMap": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "**/*.test.ts"]
}

Package.json Scripts

Update your package.json scripts section:
{
  "scripts": {
    "build": "tsc",
    "start": "node dist/src/index.js",
    "dev": "NODE_ENV=development ts-node-dev --respawn --transpile-only src/index.ts",
    "test": "cross-env NODE_ENV=test jest",
    "test:watch": "cross-env NODE_ENV=test jest --watch",
    "lint": "eslint src/**/*.ts",
    "clean": "rm -rf dist"
  }
}

🔧 Step 2: Core Configuration Setup

ℹ️
Proper configuration management is essential for enterprise applications. This step implements environment-specific settings and secure credential management to support different deployment environments while keeping sensitive information protected.

Environment Configuration

Create src/config/index.ts:
import { config as dotenvConfig } from 'dotenv';

// Load environment variables
dotenvConfig({ path: `.env.${process.env.NODE_ENV || 'development'}` });

export const config = {
  // Environment
  nodeEnv: process.env.NODE_ENV || 'development',
  port: parseInt(process.env.PORT || '3000'),
  
  // Database
  mongoConnectionString: process.env.MONGODB_URL || 'mongodb://localhost:27017/myapp',
  
  // Security
  jwtSecret: process.env.JWT_SECRET || 'your-jwt-secret-change-in-production',
  
  // CORS origins by environment
  clientOrigins: {
    development: ['http://localhost:3000', 'http://localhost:3001'],
    production: process.env.CLIENT_ORIGINS?.split(',') || [],
    test: ['http://localhost:3000']
  },
  
  // Logging
  logLevel: process.env.LOG_LEVEL || 'info'
};

// Validate required environment variables in production
if (config.nodeEnv === 'production') {
  const required = ['MONGODB_URL', 'JWT_SECRET'];
  for (const key of required) {
    if (!process.env[key]) {
      throw new Error(`Missing required environment variable: ${key}`);
    }
  }
}

Environment Files

Create .env.development:
NODE_ENV=development
PORT=3000
MONGODB_URL=mongodb://localhost:27017/myapp-dev
JWT_SECRET=dev-jwt-secret-change-me
LOG_LEVEL=debug
Create .env.test:
NODE_ENV=test
MONGODB_URL=mongodb://localhost:27017/myapp-test
JWT_SECRET=test-jwt-secret
LOG_LEVEL=error
Create .env.production.example (copy to .env.production and fill in):
NODE_ENV=production
PORT=3000
MONGODB_URL=your-production-mongodb-url
JWT_SECRET=your-super-secure-jwt-secret
CLIENT_ORIGINS=https://yourdomain.com,https://api.yourdomain.com
LOG_LEVEL=info

🏗️ Step 3: Dependency Injection Container

ℹ️
Dependency injection improves testability and reduces coupling between components. This simple DI container provides a centralized way to access core services and dependencies throughout your application.
 
Create src/DI.ts:
import { MikroORM, EntityManager } from '@mikro-orm/core';
import { Db } from 'mongodb';
import * as winston from 'winston';
import * as http from 'http';

/**
 * Dependency Injection container
 * Provides centralized access to core application dependencies
 */
export const DI = {} as {
  server: http.Server;
  orm: MikroORM;
  em: EntityManager;
  nativeClient: Db;
  logger: winston.Logger;
};

🛠️ Step 4: Shared Infrastructure

ℹ️
Building reusable components saves time and ensures consistency. This section establishes shared services like logging, error handling, and base entity classes that will be used across your entire application.

Logger Service

Create src/shared/services/logger.service.ts:
import winston from 'winston';
import { config } from '../../config';

const createLogger = () => {
  const isDevelopment = config.nodeEnv === 'development';
  
  return winston.createLogger({
    level: config.logLevel,
    format: winston.format.combine(
      winston.format.timestamp(),
      winston.format.errors({ stack: true }),
      isDevelopment
        ? winston.format.combine(
            winston.format.colorize(),
            winston.format.simple()
          )
        : winston.format.json()
    ),
    transports: [
      new winston.transports.Console(),
      ...(config.nodeEnv !== 'test' 
        ? [new winston.transports.File({ filename: 'app.log' })]
        : []
      )
    ]
  });
};

export const logger = createLogger();

Custom Error Classes

Create src/shared/errors/index.ts:
export class AppError extends Error {
  public readonly isOperational: boolean = true;

  constructor(
    public readonly message: string,
    public readonly statusCode: number = 500
  ) {
    super(message);
    this.name = this.constructor.name;
    Error.captureStackTrace(this, this.constructor);
  }
}

export class ValidationError extends AppError {
  constructor(message: string = 'Validation failed', public readonly details?: any[]) {
    super(message, 400);
  }
}

export class NotFoundError extends AppError {
  constructor(message: string = 'Resource not found') {
    super(message, 404);
  }
}

export class UnauthorizedError extends AppError {
  constructor(message: string = 'Authentication required') {
    super(message, 401);
  }
}

export class ForbiddenError extends AppError {
  constructor(message: string = 'Access denied') {
    super(message, 403);
  }
}

export class ConflictError extends AppError {
  constructor(message: string = 'Resource already exists') {
    super(message, 409);
  }
}

Base Entity

Create src/shared/entities/base.entity.ts:
import { Entity, PrimaryKey, SerializedPrimaryKey, Property } from '@mikro-orm/core';
import { ObjectId } from '@mikro-orm/mongodb';

/**
 * Base entity class that all entities should extend
 * Provides common fields and functionality
 */
@Entity({ abstract: true })
export abstract class BaseEntity {
  @PrimaryKey()
  _id!: ObjectId;

  @SerializedPrimaryKey()
  id!: string;

  @Property()
  createdAt = new Date();

  @Property({ onUpdate: () => new Date() })
  updatedAt = new Date();

  @Property()
  isActive: boolean = true;

  @Property({ hidden: true })
  isTestData: boolean = false;

  // Utility method to mark as test data
  markAsTestData(): this {
    this.isTestData = true;
    return this;
  }

  // Soft delete functionality
  softDelete(): this {
    this.isActive = false;
    return this;
  }

  restore(): this {
    this.isActive = true;
    return this;
  }
}

Type Definitions

Create src/shared/types/requests.ts:
import { Request } from 'express';

export interface AuthenticatedUser {
  id: string;
  email: string;
  roles: string[];
}

export interface AuthenticatedRequest extends Request {
  user?: AuthenticatedUser;
}

export interface PaginationQuery {
  page?: string;
  limit?: string;
  orderBy?: string;
  direction?: 'asc' | 'desc';
}

export interface SearchQuery {
  search?: string;
}

🔒 Step 5: Middleware Stack

ℹ️
Middleware provides cross-cutting functionality in Express applications. These middleware components handle common concerns like error processing, request validation, and authentication in a standardized way.

Try-Catch Wrapper

Create src/shared/middleware/tryCatch.middleware.ts:
import { Request, Response, NextFunction, RequestHandler } from 'express';

/**
 * Wrapper to catch async errors and pass them to error handler
 */
export const tryCatch = (fn: RequestHandler): RequestHandler => {
  return (req: Request, res: Response, next: NextFunction): void => {
    Promise.resolve(fn(req, res, next)).catch(next);
  };
};

Validation Middleware

Create src/shared/middleware/validation.middleware.ts:
import { Request, Response, NextFunction } from 'express';
import { ValidationChain, validationResult } from 'express-validator';
import { ValidationError } from '../errors';

/**
 * Express-validator middleware wrapper
 */
export const validateRequest = (validations: ValidationChain[]) => {
  return [
    ...validations,
    (req: Request, res: Response, next: NextFunction) => {
      const errors = validationResult(req);
      if (!errors.isEmpty()) {
        throw new ValidationError('Validation failed', errors.array());
      }
      next();
    }
  ];
};

Error Handler Middleware

Create src/shared/middleware/error.middleware.ts:
import { Request, Response, NextFunction } from 'express';
import { AppError } from '../errors';
import { logger } from '../services/logger.service';
import { config } from '../../config';

export const errorHandler = (
  err: Error,
  req: Request,
  res: Response,
  next: NextFunction
) => {
  // Log the error
  logger.error('Error occurred:', {
    message: err.message,
    stack: err.stack,
    url: req.url,
    method: req.method,
    ip: req.ip,
    userAgent: req.get('User-Agent')
  });

  // Handle known application errors
  if (err instanceof AppError) {
    return res.status(err.statusCode).json({
      status: 'error',
      message: err.message,
      ...(config.nodeEnv === 'development' && { stack: err.stack })
    });
  }

  // Handle specific error types
  if (err.name === 'ValidationError') {
    return res.status(400).json({
      status: 'error',
      message: 'Validation failed',
      details: err.message,
      ...(config.nodeEnv === 'development' && { stack: err.stack })
    });
  }

  if (err.name === 'CastError' || err.name === 'BSONError') {
    return res.status(400).json({
      status: 'error',
      message: 'Invalid ID format',
      ...(config.nodeEnv === 'development' && { stack: err.stack })
    });
  }

  // Handle unexpected errors
  res.status(500).json({
    status: 'error',
    message: config.nodeEnv === 'production' 
      ? 'Internal server error' 
      : err.message,
    ...(config.nodeEnv === 'development' && { stack: err.stack })
  });
};

export const notFoundHandler = (req: Request, res: Response) => {
  res.status(404).json({
    status: 'error',
    message: `Route ${req.originalUrl} not found`
  });
};

Authentication Middleware

Create src/shared/middleware/auth.middleware.ts:
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { UnauthorizedError } from '../errors';
import { config } from '../../config';
import { AuthenticatedRequest, AuthenticatedUser } from '../types/requests';

export const authenticateToken = (
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    throw new UnauthorizedError('Access token required');
  }

  try {
    const user = jwt.verify(token, config.jwtSecret) as AuthenticatedUser;
    req.user = user;
    next();
  } catch (error) {
    throw new UnauthorizedError('Invalid or expired token');
  }
};

export const optionalAuth = (
  req: AuthenticatedRequest,
  res: Response,
  next: NextFunction
) => {
  const authHeader = req.headers.authorization;
  const token = authHeader && authHeader.split(' ')[1];

  if (token) {
    try {
      const user = jwt.verify(token, config.jwtSecret) as AuthenticatedUser;
      req.user = user;
    } catch (error) {
      // Token is invalid, but we continue without user
      req.user = undefined;
    }
  }

  next();
};

Middleware Index

Create src/shared/middleware/index.ts:
export { tryCatch } from './tryCatch.middleware';
export { validateRequest } from './validation.middleware';
export { errorHandler, notFoundHandler } from './error.middleware';
export { authenticateToken, optionalAuth } from './auth.middleware';

🗄️ Step 6: Database Setup

ℹ️
A robust database layer is critical for enterprise applications. MikroORM provides a type-safe way to interact with MongoDB while maintaining clean domain models and enforcing business rules.

MikroORM Configuration

Create src/mikro-orm.config.ts:
import { defineConfig } from '@mikro-orm/core';
import { config } from './config';

// Entity imports (we'll add these as we create them)
import { User } from './user/user.entity';

const entities = [User];

export default defineConfig({
  entities,
  dbName: 'myapp',
  clientUrl: config.mongoConnectionString,
  debug: config.nodeEnv === 'development',
  allowGlobalContext: config.nodeEnv === 'test',
  // Enable automatic schema updates in development
  ensureIndexes: config.nodeEnv === 'development',
});

👤 Step 7: Create Your First Domain (User)

The User domain demonstrates how to implement a complete feature following domain-driven design principles. This includes entity definition, business logic in services, and proper API exposure through controllers and routes.

User Entity

Create src/user/user.entity.ts:
import { Entity, Property, Unique, BeforeCreate, BeforeUpdate } from '@mikro-orm/core';
import { BaseEntity } from '../shared/entities/base.entity';
import * as bcrypt from 'bcrypt';

@Entity()
export class User extends BaseEntity {
  @Property()
  @Unique()
  email!: string;

  @Property({ hidden: true })
  password!: string;

  @Property()
  firstName?: string;

  @Property()
  lastName?: string;

  @Property()
  roles: string[] = ['user'];

  @Property({ nullable: true })
  lastLoginAt?: Date;

  constructor(email: string, password: string) {
    super();
    this.email = email;
    this.password = password;
  }

  @BeforeCreate()
  @BeforeUpdate()
  async hashPassword() {
    if (this.password && !this.password.startsWith('$2b$')) {
      this.password = await bcrypt.hash(this.password, 10);
    }
  }

  async verifyPassword(plainPassword: string): Promise<boolean> {
    return bcrypt.compare(plainPassword, this.password);
  }

  get fullName(): string {
    return `${this.firstName || ''} ${this.lastName || ''}`.trim() || this.email;
  }

  hasRole(role: string): boolean {
    return this.roles.includes(role);
  }

  updateLastLogin(): void {
    this.lastLoginAt = new Date();
  }
}

User Service

Create src/user/user.service.ts:
import { DI } from '../DI';
import { User } from './user.entity';
import { NotFoundError, ConflictError } from '../shared/errors';
import jwt from 'jsonwebtoken';
import { config } from '../config';

export class UserService {
  async findById(id: string): Promise<User | null> {
    return DI.em.findOne(User, { id, isActive: true });
  }

  async findByEmail(email: string): Promise<User | null> {
    return DI.em.findOne(User, { email, isActive: true });
  }

  async createUser(userData: {
    email: string;
    password: string;
    firstName?: string;
    lastName?: string;
  }): Promise<User> {
    // Check if user already exists
    const existingUser = await this.findByEmail(userData.email);
    if (existingUser) {
      throw new ConflictError('User with this email already exists');
    }

    const user = new User(userData.email, userData.password);
    if (userData.firstName) user.firstName = userData.firstName;
    if (userData.lastName) user.lastName = userData.lastName;

    await DI.em.persistAndFlush(user);
    return user;
  }

  async authenticateUser(email: string, password: string): Promise<string> {
    const user = await this.findByEmail(email);
    if (!user) {
      throw new NotFoundError('Invalid credentials');
    }

    const isPasswordValid = await user.verifyPassword(password);
    if (!isPasswordValid) {
      throw new NotFoundError('Invalid credentials');
    }

    user.updateLastLogin();
    await DI.em.flush();

    // Generate JWT token
    const token = jwt.sign(
      {
        id: user.id,
        email: user.email,
        roles: user.roles
      },
      config.jwtSecret,
      { expiresIn: '24h' }
    );

    return token;
  }

  async updateUser(id: string, updates: Partial<User>): Promise<User> {
    const user = await this.findById(id);
    if (!user) {
      throw new NotFoundError('User not found');
    }

    // Only allow certain fields to be updated
    const allowedUpdates = ['firstName', 'lastName'];
    Object.keys(updates).forEach(key => {
      if (allowedUpdates.includes(key)) {
        (user as any)[key] = (updates as any)[key];
      }
    });

    await DI.em.flush();
    return user;
  }

  async deleteUser(id: string): Promise<void> {
    const user = await this.findById(id);
    if (!user) {
      throw new NotFoundError('User not found');
    }

    user.softDelete();
    await DI.em.flush();
  }

  async searchUsers(query: string, limit = 20): Promise<User[]> {
    return DI.em.find(User, {
      $or: [
        { firstName: new RegExp(query, 'i') },
        { lastName: new RegExp(query, 'i') },
        { email: new RegExp(query, 'i') }
      ],
      isActive: true
    }, { limit });
  }
}

User Controller

Create src/user/user.controller.ts:
import { Request, Response } from 'express';
import { UserService } from './user.service';
import { AuthenticatedRequest } from '../shared/types/requests';
import { ForbiddenError } from '../shared/errors';

export class UserController {
  private userService = new UserService();

  register = async (req: Request, res: Response): Promise<void> => {
    const { email, password, firstName, lastName } = req.body;
    
    const user = await this.userService.createUser({
      email,
      password,
      firstName,
      lastName
    });

    // Don't return password in response
    const { password: _, ...userResponse } = user;
    
    res.status(201).json({
      status: 'success',
      data: { user: userResponse }
    });
  };

  login = async (req: Request, res: Response): Promise<void> => {
    const { email, password } = req.body;
    
    const token = await this.userService.authenticateUser(email, password);
    
    res.json({
      status: 'success',
      data: { token }
    });
  };

  getProfile = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
    const user = await this.userService.findById(req.user!.id);
    
    res.json({
      status: 'success',
      data: { user }
    });
  };

  updateProfile = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
    const updates = req.body;
    const user = await this.userService.updateUser(req.user!.id, updates);
    
    res.json({
      status: 'success',
      data: { user }
    });
  };

  getUserById = async (req: AuthenticatedRequest, res: Response): Promise<void> => {
    const { id } = req.params;
    
    // Only allow users to see their own profile or admins to see any
    if (req.user!.id !== id && !req.user!.roles.includes('admin')) {
      throw new ForbiddenError('Access denied');
    }
    
    const user = await this.userService.findById(id);
    
    res.json({
      status: 'success',
      data: { user }
    });
  };

  searchUsers = async (req: Request, res: Response): Promise<void> => {
    const { query, limit } = req.query;
    
    const users = await this.userService.searchUsers(
      query as string,
      parseInt(limit as string) || 20
    );
    
    res.json({
      status: 'success',
      data: { users }
    });
  };
}

User Routes

Create src/user/user.routes.ts:
import { Router } from 'express';
import { body, param, query } from 'express-validator';
import { UserController } from './user.controller';
import { 
  tryCatch, 
  validateRequest, 
  authenticateToken 
} from '../shared/middleware';

const router = Router();
const userController = new UserController();

// Public routes
router.post(
  '/register',
  ...validateRequest([
    body('email').isEmail().normalizeEmail(),
    body('password').isLength({ min: 6 }).withMessage('Password must be at least 6 characters'),
    body('firstName').optional().trim().isLength({ min: 1, max: 50 }),
    body('lastName').optional().trim().isLength({ min: 1, max: 50 })
  ]),
  tryCatch(userController.register)
);

router.post(
  '/login',
  ...validateRequest([
    body('email').isEmail().normalizeEmail(),
    body('password').exists().withMessage('Password is required')
  ]),
  tryCatch(userController.login)
);

// Protected routes
router.use(authenticateToken);

router.get(
  '/profile',
  tryCatch(userController.getProfile)
);

router.put(
  '/profile',
  ...validateRequest([
    body('firstName').optional().trim().isLength({ min: 1, max: 50 }),
    body('lastName').optional().trim().isLength({ min: 1, max: 50 })
  ]),
  tryCatch(userController.updateProfile)
);

router.get(
  '/search',
  ...validateRequest([
    query('query').exists().withMessage('Search query is required'),
    query('limit').optional().isInt({ min: 1, max: 100 })
  ]),
  tryCatch(userController.searchUsers)
);

router.get(
  '/:id',
  ...validateRequest([
    param('id').isMongoId().withMessage('Valid user ID required')
  ]),
  tryCatch(userController.getUserById)
);

export { router as UserRouter };

🧪 Step 8: Testing Setup

ℹ️
Comprehensive testing ensures application reliability. This testing infrastructure enables unit, integration, and API tests with proper database isolation for consistent and reliable test results.

Jest Configuration

Create jest.config.js:
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.test.ts'],
  collectCoverageFrom: [
    'src/**/*.ts',
    '!src/**/*.d.ts',
    '!src/index.ts',
    '!src/mikro-orm.config.ts'
  ],
  setupFilesAfterEnv: ['<rootDir>/src/__tests__/setup.ts'],
  clearMocks: true,
  restoreMocks: true
};

Test Setup

Create src/__tests__/setup.ts:
import { MikroORM } from '@mikro-orm/core';
import { MongoClient } from 'mongodb';
import { DI } from '../DI';
import config from '../mikro-orm.config';
import { logger } from '../shared/services/logger.service';

beforeAll(async () => {
  // Setup test database
  const client = await MongoClient.connect('mongodb://localhost:27017');
  
  DI.orm = await MikroORM.init({
    ...config,
    dbName: 'myapp-test',
    allowGlobalContext: true
  });
  
  DI.em = DI.orm.em;
  DI.nativeClient = client.db('myapp-test');
  DI.logger = logger;
});

afterAll(async () => {
  await DI.orm.close();
});

beforeEach(async () => {
  // Clear database before each test
  await DI.orm.getSchemaGenerator().clearDatabase();
});

Example Test

Create src/user/__tests__/user.service.test.ts:
import { UserService } from '../user.service';
import { User } from '../user.entity';
import { DI } from '../../DI';
import { ConflictError, NotFoundError } from '../../shared/errors';

describe('UserService', () => {
  let userService: UserService;

  beforeEach(() => {
    userService = new UserService();
  });

  describe('createUser', () => {
    it('should create a new user', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'password123',
        firstName: 'John',
        lastName: 'Doe'
      };

      const user = await userService.createUser(userData);

      expect(user).toBeInstanceOf(User);
      expect(user.email).toBe('test@example.com');
      expect(user.firstName).toBe('John');
      expect(user.password).not.toBe('password123'); // Should be hashed
    });

    it('should throw ConflictError if user already exists', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'password123'
      };

      await userService.createUser(userData);

      await expect(userService.createUser(userData))
        .rejects.toThrow(ConflictError);
    });
  });

  describe('authenticateUser', () => {
    it('should authenticate user with correct credentials', async () => {
      const userData = {
        email: 'test@example.com',
        password: 'password123'
      };

      await userService.createUser(userData);
      const token = await userService.authenticateUser(userData.email, userData.password);

      expect(typeof token).toBe('string');
      expect(token.length).toBeGreaterThan(0);
    });

    it('should throw NotFoundError with invalid credentials', async () => {
      await expect(userService.authenticateUser('nonexistent@example.com', 'password'))
        .rejects.toThrow(NotFoundError);
    });
  });
});

🚀 Step 9: Application Bootstrap

ℹ️
The bootstrap process ties everything together, properly initializing services, connecting to databases, and starting the Express server. This includes proper error handling and graceful shutdown procedures for production reliability.

Service Registration

Create src/services.ts:
import { UserService } from './user/user.service';

// Service instances
export let userService: UserService;

export const initializeServices = () => {
  userService = new UserService();
  
  // Add more services here as you create them
  // productService = new ProductService();
  // orderService = new OrderService();
};

Main Application

Create src/app.ts:
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import { RequestContext } from '@mikro-orm/core';

import { config } from './config';
import { DI } from './DI';
import { 
  errorHandler, 
  notFoundHandler 
} from './shared/middleware';

// Import routes
import { UserRouter } from './user/user.routes';

export const createApp = () => {
  const app = express();

  // Security middleware
  app.use(helmet());
  app.use(cors({ 
    origin: config.clientOrigins[config.nodeEnv],
    credentials: true 
  }));

  // Body parsing middleware
  app.use(express.json({ limit: '10mb' }));
  app.use(express.urlencoded({ extended: true }));

  // HTTP logging
  if (config.nodeEnv !== 'test') {
    app.use(morgan('combined'));
  }

  // Database context middleware
  app.use((req, res, next) => RequestContext.create(DI.orm.em, next));

  // Health check endpoint
  app.get('/health', (req, res) => {
    res.json({
      status: 'ok',
      timestamp: new Date().toISOString(),
      environment: config.nodeEnv
    });
  });

  // API routes
  app.use('/api/users', UserRouter);

  // Error handling middleware (must be last)
  app.use(notFoundHandler);
  app.use(errorHandler);

  return app;
};

Application Entry Point

Create src/index.ts:
import { MikroORM } from '@mikro-orm/core';
import { MongoClient } from 'mongodb';
import mikroOrmConfig from './mikro-orm.config';
import { createApp } from './app';
import { DI } from './DI';
import { config } from './config';
import { initializeServices } from './services';
import { logger } from './shared/services/logger.service';

const bootstrap = async () => {
  try {
    logger.info('🚀 Starting application...');

    // Initialize database connections
    logger.info('📦 Connecting to database...');
    const client = await MongoClient.connect(config.mongoConnectionString);
    
    DI.orm = await MikroORM.init(mikroOrmConfig);
    DI.em = DI.orm.em;
    DI.nativeClient = client.db('myapp');
    DI.logger = logger;

    logger.info('✅ Database connected');

    // Initialize services
    initializeServices();
    logger.info('🔧 Services initialized');

    // Create Express app
    const app = createApp();

    // Start server
    DI.server = app.listen(config.port, () => {
      logger.info(`🌟 Server running on port ${config.port}`);
      logger.info(`📖 Environment: ${config.nodeEnv}`);
      logger.info(`🏠 Health check: http://localhost:${config.port}/health`);
    });

    // Graceful shutdown handling
    const shutdown = async (signal: string) => {
      logger.info(`🛑 Received ${signal}, shutting down gracefully...`);
      
      DI.server.close(async () => {
        logger.info('🔌 HTTP server closed');
        
        try {
          await DI.orm.close();
          logger.info('📦 Database connection closed');
          process.exit(0);
        } catch (error) {
          logger.error('Error during shutdown:', error);
          process.exit(1);
        }
      });
    };

    process.on('SIGTERM', () => shutdown('SIGTERM'));
    process.on('SIGINT', () => shutdown('SIGINT'));

  } catch (error) {
    logger.error('❌ Error starting application:', error);
    process.exit(1);
  }
};

// Handle unhandled promise rejections
process.on('unhandledRejection', (reason, promise) => {
  logger.error('Unhandled Rejection at:', promise, 'reason:', reason);
  process.exit(1);
});

// Handle uncaught exceptions
process.on('uncaughtException', (error) => {
  logger.error('Uncaught Exception:', error);
  process.exit(1);
});

bootstrap();

🏃‍♂️ Step 10: Run Your Application

ℹ️
This final step verifies that everything is working correctly by starting the server, testing the API endpoints, and running the test suite to ensure the application functions as expected.

Install Missing Dependencies

# We need bcrypt and jsonwebtoken for the user authentication
npm install bcrypt jsonwebtoken
npm install --save-dev @types/bcrypt @types/jsonwebtoken

Start Development Server

# Start MongoDB (if running locally)
mongod

# Start the application in development mode
npm run dev
Your application should now be running on http://localhost:3000!

Test the API

# Health check
curl http://localhost:3000/health

# Register a user
curl -X POST http://localhost:3000/api/users/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","firstName":"John","lastName":"Doe"}'

# Login
curl -X POST http://localhost:3000/api/users/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123"}'

# Get profile (use the token from login response)
curl http://localhost:3000/api/users/profile \
  -H "Authorization: Bearer YOUR_JWT_TOKEN_HERE"

Run Tests

npm test

🎯 What You’ve Built

Congratulations! You now have a production-ready Express.js application with:
Type-safe development with TypeScript
Scalable architecture with domain-driven structure
Database integration with MikroORM and MongoDB
Authentication system with JWT tokens
Comprehensive error handling with custom error classes
Input validation with express-validator
Logging system with Winston
Testing infrastructure with Jest
Security features with Helmet and CORS
Development workflow with hot reloading
 

🚀 Next Steps

Now that you have the foundation, you can:
  1. Add more domains (products, orders, etc.) following the same patterns
  1. Implement role-based authorization for different user types
  1. Add file upload capabilities with Multer
  1. Integrate real-time features with WebSockets or Server-Sent Events
  1. Add API documentation with Swagger/OpenAPI
  1. Set up CI/CD pipelines for deployment
  1. Add monitoring and observability with tools like Sentry
  1. Implement caching with Redis
  1. Add rate limiting for API protection
  1. Create admin dashboard for user management
 

📚 Architecture Benefits

This setup provides:
  • Maintainability: Clear separation of concerns
  • Scalability: Easy to add new features and domains
  • Testability: Comprehensive testing infrastructure
  • Type Safety: Full TypeScript coverage
  • Security: Built-in security best practices
  • Developer Experience: Hot reloading and clear error messages
  • Production Ready: Logging, error handling, and graceful shutdown
 

You now have a solid foundation for building any Express.js application! 🎉