Skip to content

Module Creation Guide

Modules are the building blocks of reusable business logic in Gati. They provide shared functionality through dependency injection and support full lifecycle management. This guide covers everything you need to create robust, testable modules.

Table of Contents

Module Overview

A module in Gati is a reusable component that:

  • Provides shared functionality across handlers
  • Initializes once at application startup
  • Supports lifecycle hooks (init, shutdown, health checks)
  • Integrates via dependency injection through global context
  • Can depend on other modules

Why Use Modules?

✅ Benefits:

  • Reusability: Write once, use everywhere
  • Testability: Easy to mock and test
  • Lifecycle Management: Automatic initialization and cleanup
  • Dependency Injection: Loose coupling, better architecture
  • Resource Management: Share expensive resources (DB connections, caches)

Common Use Cases:

  • Database connections
  • Cache clients (Redis, Memcached)
  • Logging systems
  • Authentication services
  • External API clients
  • Email services
  • File storage

Module Structure

Module Interface

typescript
interface Module<T = unknown> {
  name: string;              // Unique module identifier
  version: string;           // Semantic version
  description?: string;      // Optional description
  dependencies?: string[];   // Required module names
  exports: T;                // Module functionality
  init?(gctx: GlobalContext): Promise<void> | void;     // Initialization
  shutdown?(): Promise<void> | void;                     // Cleanup
  healthCheck?(): Promise<boolean> | boolean;            // Health status
}

Basic Module Example

typescript
export interface Logger {
  log: (message: string) => void;
  error: (message: string, error?: Error) => void;
  warn: (message: string) => void;
}

export const loggerModule: Module<Logger> = {
  name: 'logger',
  version: '1.0.0',
  description: 'Simple logging module',
  
  exports: {
    log: (message: string) => console.log(`[INFO] ${message}`),
    error: (message: string, error?: Error) => {
      console.error(`[ERROR] ${message}`);
      if (error) console.error(error);
    },
    warn: (message: string) => console.warn(`[WARN] ${message}`),
  },
};

Module Lifecycle

Modules go through several lifecycle stages:

1. Registration

Module is registered with the module loader:

typescript
const loader = createModuleLoader();
await loader.register(myModule, gctx);

2. Initialization

The init function is called (if provided):

typescript
const dbModule: Module<Database> = {
  name: 'database',
  version: '1.0.0',
  
  exports: {} as Database, // Will be populated in init
  
  async init(gctx: GlobalContext) {
    // Create database connection
    const connection = await createConnection({
      host: gctx.config.dbHost,
      port: gctx.config.dbPort,
    });
    
    // Assign to exports
    Object.assign(this.exports, {
      query: (sql) => connection.query(sql),
      close: () => connection.close(),
    });
    
    console.log('Database module initialized');
  },
};

3. Active/Running

Module is available via global context:

typescript
const handler: Handler = async (req, res, gctx) => {
  const db = gctx.modules['database'];
  const users = await db.query('SELECT * FROM users');
  res.json({ users });
};

4. Shutdown

The shutdown function is called on app shutdown:

typescript
const dbModule: Module<Database> = {
  // ... other properties
  
  async shutdown() {
    await this.exports.close();
    console.log('Database module shut down');
  },
};

Lifecycle State Diagram

uninitialized → initializing → initialized → shutdown

                   error

Creating Modules

Pattern 1: Simple Function Module

Best for stateless utilities:

typescript
import type { Module } from 'gati';
import type { GlobalContext } from 'gati';

export interface MathUtils {
  add: (a: number, b: number) => number;
  multiply: (a: number, b: number) => number;
}

export function createMathModule(): Module<MathUtils> {
  return {
    name: 'math',
    version: '1.0.0',
    description: 'Math utility functions',
    
    exports: {
      add: (a, b) => a + b,
      multiply: (a, b) => a * b,
    },
  };
}

Pattern 2: Factory Function Module

Best for modules with configuration:

typescript
export interface Logger {
  log: (message: string) => void;
  error: (message: string, error?: Error) => void;
}

export interface LoggerConfig {
  level: 'info' | 'warn' | 'error';
  prefix?: string;
}

export function createLoggerModule(config: LoggerConfig): Module<Logger> {
  const { level, prefix = '[App]' } = config;
  
  return {
    name: 'logger',
    version: '1.0.0',
    
    exports: {
      log: (message) => {
        if (level === 'info') {
          console.log(`${prefix} [INFO] ${message}`);
        }
      },
      
      error: (message, error) => {
        console.error(`${prefix} [ERROR] ${message}`);
        if (error) console.error(error);
      },
    },
  };
}

Pattern 3: Class-Based Module

Best for complex stateful modules:

typescript
export class DatabaseModule implements Module<DatabaseClient> {
  name = 'database';
  version = '1.0.0';
  description = 'PostgreSQL database module';
  
  private connection: Connection | null = null;
  
  exports = {
    query: async (sql: string) => {
      if (!this.connection) {
        throw new Error('Database not connected');
      }
      return this.connection.query(sql);
    },
    
    transaction: async (fn: (tx: Transaction) => Promise<void>) => {
      if (!this.connection) {
        throw new Error('Database not connected');
      }
      return this.connection.transaction(fn);
    },
  };
  
  async init(gctx: GlobalContext): Promise<void> {
    const config = gctx.config as { dbUrl: string };
    this.connection = await createConnection(config.dbUrl);
    console.log('Database connected');
  }
  
  async shutdown(): Promise<void> {
    if (this.connection) {
      await this.connection.close();
      this.connection = null;
      console.log('Database disconnected');
    }
  }
  
  async healthCheck(): Promise<boolean> {
    if (!this.connection) return false;
    
    try {
      await this.connection.query('SELECT 1');
      return true;
    } catch {
      return false;
    }
  }
}

Pattern 4: Async Initialization Module

Best for modules requiring async setup:

typescript
export interface CacheClient {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string, ttl?: number) => Promise<void>;
  del: (key: string) => Promise<void>;
}

export function createCacheModule(): Module<CacheClient> {
  let client: RedisClient | null = null;
  
  return {
    name: 'cache',
    version: '1.0.0',
    description: 'Redis cache module',
    
    exports: {
      get: async (key) => {
        if (!client) throw new Error('Cache not initialized');
        return client.get(key);
      },
      
      set: async (key, value, ttl) => {
        if (!client) throw new Error('Cache not initialized');
        return client.set(key, value, ttl);
      },
      
      del: async (key) => {
        if (!client) throw new Error('Cache not initialized');
        return client.del(key);
      },
    },
    
    async init(gctx: GlobalContext) {
      const config = gctx.config as { redisUrl: string };
      client = await createRedisClient(config.redisUrl);
      
      // Test connection
      await client.ping();
      console.log('Redis cache connected');
    },
    
    async shutdown() {
      if (client) {
        await client.quit();
        client = null;
        console.log('Redis cache disconnected');
      }
    },
    
    async healthCheck() {
      if (!client) return false;
      try {
        await client.ping();
        return true;
      } catch {
        return false;
      }
    },
  };
}

Dependency Injection

Accessing Modules in Handlers

Modules are injected via global context:

typescript
const handler: Handler = async (req, res, gctx) => {
  // Access modules
  const db = gctx.modules['database'] as Database;
  const cache = gctx.modules['cache'] as CacheClient;
  const logger = gctx.modules['logger'] as Logger;
  
  // Use modules
  logger.log('Fetching user');
  const user = await db.query('SELECT * FROM users WHERE id = $1', [req.params.id]);
  
  res.json({ user });
};

Type-Safe Module Access

Create typed helper functions:

typescript
// src/modules/types.ts
export interface ModuleRegistry {
  database: Database;
  cache: CacheClient;
  logger: Logger;
  email: EmailService;
}

// Helper function
export function getModule<K extends keyof ModuleRegistry>(
  gctx: GlobalContext,
  name: K
): ModuleRegistry[K] {
  return gctx.modules[name] as ModuleRegistry[K];
}

// Usage in handler
const handler: Handler = async (req, res, gctx) => {
  const db = getModule(gctx, 'database');     // Typed as Database
  const cache = getModule(gctx, 'cache');     // Typed as CacheClient
  
  // TypeScript knows the types!
  const user = await db.query(/* ... */);
  await cache.set(/* ... */);
};

Module Dependencies

Modules can depend on other modules:

typescript
export function createUserServiceModule(): Module<UserService> {
  return {
    name: 'userService',
    version: '1.0.0',
    dependencies: ['database', 'cache'], // Requires these modules
    
    exports: {
      async getUser(id: string, gctx: GlobalContext) {
        const cache = gctx.modules['cache'] as CacheClient;
        const db = gctx.modules['database'] as Database;
        
        // Try cache first
        const cached = await cache.get(`user:${id}`);
        if (cached) return JSON.parse(cached);
        
        // Fallback to database
        const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
        
        // Store in cache
        await cache.set(`user:${id}`, JSON.stringify(user), 3600);
        
        return user;
      },
    },
    
    async init(gctx: GlobalContext) {
      // Dependencies are guaranteed to be initialized
      const db = gctx.modules['database'];
      const cache = gctx.modules['cache'];
      
      console.log('User service initialized with dependencies');
    },
  };
}

Dependency Ordering

The module loader automatically resolves dependencies:

typescript
// These will be initialized in the correct order:
// 1. database (no dependencies)
// 2. cache (no dependencies)
// 3. userService (depends on database and cache)

modules: (gctx) => {
  gctx.modules['database'] = createDatabaseModule();
  gctx.modules['cache'] = createCacheModule();
  gctx.modules['userService'] = createUserServiceModule();
}

Module Registration

In Configuration File

Register modules in gati.config.ts:

typescript
import type { GlobalContext } from 'gati';
import { createLoggerModule } from './src/modules/logger';
import { createDatabaseModule } from './src/modules/database';
import { createCacheModule } from './src/modules/cache';

export default {
  server: {
    port: 3000,
    host: 'localhost',
  },
  
  routes: [/* ... */],
  
  modules: (gctx: GlobalContext) => {
    // Register logger
    gctx.modules['logger'] = createLoggerModule({
      level: 'info',
      prefix: '[MyApp]',
    }).exports;
    
    // Register database
    gctx.modules['database'] = createDatabaseModule({
      url: process.env.DATABASE_URL!,
    }).exports;
    
    // Register cache
    gctx.modules['cache'] = createCacheModule({
      url: process.env.REDIS_URL!,
    }).exports;
  },
};

With Module Loader

For advanced scenarios with lifecycle management:

typescript
import { createModuleLoader } from 'gati/runtime';

export default {
  server: { port: 3000 },
  routes: [/* ... */],
  
  async modules(gctx: GlobalContext) {
    const loader = createModuleLoader({
      autoInit: true,
      initTimeout: 30000,
    });
    
    // Register modules
    await loader.register(createDatabaseModule(), gctx);
    await loader.register(createCacheModule(), gctx);
    await loader.register(createUserServiceModule(), gctx);
    
    // Get initialized modules
    gctx.modules['database'] = await loader.get('database', gctx);
    gctx.modules['cache'] = await loader.get('cache', gctx);
    gctx.modules['userService'] = await loader.get('userService', gctx);
    
    // Register cleanup
    gctx.lifecycle.onShutdown(async () => {
      await loader.shutdownAll();
    });
  },
};

Testing Modules

Unit Testing Modules

Test modules in isolation:

typescript
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createLoggerModule } from '../src/modules/logger';

describe('Logger Module', () => {
  let logger: Logger;
  
  beforeEach(() => {
    const module = createLoggerModule({ level: 'info' });
    logger = module.exports;
  });
  
  it('should log info messages', () => {
    const spy = vi.spyOn(console, 'log');
    logger.log('test message');
    expect(spy).toHaveBeenCalledWith(expect.stringContaining('test message'));
  });
  
  it('should log error messages', () => {
    const spy = vi.spyOn(console, 'error');
    logger.error('error message');
    expect(spy).toHaveBeenCalledWith(expect.stringContaining('error message'));
  });
});

Testing Module Initialization

typescript
import { describe, it, expect } from 'vitest';
import { createDatabaseModule } from '../src/modules/database';
import { createGlobalContext } from 'gati/runtime';

describe('Database Module', () => {
  it('should initialize connection', async () => {
    const gctx = createGlobalContext({
      config: { dbUrl: 'postgresql://localhost/testdb' },
    });
    
    const module = createDatabaseModule();
    
    // Initialize module
    await module.init?.(gctx);
    
    // Verify connection
    expect(module.exports.query).toBeDefined();
    
    // Cleanup
    await module.shutdown?.();
  });
  
  it('should handle initialization errors', async () => {
    const gctx = createGlobalContext({
      config: { dbUrl: 'invalid://url' },
    });
    
    const module = createDatabaseModule();
    
    // Should throw on invalid config
    await expect(module.init?.(gctx)).rejects.toThrow();
  });
});

Mocking Modules in Handler Tests

typescript
import { describe, it, expect } from 'vitest';
import { createMockRequest, createMockResponse } from 'gati/testing';
import { getUserHandler } from '../src/handlers/user';

describe('User Handler', () => {
  it('should fetch user from database', async () => {
    // Create mock module
    const mockDb = {
      query: vi.fn().mockResolvedValue({ id: '1', name: 'Alice' }),
    };
    
    // Create mock context
    const gctx = createGlobalContext({
      modules: { database: mockDb },
    });
    const lctx = createLocalContext();
    
    // Create mock request/response
    const req = createMockRequest({ params: { id: '1' } });
    const res = createMockResponse();
    
    // Execute handler
    await getUserHandler(req, res, gctx, lctx);
    
    // Verify
    expect(mockDb.query).toHaveBeenCalledWith(
      expect.stringContaining('SELECT'),
      ['1']
    );
    expect(res.json).toHaveBeenCalledWith({
      user: { id: '1', name: 'Alice' },
    });
  });
});

Best Practices

1. Single Responsibility

Each module should have one clear purpose:

typescript
// ✅ Good - Focused module
const loggerModule = createLoggerModule();

// ❌ Bad - Too many responsibilities
const utilsModule = {
  log: () => {},
  sendEmail: () => {},
  processPayment: () => {},
  generatePdf: () => {},
};

2. Explicit Dependencies

Declare module dependencies explicitly:

typescript
// ✅ Good - Explicit dependencies
export function createUserServiceModule(): Module<UserService> {
  return {
    name: 'userService',
    dependencies: ['database', 'cache'],
    // ...
  };
}

// ❌ Bad - Hidden dependencies
export function createUserServiceModule(): Module<UserService> {
  return {
    name: 'userService',
    // Implicitly uses database and cache without declaring
  };
}

3. Handle Initialization Errors

Fail fast with clear error messages:

typescript
async init(gctx: GlobalContext) {
  try {
    this.connection = await createConnection(config);
  } catch (error) {
    throw new ModuleInitializationError(
      'database',
      new Error(`Failed to connect: ${error.message}`)
    );
  }
}

4. Cleanup Resources

Always implement shutdown for resources:

typescript
const dbModule: Module<Database> = {
  // ...
  
  async shutdown() {
    if (this.connection) {
      await this.connection.close();
      this.connection = null;
    }
    
    if (this.pool) {
      await this.pool.end();
      this.pool = null;
    }
  },
};

5. Provide Health Checks

Implement health checks for monitoring:

typescript
const dbModule: Module<Database> = {
  // ...
  
  async healthCheck() {
    if (!this.connection) return false;
    
    try {
      await this.connection.query('SELECT 1');
      return true;
    } catch {
      return false;
    }
  },
};

6. Use Semantic Versioning

Version your modules properly:

typescript
const module: Module<API> = {
  name: 'myModule',
  version: '2.1.0', // MAJOR.MINOR.PATCH
  // ...
};
  • MAJOR: Breaking changes
  • MINOR: New features (backward compatible)
  • PATCH: Bug fixes

Real-World Examples

Example 1: PostgreSQL Database Module

typescript
import { Pool } from 'pg';
import type { Module, GlobalContext } from 'gati';

export interface Database {
  query: <T = any>(sql: string, params?: any[]) => Promise<T[]>;
  transaction: <T>(fn: (client: PoolClient) => Promise<T>) => Promise<T>;
}

export function createDatabaseModule(): Module<Database> {
  let pool: Pool | null = null;
  
  return {
    name: 'database',
    version: '1.0.0',
    description: 'PostgreSQL database connection pool',
    
    exports: {
      query: async (sql, params = []) => {
        if (!pool) throw new Error('Database not initialized');
        const result = await pool.query(sql, params);
        return result.rows;
      },
      
      transaction: async (fn) => {
        if (!pool) throw new Error('Database not initialized');
        const client = await pool.connect();
        
        try {
          await client.query('BEGIN');
          const result = await fn(client);
          await client.query('COMMIT');
          return result;
        } catch (error) {
          await client.query('ROLLBACK');
          throw error;
        } finally {
          client.release();
        }
      },
    },
    
    async init(gctx: GlobalContext) {
      const config = gctx.config as { databaseUrl: string };
      
      pool = new Pool({
        connectionString: config.databaseUrl,
        max: 20,
        idleTimeoutMillis: 30000,
        connectionTimeoutMillis: 2000,
      });
      
      // Test connection
      await pool.query('SELECT NOW()');
      console.log('Database connection pool created');
      
      // Register shutdown
      gctx.lifecycle.onShutdown(async () => {
        await this.shutdown?.();
      });
    },
    
    async shutdown() {
      if (pool) {
        await pool.end();
        pool = null;
        console.log('Database connection pool closed');
      }
    },
    
    async healthCheck() {
      if (!pool) return false;
      try {
        await pool.query('SELECT 1');
        return true;
      } catch {
        return false;
      }
    },
  };
}

Example 2: Redis Cache Module

typescript
import { createClient } from 'redis';
import type { Module, GlobalContext } from 'gati';

export interface CacheClient {
  get: (key: string) => Promise<string | null>;
  set: (key: string, value: string, ttl?: number) => Promise<void>;
  del: (key: string) => Promise<void>;
  exists: (key: string) => Promise<boolean>;
}

export function createCacheModule(): Module<CacheClient> {
  let client: ReturnType<typeof createClient> | null = null;
  
  return {
    name: 'cache',
    version: '1.0.0',
    description: 'Redis cache client',
    
    exports: {
      get: async (key) => {
        if (!client) throw new Error('Cache not initialized');
        return client.get(key);
      },
      
      set: async (key, value, ttl) => {
        if (!client) throw new Error('Cache not initialized');
        if (ttl) {
          await client.setEx(key, ttl, value);
        } else {
          await client.set(key, value);
        }
      },
      
      del: async (key) => {
        if (!client) throw new Error('Cache not initialized');
        await client.del(key);
      },
      
      exists: async (key) => {
        if (!client) throw new Error('Cache not initialized');
        const result = await client.exists(key);
        return result > 0;
      },
    },
    
    async init(gctx: GlobalContext) {
      const config = gctx.config as { redisUrl: string };
      
      client = createClient({ url: config.redisUrl });
      
      client.on('error', (err) => {
        console.error('Redis client error:', err);
      });
      
      await client.connect();
      console.log('Redis cache connected');
    },
    
    async shutdown() {
      if (client) {
        await client.quit();
        client = null;
        console.log('Redis cache disconnected');
      }
    },
    
    async healthCheck() {
      if (!client || !client.isOpen) return false;
      try {
        await client.ping();
        return true;
      } catch {
        return false;
      }
    },
  };
}

Example 3: Email Service Module

typescript
import nodemailer from 'nodemailer';
import type { Module, GlobalContext } from 'gati';

export interface EmailService {
  send: (to: string, subject: string, body: string) => Promise<void>;
  sendTemplate: (to: string, template: string, data: any) => Promise<void>;
}

export function createEmailModule(): Module<EmailService> {
  let transporter: nodemailer.Transporter | null = null;
  
  return {
    name: 'email',
    version: '1.0.0',
    description: 'Email service using nodemailer',
    
    exports: {
      send: async (to, subject, body) => {
        if (!transporter) throw new Error('Email service not initialized');
        
        await transporter.sendMail({
          from: process.env.EMAIL_FROM,
          to,
          subject,
          html: body,
        });
      },
      
      sendTemplate: async (to, template, data) => {
        // Load template and render with data
        const body = renderTemplate(template, data);
        
        if (!transporter) throw new Error('Email service not initialized');
        
        await transporter.sendMail({
          from: process.env.EMAIL_FROM,
          to,
          subject: data.subject,
          html: body,
        });
      },
    },
    
    async init(gctx: GlobalContext) {
      const config = gctx.config as {
        smtpHost: string;
        smtpPort: number;
        smtpUser: string;
        smtpPass: string;
      };
      
      transporter = nodemailer.createTransport({
        host: config.smtpHost,
        port: config.smtpPort,
        secure: config.smtpPort === 465,
        auth: {
          user: config.smtpUser,
          pass: config.smtpPass,
        },
      });
      
      // Verify connection
      await transporter.verify();
      console.log('Email service initialized');
    },
    
    async shutdown() {
      if (transporter) {
        transporter.close();
        transporter = null;
        console.log('Email service closed');
      }
    },
    
    async healthCheck() {
      if (!transporter) return false;
      try {
        await transporter.verify();
        return true;
      } catch {
        return false;
      }
    },
  };
}

Common Pitfalls

Pitfall 1: Not Handling Init Errors

Bad:

typescript
async init(gctx: GlobalContext) {
  this.connection = await createConnection(config);
  // No error handling - app crashes silently
}

Good:

typescript
async init(gctx: GlobalContext) {
  try {
    this.connection = await createConnection(config);
    console.log('Connected successfully');
  } catch (error) {
    console.error('Failed to initialize:', error);
    throw new ModuleInitializationError('database', error as Error);
  }
}

Pitfall 2: Forgetting to Clean Up

Bad:

typescript
const module: Module<DB> = {
  // ... init creates connection
  // No shutdown function - connection leaks!
};

Good:

typescript
const module: Module<DB> = {
  async init(gctx) {
    this.connection = await createConnection();
  },
  
  async shutdown() {
    await this.connection?.close();
    this.connection = null;
  },
};

Pitfall 3: Circular Dependencies

Bad:

typescript
// Module A depends on B
moduleA.dependencies = ['moduleB'];

// Module B depends on A (circular!)
moduleB.dependencies = ['moduleA'];

Good:

typescript
// Extract shared logic to a third module
moduleA.dependencies = ['shared'];
moduleB.dependencies = ['shared'];

Pitfall 4: Mutating Module State

Bad:

typescript
const handler: Handler = (req, res, gctx) => {
  const module = gctx.modules['myModule'];
  module.counter++; // Shared state - race conditions!
};

Good:

typescript
// Use request-local state
const handler: Handler = (req, res, gctx, lctx) => {
  lctx.state.counter = (lctx.state.counter || 0) + 1;
};

Pitfall 5: Not Declaring Dependencies

Bad:

typescript
const module: Module = {
  name: 'userService',
  // Missing dependencies declaration
  
  exports: {
    getUser: async (id, gctx) => {
      // Implicitly uses 'database' module
      return gctx.modules['database'].query(/* ... */);
    },
  },
};

Good:

typescript
const module: Module = {
  name: 'userService',
  dependencies: ['database'], // Explicit
  
  exports: {
    getUser: async (id, gctx) => {
      return gctx.modules['database'].query(/* ... */);
    },
  },
};

Next: Architecture Documentation

See Also:

Released under the MIT License.