Backend

Production Node.js Architecture: Building a Modular TypeScript Backend

DD
Ankur Ishwar
12 min read Updated Sep 7, 2026
Production Node.js Architecture Building a Modular TypeScript Backend

The 800-Line server.js Anti-Pattern

Open GitHub and search for junior Node.js developer projects. You will see thousands of repositories where the entire backend lives in a single, massive server.js file. Database connections, raw SQL queries, authentication middleware, payment logic, and response handlers are all tangled inside nested callback chains.

When an unhandled promise rejection triggers or a database error occurs, the server crashes completely. There is no input sanitization, no environment configuration validation, and no structured logging. Senior backend engineers reject these repositories on sight because writing code that works on localhost is easy. Writing code that survives production traffic requires architectural discipline.

In this guide, we will build an enterprise-ready Modular Node.js Backend using Node 22 LTS, TypeScript 5.7, Fastify, and Zod. We will implement strict layered architecture (Routes -> Controllers -> Services -> Repositories), centralized error handling, and graceful process shutdown.

The Production Layered Architecture

Every maintainable Node.js service isolates responsibilities across four distinct layers:

Incoming HTTP Request
         │
         ▼
[ 1. Transport Layer (Routes & Middleware) ]
     - Rate limiting, CORS, Authentication
     - Schema validation with Zod (Returns 400 Bad Request immediately)
         │
         ▼
[ 2. Controller Layer ]
     - Parses HTTP request parameters
     - Delegates execution to the Service layer
     - Formats standard JSON HTTP responses
         │
         ▼
[ 3. Service Layer (Pure Business Logic) ]
     - Zero HTTP or Fastify/Express dependencies
     - Coordinates transactions, discount rules, external API calls
     - Throws strongly-typed Domain Exceptions
         │
         ▼
[ 4. Data Access Layer (Repository / Database) ]
     - Executes SQL queries or ORM commands
     - Returns raw database records to the Service layer

Step 1: Clean Project Setup and Environment Validation

Never read process.env.PORT blindly throughout your codebase. If someone forgets to set a required database password in production, the application should fail immediately at boot time with a clear error message.

mkdir production-node-backend && cd production-node-backend
npm init -y

# Install runtime dependencies
npm install fastify @fastify/cors @fastify/sensible zod dotenv pino pino-pretty

# Install development tooling
npm install -D typescript @types/node tsx vitest

Now, validate your environment variables using Zod:

// src/config/env.ts
import { z } from 'zod';
import * as dotenv from 'dotenv';

dotenv.config();

const envSchema = z.object({
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  HOST: z.string().default('0.0.0.0'),
  DATABASE_URL: z.string().url(),
  JWT_SECRET: z.string().min(32, 'JWT_SECRET must be at least 32 characters long'),
});

const parsed = envSchema.safeParse(process.env);

if (!parsed.success) {
  console.error('Invalid environment configuration:');
  console.error(JSON.stringify(parsed.error.format(), null, 2));
  process.exit(1);
}

export const env = parsed.data;

Step 2: Strongly-Typed Domain Error Hierarchy

Never send raw SQL errors or unformatted exception messages back to client devices. We build custom error classes and a centralized Fastify error hook.

// src/errors/AppError.ts
export class AppError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly message: string,
    public readonly code: string,
    public readonly details: unknown = null
  ) {
    super(message);
    Object.setPrototypeOf(this, new.target.prototype);
  }
}

export class NotFoundError extends AppError {
  constructor(resource: string, id: string) {
    super(404, `${resource} with id ${id} not found.`, 'NOT_FOUND');
  }
}

export class ValidationError extends AppError {
  constructor(details: unknown) {
    super(400, 'Invalid request payload.', 'VALIDATION_ERROR', details);
  }
}

export class ConflictError extends AppError {
  constructor(message: string) {
    super(409, message, 'CONFLICT');
  }
}

Step 3: Implementing an Order Processing Domain

Let us implement an e-commerce order creation flow showing how routes, controllers, and services interact cleanly.

The Validation Schema (Zod):

// src/modules/orders/order.schema.ts
import { z } from 'zod';

export const createOrderSchema = z.object({
  customerId: z.string().uuid(),
  items: z.array(
    z.object({
      productId: z.string().uuid(),
      quantity: z.number().int().positive(),
      unitPrice: z.number().positive(),
    })
  ).min(1, 'Order must contain at least one item'),
  discountCode: z.string().optional(),
});

export type CreateOrderInput = z.infer<typeof createOrderSchema>;

The Service Layer (Pure Business Logic):

// src/modules/orders/order.service.ts
import { CreateOrderInput } from './order.schema.js';
import { ConflictError } from '../../errors/AppError.js';

export interface Order {
  id: string;
  customerId: string;
  totalAmount: number;
  status: 'PENDING' | 'PAID';
  createdAt: Date;
}

export class OrderService {
  // In a real application, inject your Database Repository via constructor
  constructor() {}

  async createOrder(input: CreateOrderInput): Promise<Order> {
    // Check business invariant: calculate total
    const subtotal = input.items.reduce(
      (sum, item) => sum + item.quantity * item.unitPrice,
      0
    );

    let discountMultiplier = 1.0;
    if (input.discountCode) {
      if (input.discountCode === 'DROPOUT50') {
        discountMultiplier = 0.5;
      } else {
        throw new ConflictError(`Invalid or expired discount code: ${input.discountCode}`);
      }
    }

    const totalAmount = subtotal * discountMultiplier;

    const newOrder: Order = {
      id: crypto.randomUUID(),
      customerId: input.customerId,
      totalAmount,
      status: 'PENDING',
      createdAt: new Date(),
    };

    // Persist order to database in a transaction
    return newOrder;
  }
}

The Controller and Fastify Routes:

// src/modules/orders/order.routes.ts
import { FastifyPluginAsync } from 'fastify';
import { OrderService } from './order.service.js';
import { createOrderSchema } from './order.schema.js';
import { ValidationError } from '../../errors/AppError.js';

export const orderRoutes: FastifyPluginAsync = async (fastify) => {
  const orderService = new OrderService();

  fastify.post('/', async (request, reply) => {
    // Schema validation hook
    const parseResult = createOrderSchema.safeParse(request.body);
    if (!parseResult.success) {
      throw new ValidationError(parseResult.error.flatten());
    }

    const order = await orderService.createOrder(parseResult.data);
    return reply.status(201).send({ success: true, data: order });
  });
};

Step 4: Graceful Shutdown and Server Bootstrap

In containerized environments like Kubernetes, Docker, or AWS ECS, servers receive a SIGTERM signal when deploying new versions. If you terminate immediately, active database queries are severed and in-flight payments fail.

A production server must intercept the signal, stop accepting new connections, finish ongoing requests, and close database connection pools cleanly before exiting.

// src/server.ts
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { env } from './config/env.js';
import { orderRoutes } from './modules/orders/order.routes.js';
import { AppError } from './errors/AppError.js';

const server = Fastify({
  logger: {
    level: env.NODE_ENV === 'development' ? 'info' : 'warn',
  },
});

await server.register(cors, { origin: true });

// Centralized Error Handler Hook
server.setErrorHandler((error, request, reply) => {
  if (error instanceof AppError) {
    return reply.status(error.statusCode).send({
      success: false,
      code: error.code,
      message: error.message,
      details: error.details,
    });
  }

  // Log unexpected internal errors securely
  request.log.error(error);
  return reply.status(500).send({
    success: false,
    code: 'INTERNAL_SERVER_ERROR',
    message: 'An unexpected error occurred. Please try again later.',
  });
});

// Health Checks for Cloud Readiness
server.get('/health/live', async () => ({ status: 'alive' }));
server.get('/health/ready', async () => ({ status: 'ready', database: 'connected' }));

// Register Feature Modules
await server.register(orderRoutes, { prefix: '/api/v1/orders' });

// Start Server
try {
  await server.listen({ port: env.PORT, host: env.HOST });
  console.log(`Server listening on http://${env.HOST}:${env.PORT}`);
} catch (err) {
  server.log.error(err);
  process.exit(1);
}

// Graceful Shutdown Handler
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM'];
for (const signal of signals) {
  process.on(signal, async () => {
    console.log(`Received ${signal}. Draining active connections...`);
    try {
      await server.close();
      console.log('Server shut down cleanly. Exiting.');
      process.exit(0);
    } catch (err) {
      console.error('Error during shutdown:', err);
      process.exit(1);
    }
  });
}

What to Put on Your Resume

Compare how typical junior candidates describe their Node.js projects vs how you should phrase it:

Weak Resume Bullet Engineered Resume Bullet
Built a REST API with Node.js, Express, and MongoDB for ordering products. Architected a modular e-commerce backend in Node 22 and Fastify with TypeScript, implementing Zod schema enforcement, domain-driven service layers, centralized error handling, and zero-downtime SIGTERM graceful shutdown hooks.

Next Steps

Take this project further by adding integration tests using Vitest and Supertest, containerizing with a multi-stage Dockerfile, and generating OpenAPI documentation directly from your Zod schemas.

To dive deeper into backend systems and career preparation, explore our guides on resume-worthy coding projects, building real-world projects for your portfolio, strengthening your JavaScript project fundamentals, and our realistic playbook for becoming a developer without a degree.

Found this useful?
View all articles
Free Technical Interview Prep

Practicing for Engineering Interviews?

Skip the expensive coaching bootcamps and dry LeetCode memorization. Practice real production scenarios with instant turn-by-turn AI feedback on Frontend, Backend, System Design, and DSA.

Free Utilities

Recommended Developer Tools for this Topic

Explore all 25+ tools

Keep Reading

Related Articles

Learn with Dropout Developer

Build real software with AI

Step-by-step learning paths, vibe coding tutorials, and certified developer programs designed for the modern engineer.