Building a Modern Node.js CLI Generator for Microservices – Part 3: Template Generation System

Welcome to Part 3 of our CLI generator series! Now we’ll dive into the core template generation system using Mustache.js to create dynamic, production-ready code.

Template Generation Architecture

Our template system needs to be flexible enough to generate different combinations of services while maintaining clean, readable code. We’ll use Mustache.js for its simplicity and power.

flowchart TD
    A[CLI Input] --> B[Template Generator]
    B --> C{Services Selected?}
    C -->|HTTP| D[HTTP Service Template]
    C -->|Kafka| E[Kafka Service Template]
    C -->|Redis| F[Redis Service Template]
    D --> G[Mustache Renderer]
    E --> G
    F --> G
    G --> H[Generated Files]
    H --> I[Package.json]
    H --> J[Source Files]
    H --> K[Config Files]
    H --> L[Docker Files]
    
    style A fill:#e1f5fe
    style B fill:#f3e5f5
    style G fill:#e8f5e8
    style H fill:#fff3e0

Template Generator Implementation

Let’s create the core template generation logic:

import fs from 'fs-extra';
import path from 'path';
import Mustache from 'mustache';

export class TemplateGenerator {
  private templatesDir: string;
  
  constructor(templatesDir: string) {
    this.templatesDir = templatesDir;
  }

  async generateProject(config: ProjectConfig, outputDir: string) {
    await fs.ensureDir(outputDir);
    
    // Generate package.json
    await this.generatePackageJson(config, outputDir);
    
    // Generate source files
    await this.generateSourceFiles(config, outputDir);
    
    // Generate configuration files
    await this.generateConfigFiles(config, outputDir);
    
    // Generate Docker files
    await this.generateDockerFiles(config, outputDir);
  }

  private async generatePackageJson(config: ProjectConfig, outputDir: string) {
    const template = await fs.readFile(
      path.join(this.templatesDir, 'package.json.mustache'), 
      'utf8'
    );
    
    const packageData = {
      name: config.name,
      description: config.description,
      useTypeScript: config.useTypeScript,
      enableHttp: config.enableHttp,
      enableKafka: config.enableKafka,
      enableRedis: config.enableRedis
    };
    
    const output = Mustache.render(template, packageData);
    await fs.writeFile(path.join(outputDir, 'package.json'), output);
  }
}

Dynamic Package.json Template

Here’s our smart package.json template that includes dependencies based on selected services:

{
  "name": "{{name}}",
  "version": "1.0.0",
  "description": "{{description}}",
  "main": "{{#useTypeScript}}dist/index.js{{/useTypeScript}}{{^useTypeScript}}src/index.js{{/useTypeScript}}",
  {{#useTypeScript}}"types": "dist/index.d.ts",{{/useTypeScript}}
  "scripts": {
    {{#useTypeScript}}"build": "tsc",{{/useTypeScript}}
    {{#useTypeScript}}"dev": "tsx watch src/index.ts",{{/useTypeScript}}
    {{^useTypeScript}}"dev": "nodemon src/index.js",{{/useTypeScript}}
    "start": "node {{#useTypeScript}}dist/index.js{{/useTypeScript}}{{^useTypeScript}}src/index.js{{/useTypeScript}}",
    "test": "jest",
    "lint": "eslint src/**/*.{{#useTypeScript}}ts{{/useTypeScript}}{{^useTypeScript}}js{{/useTypeScript}}"
  },
  "dependencies": {
    "dotenv": "^16.3.1"{{#enableHttp}},
    "express": "^4.18.2",
    "cors": "^2.8.5",
    "helmet": "^7.0.0"{{/enableHttp}}{{#enableKafka}},
    "kafkajs": "^2.2.4"{{/enableKafka}}{{#enableRedis}},
    "redis": "^4.6.7"{{/enableRedis}}
  },
  "devDependencies": {
    {{#useTypeScript}}"typescript": "^5.2.0",
    "@types/node": "^20.8.0",
    "tsx": "^3.14.0"{{#enableHttp}},
    "@types/express": "^4.17.17",
    "@types/cors": "^2.8.13"{{/enableHttp}}{{/useTypeScript}}
    {{^useTypeScript}}"nodemon": "^3.0.1"{{/useTypeScript}},
    "eslint": "^8.50.0",
    "jest": "^29.7.0"
  }
}

HTTP Service Template

Let’s create a production-ready HTTP service template with proper middleware and error handling:

{{#useTypeScript}}import express, { Request, Response, Application } from 'express';
import cors from 'cors';
import helmet from 'helmet';

export class HttpService {
  private app: Application;
  private port: number;

  constructor(port: number = {{httpPort}}) {
    this.app = express();
    this.port = port;
    this.setupMiddleware();
    this.setupRoutes();
  }

  private setupMiddleware(): void {
    this.app.use(helmet());
    this.app.use(cors());
    this.app.use(express.json());
    this.app.use(express.urlencoded({ extended: true }));
  }

  private setupRoutes(): void {
    this.app.get('/health', (req: Request, res: Response) => {
      res.json({ 
        status: 'healthy', 
        timestamp: new Date().toISOString(),
        service: '{{name}}'
      });
    });

    this.app.get('/', (req: Request, res: Response) => {
      res.json({ 
        message: 'Welcome to {{name}}',
        version: '1.0.0'
      });
    });
  }

  async start(): Promise<void> {
    return new Promise((resolve) => {
      this.app.listen(this.port, () => {
        console.log(`HTTP server running on port ${this.port}`);
        resolve();
      });
    });
  }

  getApp(): Application {
    return this.app;
  }
}{{/useTypeScript}}

Kafka Service Template

Here’s a robust Kafka service template with proper error handling and reconnection logic:

{{#enableKafka}}{{#useTypeScript}}import { Kafka, Producer, Consumer, KafkaMessage } from 'kafkajs';

export class KafkaService {
  private kafka: Kafka;
  private producer: Producer;
  private consumer: Consumer;
  private isConnected: boolean = false;

  constructor(brokers: string[] = ['{{kafkaBroker}}']) {
    this.kafka = new Kafka({
      clientId: '{{name}}',
      brokers,
      retry: {
        initialRetryTime: 100,
        retries: 8
      }
    });
    
    this.producer = this.kafka.producer();
    this.consumer = this.kafka.consumer({ 
      groupId: '{{name}}-group' 
    });
  }

  async connect(): Promise<void> {
    try {
      await this.producer.connect();
      await this.consumer.connect();
      this.isConnected = true;
      console.log('Kafka connected successfully');
    } catch (error) {
      console.error('Failed to connect to Kafka:', error);
      throw error;
    }
  }

  async publishMessage(topic: string, message: any): Promise<void> {
    if (!this.isConnected) {
      throw new Error('Kafka not connected');
    }

    await this.producer.send({
      topic,
      messages: [{
        key: String(Date.now()),
        value: JSON.stringify(message),
        timestamp: String(Date.now())
      }]
    });
  }

  async subscribe(topic: string, handler: (message: KafkaMessage) => Promise<void>): Promise<void> {
    if (!this.isConnected) {
      throw new Error('Kafka not connected');
    }

    await this.consumer.subscribe({ topic });
    
    await this.consumer.run({
      eachMessage: async ({ message }) => {
        try {
          await handler(message);
        } catch (error) {
          console.error('Error processing message:', error);
        }
      }
    });
  }

  async disconnect(): Promise<void> {
    await this.producer.disconnect();
    await this.consumer.disconnect();
    this.isConnected = false;
    console.log('Kafka disconnected');
  }
}{{/useTypeScript}}{{/enableKafka}}

Redis Service Template

A Redis service template with connection pooling and error handling:

{{#enableRedis}}{{#useTypeScript}}import { createClient, RedisClientType } from 'redis';

export class RedisService {
  private client: RedisClientType;
  private isConnected: boolean = false;

  constructor(url: string = '{{redisUrl}}') {
    this.client = createClient({
      url,
      retry_delay_on_failure: 100,
      max_attempts: 3
    });

    this.client.on('error', (err) => {
      console.error('Redis Client Error:', err);
    });

    this.client.on('connect', () => {
      console.log('Redis client connected');
    });

    this.client.on('ready', () => {
      this.isConnected = true;
      console.log('Redis client ready');
    });
  }

  async connect(): Promise<void> {
    try {
      await this.client.connect();
    } catch (error) {
      console.error('Failed to connect to Redis:', error);
      throw error;
    }
  }

  async set(key: string, value: any, ttl?: number): Promise<void> {
    if (!this.isConnected) {
      throw new Error('Redis not connected');
    }

    const serializedValue = JSON.stringify(value);
    
    if (ttl) {
      await this.client.setEx(key, ttl, serializedValue);
    } else {
      await this.client.set(key, serializedValue);
    }
  }

  async get<T = any>(key: string): Promise<T | null> {
    if (!this.isConnected) {
      throw new Error('Redis not connected');
    }

    const value = await this.client.get(key);
    return value ? JSON.parse(value) : null;
  }

  async delete(key: string): Promise<void> {
    if (!this.isConnected) {
      throw new Error('Redis not connected');
    }

    await this.client.del(key);
  }

  async disconnect(): Promise<void> {
    await this.client.disconnect();
    this.isConnected = false;
    console.log('Redis disconnected');
  }
}{{/useTypeScript}}{{/enableRedis}}

Main Application Template

The main index file that orchestrates all services with graceful shutdown:

{{#useTypeScript}}import 'dotenv/config';
{{#enableHttp}}import { HttpService } from './services/http';{{/enableHttp}}
{{#enableKafka}}import { KafkaService } from './services/kafka';{{/enableKafka}}
{{#enableRedis}}import { RedisService } from './services/redis';{{/enableRedis}}

class Application {
  {{#enableHttp}}private httpService: HttpService;{{/enableHttp}}
  {{#enableKafka}}private kafkaService: KafkaService;{{/enableKafka}}
  {{#enableRedis}}private redisService: RedisService;{{/enableRedis}}

  constructor() {
    {{#enableHttp}}this.httpService = new HttpService(Number(process.env.PORT) || {{httpPort}});{{/enableHttp}}
    {{#enableKafka}}this.kafkaService = new KafkaService();{{/enableKafka}}
    {{#enableRedis}}this.redisService = new RedisService();{{/enableRedis}}
  }

  async start(): Promise<void> {
    try {
      console.log('Starting {{name}}...');

      {{#enableRedis}}// Connect to Redis
      await this.redisService.connect();{{/enableRedis}}

      {{#enableKafka}}// Connect to Kafka
      await this.kafkaService.connect();
      
      // Setup Kafka message handling
      await this.kafkaService.subscribe('{{name}}-events', async (message) => {
        console.log('Received message:', message.value?.toString());
      });{{/enableKafka}}

      {{#enableHttp}}// Start HTTP server
      await this.httpService.start();{{/enableHttp}}

      console.log('{{name}} started successfully');
    } catch (error) {
      console.error('Failed to start application:', error);
      process.exit(1);
    }
  }

  async shutdown(): Promise<void> {
    console.log('Shutting down gracefully...');

    {{#enableKafka}}await this.kafkaService.disconnect();{{/enableKafka}}
    {{#enableRedis}}await this.redisService.disconnect();{{/enableRedis}}

    console.log('Shutdown complete');
    process.exit(0);
  }
}

const app = new Application();

// Graceful shutdown handling
process.on('SIGTERM', () => app.shutdown());
process.on('SIGINT', () => app.shutdown());

// Start the application
app.start().catch((error) => {
  console.error('Application failed to start:', error);
  process.exit(1);
});{{/useTypeScript}}

Environment Configuration Template

Generate a comprehensive .env.example file:

# {{name}} Configuration

# Application
NODE_ENV=development
LOG_LEVEL=info

{{#enableHttp}}# HTTP Server
PORT={{httpPort}}
{{/enableHttp}}

{{#enableKafka}}# Kafka Configuration
KAFKA_BROKERS={{kafkaBroker}}
KAFKA_CLIENT_ID={{name}}
KAFKA_GROUP_ID={{name}}-group
{{/enableKafka}}

{{#enableRedis}}# Redis Configuration
REDIS_URL={{redisUrl}}
REDIS_PASSWORD=
REDIS_DB=0
{{/enableRedis}}

# Health Check
HEALTH_CHECK_INTERVAL=30000

Docker Configuration

Generate Docker and docker-compose files for easy deployment:

version: '3.8'

services:
  {{name}}:
    build: .
    ports:
      {{#enableHttp}}- "{{httpPort}}:{{httpPort}}"{{/enableHttp}}
    environment:
      - NODE_ENV=production
      {{#enableHttp}}- PORT={{httpPort}}{{/enableHttp}}
      {{#enableKafka}}- KAFKA_BROKERS=kafka:9092{{/enableKafka}}
      {{#enableRedis}}- REDIS_URL=redis://redis:6379{{/enableRedis}}
    depends_on:
      {{#enableKafka}}- kafka{{/enableKafka}}
      {{#enableRedis}}- redis{{/enableRedis}}

  {{#enableKafka}}kafka:
    image: confluentinc/cp-kafka:latest
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
    depends_on:
      - zookeeper

  zookeeper:
    image: confluentinc/cp-zookeeper:latest
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000{{/enableKafka}}

  {{#enableRedis}}redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data

volumes:
  redis_data:{{/enableRedis}}

What’s Coming in Part 4?

In our final part, we’ll cover advanced features and publishing the CLI tool:

  • Testing the generated templates
  • Adding custom middleware templates
  • Publishing to npm registry
  • Versioning and updates
  • Advanced configuration options

Our template system is now capable of generating production-ready microservices with proper architecture and best practices!

Written by:

339 Posts

View All Posts
Follow Me :