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:
- Add more domains (products, orders, etc.) following the same patterns
- Implement role-based authorization for different user types
- Add file upload capabilities with Multer
- Integrate real-time features with WebSockets or Server-Sent Events
- Add API documentation with Swagger/OpenAPI
- Set up CI/CD pipelines for deployment
- Add monitoring and observability with tools like Sentry
- Implement caching with Redis
- Add rate limiting for API protection
- 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