tutoriales.com

Desarrollo Modular Robusto: Tipado de Entornos de Ejecución con TypeScript y Patrones de Inyección de Dependencias

Este tutorial profundiza en cómo tipar entornos de ejecución y aplicar patrones de inyección de dependencias en TypeScript. Aprenderás a construir aplicaciones más robustas, modulares y fáciles de mantener, garantizando la seguridad de tipos en cada capa de tu arquitectura.

Intermedio20 min de lectura11 views
Reportar error

🚀 Introducción al Tipado de Entornos y la Inyección de Dependencias en TypeScript

En el mundo del desarrollo de software moderno, la modularidad y la robustez son claves. A medida que las aplicaciones crecen en complejidad, se vuelve esencial gestionar sus dependencias y configuraciones de una manera estructurada y segura. TypeScript, con su sistema de tipos estáticos, nos ofrece herramientas poderosas para lograr esto.

Este tutorial te guiará a través de la implementación de patrones de Inyección de Dependencias (DI) combinados con un tipado estricto de los entornos de ejecución. Exploraremos cómo asegurar que tu aplicación se comporte correctamente en diferentes contextos (desarrollo, pruebas, producción), y cómo desacoplar tus componentes para facilitar el testing y la escalabilidad.

¿Por qué la Inyección de Dependencias y el Tipado de Entornos son Cruciales? 🤔

  1. Modularidad: Permite construir componentes que no conocen sus dependencias directamente, sino que las reciben externamente. Esto promueve el principio de responsabilidad única.
  2. Testabilidad: Facilita la sustitución de dependencias reales por mocks o stubs durante las pruebas unitarias, lo que hace que los tests sean más rápidos, fiables y aislados.
  3. Flexibilidad: Permite cambiar implementaciones de dependencias sin modificar el código que las usa, lo cual es ideal para diferentes entornos de ejecución o para refactorizaciones.
  4. Seguridad de Tipos: TypeScript asegura que las dependencias inyectadas y los valores de configuración del entorno siempre cumplan con las interfaces esperadas, previniendo errores en tiempo de ejecución.
  5. Mantenibilidad: Reduce el acoplamiento entre módulos, haciendo que el código sea más fácil de entender, mantener y extender.
💡 Consejo: La Inyección de Dependencias no es exclusiva de frameworks o librerías. Puedes implementarla con patrones sencillos en cualquier aplicación TypeScript para mejorar su arquitectura.

🛠️ Configurando el Entorno: Un Caso de Uso Práctico

Imagina que estás desarrollando una aplicación web que necesita conectarse a una base de datos y a un servicio externo de terceros. La URL de la base de datos y las claves de API variarán según si estás en desarrollo, pruebas o producción.

1. Definición del Esquema de Entorno 📜

Primero, definiremos una interfaz que represente la estructura de nuestro entorno. Esto es fundamental para que TypeScript pueda validar los valores que le pasamos.

// src/config/environment.d.ts

/**
 * @interface IEnvironmentConfig
 * Define la estructura de la configuración del entorno para la aplicación.
 */
export interface IEnvironmentConfig {
  env: 'development' | 'production' | 'test';
  databaseUrl: string;
  apiUrl: string;
  apiKey: string;
  debugMode: boolean;
}

/**
 * @interface IServiceDependencies
 * Define las dependencias de los servicios que se inyectarán.
 */
export interface IServiceDependencies {
  dbService: IDatabaseService;
  apiClient: IApiClient;
}

/**
 * @interface IDatabaseService
 * Interfaz para el servicio de base de datos.
 */
export interface IDatabaseService {
  connect(): Promise<void>;
  disconnect(): Promise<void>;
  query<T>(sql: string, params?: any[]): Promise<T[]>;
}

/**
 * @interface IApiClient
 * Interfaz para el cliente de API externo.
 */
export interface IApiClient {
  fetchData<T>(endpoint: string, params?: Record<string, any>): Promise<T>;
  postData<T>(endpoint: string, data: any): Promise<T>;
}

2. Implementaciones del Servicio de Base de Datos y Cliente API 🏗️

Crearemos implementaciones mock (simuladas) y reales para nuestros servicios, demostrando cómo la DI nos permite cambiar entre ellas fácilmente.

Implementación Mock de Base de Datos

// src/services/mockDatabase.service.ts
import { IDatabaseService } from '../config/environment';

export class MockDatabaseService implements IDatabaseService {
  private isConnected = false;

  constructor(private url: string) {
    console.log(`Mock DB Service initialized with URL: ${this.url}`);
  }

  async connect(): Promise<void> {
    console.log(`(Mock DB) Connecting to ${this.url}...`);
    await new Promise(resolve => setTimeout(resolve, 50)); // Simula retardo
    this.isConnected = true;
    console.log(`(Mock DB) Connected to ${this.url}.`);
  }

  async disconnect(): Promise<void> {
    console.log(`(Mock DB) Disconnecting from ${this.url}...`);
    await new Promise(resolve => setTimeout(resolve, 30));
    this.isConnected = false;
    console.log(`(Mock DB) Disconnected.`);
  }

  async query<T>(sql: string, params?: any[]): Promise<T[]> {
    if (!this.isConnected) {
      throw new Error('Mock Database not connected.');
    }
    console.log(`(Mock DB) Executing query: ${sql} with params: ${JSON.stringify(params)}`);
    // Simular datos de respuesta
    return Promise.resolve([ { id: 1, name: 'Mock Item 1' }, { id: 2, name: 'Mock Item 2' } ]) as Promise<T[]>;
  }
}

Implementación Real (o quasi-real) de Base de Datos

// src/services/realDatabase.service.ts
import { IDatabaseService } from '../config/environment';

export class RealDatabaseService implements IDatabaseService {
  private connectionPool: any; // En un caso real, sería un objeto de conexión real

  constructor(private url: string) {
    console.log(`Real DB Service initialized with URL: ${this.url}`);
  }

  async connect(): Promise<void> {
    console.log(`(Real DB) Attempting to connect to ${this.url}...`);
    // Aquí iría la lógica de conexión real a la base de datos (e.g., pg, mysql2)
    this.connectionPool = { /* objeto de pool de conexión */ };
    console.log(`(Real DB) Successfully connected to ${this.url}.`);
  }

  async disconnect(): Promise<void> {
    console.log(`(Real DB) Closing connection to ${this.url}...`);
    // Lógica para cerrar la conexión real
    this.connectionPool = null;
    console.log(`(Real DB) Connection closed.`);
  }

  async query<T>(sql: string, params?: any[]): Promise<T[]> {
    if (!this.connectionPool) {
      throw new Error('Real Database not connected.');
    }
    console.log(`(Real DB) Executing query: ${sql} with params: ${JSON.stringify(params)}`);
    // Aquí iría la ejecución real de la query
    return Promise.resolve([ { id: 101, name: 'Real Data A' }, { id: 102, name: 'Real Data B' } ]) as Promise<T[]>;
  }
}

Lo mismo aplicaríamos para el cliente de API:

// src/services/mockApiClient.service.ts
import { IApiClient } from '../config/environment';

export class MockApiClient implements IApiClient {
  constructor(private baseUrl: string, private apiKey: string) {
    console.log(`Mock API Client initialized for ${this.baseUrl} with API Key: ${apiKey.substring(0, 4)}...`);
  }

  async fetchData<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
    console.log(`(Mock API) Fetching from ${this.baseUrl}${endpoint} with params: ${JSON.stringify(params)}`);
    await new Promise(resolve => setTimeout(resolve, 100));
    return Promise.resolve({ mockData: `Data for ${endpoint}`, timestamp: new Date().toISOString() }) as Promise<T>;
  }

  async postData<T>(endpoint: string, data: any): Promise<T> {
    console.log(`(Mock API) Posting to ${this.baseUrl}${endpoint} with data: ${JSON.stringify(data)}`);
    await new Promise(resolve => setTimeout(resolve, 100));
    return Promise.resolve({ status: 'success', received: data }) as Promise<T>;
  }
}
// src/services/realApiClient.service.ts
import { IApiClient } from '../config/environment';
import axios from 'axios'; // Se asume axios instalado: npm install axios

export class RealApiClient implements IApiClient {
  constructor(private baseUrl: string, private apiKey: string) {
    console.log(`Real API Client initialized for ${this.baseUrl} with API Key: ${apiKey.substring(0, 4)}...`);
  }

  async fetchData<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
    try {
      const response = await axios.get<T>(`${this.baseUrl}${endpoint}`, {
        params,
        headers: { 'X-API-Key': this.apiKey }
      });
      return response.data;
    } catch (error) {
      console.error(`Error fetching from ${endpoint}:`, error);
      throw error;
    }
  }

  async postData<T>(endpoint: string, data: any): Promise<T> {
    try {
      const response = await axios.post<T>(`${this.baseUrl}${endpoint}`, data, {
        headers: { 'X-API-Key': this.apiKey }
      });
      return response.data;
    } catch (error) {
      console.error(`Error posting to ${endpoint}:`, error);
      throw error;
    }
  }
}

🔄 Inyección de Dependencias: El Contenedor

Para gestionar nuestras dependencias y el entorno, construiremos un simple contenedor de DI. Este contenedor será responsable de instanciar y proveer las dependencias necesarias a nuestra aplicación.

3. El Archivo de Configuración de Entorno 🌍

Este archivo será el punto central donde definiremos las configuraciones específicas para cada entorno (desarrollo, producción, testing). Usaremos un sistema para cargar variables de entorno (como dotenv) para mayor flexibilidad.

⚠️ Advertencia: Nunca guardes credenciales sensibles directamente en tu código fuente. Usa variables de entorno (.env) y asegúrate de que el archivo .env NO se suba al control de versiones (añádelo a .gitignore).
// src/config/index.ts

import dotenv from 'dotenv';
import path from 'path';
import { IEnvironmentConfig, IServiceDependencies } from './environment';
import { MockDatabaseService } from '../services/mockDatabase.service';
import { RealDatabaseService } from '../services/realDatabase.service';
import { MockApiClient } from '../services/mockApiClient.service';
import { RealApiClient } from '../services/realApiClient.service';

// Cargar variables de entorno según el modo de ejecución
const envPath = path.resolve(process.cwd(), `.env.${process.env.NODE_ENV || 'development'}`);
dotenv.config({ path: envPath });

/**
 * @function validateEnvironmentConfig
 * Valida que la configuración del entorno cumpla con la interfaz IEnvironmentConfig.
 */
function validateEnvironmentConfig(config: any): IEnvironmentConfig {
  const errors: string[] = [];

  if (!['development', 'production', 'test'].includes(config.ENV)) {
    errors.push('ENV must be one of "development", "production", "test".');
  }
  if (typeof config.DATABASE_URL !== 'string' || config.DATABASE_URL.length === 0) {
    errors.push('DATABASE_URL must be a non-empty string.');
  }
  if (typeof config.API_URL !== 'string' || config.API_URL.length === 0) {
    errors.push('API_URL must be a non-empty string.');
  }
  if (typeof config.API_KEY !== 'string' || config.API_KEY.length === 0) {
    errors.push('API_KEY must be a non-empty string.');
  }
  if (typeof config.DEBUG_MODE === 'undefined') {
    errors.push('DEBUG_MODE must be defined.');
  }

  if (errors.length > 0) {
    throw new Error(`Invalid environment configuration: ${errors.join(', ')}`);
  }

  return {
    env: config.ENV as 'development' | 'production' | 'test',
    databaseUrl: config.DATABASE_URL,
    apiUrl: config.API_URL,
    apiKey: config.API_KEY,
    debugMode: String(config.DEBUG_MODE).toLowerCase() === 'true' // Convertir a booleano
  };
}

// Cargar y validar la configuración del entorno
export const environmentConfig: IEnvironmentConfig = validateEnvironmentConfig(process.env);

/**
 * @function setupDependencies
 * Configura y devuelve las dependencias de servicio basadas en el entorno.
 */
export function setupDependencies(config: IEnvironmentConfig): IServiceDependencies {
  const isTestOrDevelopment = config.env === 'development' || config.env === 'test';

  const dbService = isTestOrDevelopment
    ? new MockDatabaseService(config.databaseUrl)
    : new RealDatabaseService(config.databaseUrl);

  const apiClient = isTestOrDevelopment
    ? new MockApiClient(config.apiUrl, config.apiKey)
    : new RealApiClient(config.apiUrl, config.apiKey);

  return {
    dbService,
    apiClient,
  };
}

// Ejemplo de archivos .env (crea estos archivos en la raíz de tu proyecto)
/*
// .env.development
ENV=development
DATABASE_URL=mongodb://localhost:27017/dev_db
API_URL=https://dev.api.example.com
API_KEY=dev_api_key_12345
DEBUG_MODE=true

// .env.production
ENV=production
DATABASE_URL=mongodb://prod-cluster.example.com/prod_db
API_URL=https://api.example.com
API_KEY=prod_api_key_abcde
DEBUG_MODE=false

// .env.test
ENV=test
DATABASE_URL=inmemory://test_db
API_URL=https://mock.api.example.com
API_KEY=test_api_key_fghij
DEBUG_MODE=true
*/

Explicación del setupDependencies 🧐

La función setupDependencies es el corazón de nuestro contenedor de DI simple. Basándose en el valor de config.env, decide qué implementaciones de los servicios (mocks o reales) debe instanciar y retornar. TypeScript nos asegura que ambas implementaciones (MockDatabaseService y RealDatabaseService) cumplen con la interfaz IDatabaseService, y lo mismo para IApiClient.

📌 Nota: Para este ejemplo, hemos usado `process.env.NODE_ENV` para cargar diferentes archivos `.env`. Puedes configurar esto según tus preferencias o sistema de build.

🧩 El Componente Principal que Usa Dependencias

Ahora, crearemos una clase de aplicación principal que no creará sus propias dependencias, sino que las recibirá a través de su constructor. Este es el patrón de Inyección de Constructor, una de las formas más comunes de DI.

4. La Clase Application 🎯

// src/app.ts

import { IEnvironmentConfig, IServiceDependencies } from './config/environment';

/**
 * @class Application
 * Clase principal de la aplicación que gestiona la lógica de negocio.
 * Sus dependencias y configuración de entorno se inyectan a través del constructor.
 */
export class Application {
  private config: IEnvironmentConfig;
  private dbService: IServiceDependencies['dbService'];
  private apiClient: IServiceDependencies['apiClient'];

  constructor(config: IEnvironmentConfig, dependencies: IServiceDependencies) {
    this.config = config;
    this.dbService = dependencies.dbService;
    this.apiClient = dependencies.apiClient;
    console.log(`Application initialized in ${this.config.env} mode.`);
    if (this.config.debugMode) {
      console.log('Debug mode is enabled.');
    }
  }

  async start(): Promise<void> {
    console.log('Application starting...');
    try {
      await this.dbService.connect();
      const dbData = await this.dbService.query<{id: number, name: string}>('SELECT * FROM users');
      console.log('Data from DB:', dbData);

      const apiData = await this.apiClient.fetchData<{mockData: string}>('/users', { limit: 5 });
      console.log('Data from API:', apiData);

      if (this.config.env === 'production') {
        await this.apiClient.postData('/logs', { message: 'Application started in production' });
      }

    } catch (error) {
      console.error('Error during application start:', error);
      // Dependiendo del entorno, podríamos querer relanzar el error o manejarlo de otra forma
      if (this.config.debugMode) {
        console.error('Detailed error (debug mode):', error);
      }
    } finally {
      await this.dbService.disconnect();
      console.log('Application stopped.');
    }
  }

  // Un método público para testear fácilmente
  public getServiceStatus(): { dbConnected: boolean, apiReady: boolean } {
    // Esto es simplificado, en un caso real se consultarían los servicios
    return { dbConnected: true, apiReady: true };
  }
}

5. El Punto de Entrada de la Aplicación (main) 🚀

Finalmente, ataremos todo junto en nuestro archivo principal index.ts o main.ts.

// src/main.ts

import { Application } from './app';
import { environmentConfig, setupDependencies } from './config';

async function bootstrap() {
  try {
    console.log(`Loading application for environment: ${environmentConfig.env}`);

    // 1. Configurar dependencias basado en el entorno
    const dependencies = setupDependencies(environmentConfig);

    // 2. Crear instancia de la aplicación inyectando la configuración y las dependencias
    const app = new Application(environmentConfig, dependencies);

    // 3. Iniciar la aplicación
    await app.start();

  } catch (error) {
    console.error('Failed to bootstrap application:', error);
    process.exit(1); // Salir con código de error
  }
}

bootstrap();

Para ejecutar este ejemplo, puedes configurar tus scripts de package.json:

{
  "name": "ts-modular-env",
  "version": "1.0.0",
  "description": "",
  "main": "dist/main.js",
  "scripts": {
    "build": "tsc",
    "start:dev": "NODE_ENV=development ts-node src/main.ts",
    "start:prod": "NODE_ENV=production ts-node src/main.ts",
    "start:test": "NODE_ENV=test ts-node src/main.ts"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "@types/node": "^20.12.7",
    "ts-node": "^10.9.2",
    "typescript": "^5.4.5"
  },
  "dependencies": {
    "axios": "^1.6.8",
    "dotenv": "^16.4.5"
  }
}

Instala las dependencias:

npm install

Y luego ejecuta:

npm run start:dev npm run start:prod npm run start:test

Observarás cómo el comportamiento de la aplicación cambia según el entorno, utilizando diferentes implementaciones de servicios y valores de configuración, todo esto validado por TypeScript.


🧪 Testeabilidad con Inyección de Dependencias

Una de las mayores ventajas de la DI es la facilidad con la que podemos probar nuestras clases de forma aislada. Ya que la clase Application recibe sus dependencias, podemos pasarle mocks controlados para nuestros tests.

Ejemplo de Test Unitario con Mocks 📝

Usaremos jest para este ejemplo. Primero, asegúrate de tenerlo instalado:

npm install --save-dev jest ts-jest @types/jest

Configura jest.config.js:

// jest.config.js
module.exports = {
  preset: 'ts-jest',
  testEnvironment: 'node',
  roots: ['<rootDir>/src'],
  testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
  transform: {
    '^.+\.ts$': 'ts-jest',
  },
};

Ahora, un archivo de test:

// src/__tests__/app.test.ts

import { Application } from '../app';
import { IEnvironmentConfig, IDatabaseService, IApiClient } from '../config/environment';

describe('Application', () => {
  let mockDbService: jest.Mocked<IDatabaseService>;
  let mockApiClient: jest.Mocked<IApiClient>;
  let mockEnvironmentConfig: IEnvironmentConfig;

  beforeEach(() => {
    // Resetear mocks antes de cada test
    mockDbService = {
      connect: jest.fn().mockResolvedValue(undefined),
      disconnect: jest.fn().mockResolvedValue(undefined),
      query: jest.fn().mockResolvedValue([{ id: 1, name: 'Test User' }]),
    };
    mockApiClient = {
      fetchData: jest.fn().mockResolvedValue({ someApiData: 'Test API Data' }),
      postData: jest.fn().mockResolvedValue({ status: 'success' }),
    };
    mockEnvironmentConfig = {
      env: 'test',
      databaseUrl: 'mock://test-db',
      apiUrl: 'mock://test-api',
      apiKey: 'test_key',
      debugMode: true,
    };
  });

  it('should initialize correctly with injected dependencies', () => {
    const app = new Application(mockEnvironmentConfig, {
      dbService: mockDbService,
      apiClient: mockApiClient,
    });
    expect(app).toBeInstanceOf(Application);
    // Podríamos añadir más aserciones sobre el estado inicial o logs
  });

  it('should call connect, query, fetchData, and disconnect during start in test mode', async () => {
    const app = new Application(mockEnvironmentConfig, {
      dbService: mockDbService,
      apiClient: mockApiClient,
    });

    await app.start();

    expect(mockDbService.connect).toHaveBeenCalledTimes(1);
    expect(mockDbService.query).toHaveBeenCalledWith('SELECT * FROM users');
    expect(mockApiClient.fetchData).toHaveBeenCalledWith('/users', { limit: 5 });
    expect(mockApiClient.postData).not.toHaveBeenCalled(); // No se llama en modo 'test' para '/logs'
    expect(mockDbService.disconnect).toHaveBeenCalledTimes(1);
  });

  it('should handle errors gracefully during start', async () => {
    mockDbService.connect.mockRejectedValue(new Error('DB Connection Failed'));

    const app = new Application(mockEnvironmentConfig, {
      dbService: mockDbService,
      apiClient: mockApiClient,
    });

    // Capturamos la salida de la consola para verificar los logs de error
    const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});

    await app.start();

    expect(consoleErrorSpy).toHaveBeenCalledWith('Error during application start:', expect.any(Error));
    expect(consoleErrorSpy).toHaveBeenCalledWith('Detailed error (debug mode):', expect.any(Error));
    expect(mockDbService.disconnect).toHaveBeenCalledTimes(1); // Debería intentar desconectar incluso después de un error

    consoleErrorSpy.mockRestore(); // Restaurar la función original
  });

  it('should call postData for logs in production environment', async () => {
    const prodConfig = { ...mockEnvironmentConfig, env: 'production', debugMode: false };
    const app = new Application(prodConfig, {
      dbService: mockDbService,
      apiClient: mockApiClient,
    });

    await app.start();

    expect(mockApiClient.postData).toHaveBeenCalledTimes(1);
    expect(mockApiClient.postData).toHaveBeenCalledWith('/logs', { message: 'Application started in production' });
  });
});
🔥 Importante: La capacidad de inyectar dependencias y de usar interfaces para definir contratos de servicio es lo que hace posible escribir tests unitarios de forma efectiva y desacoplada.

📊 Resumen de Beneficios y Patrones Clave

Este tutorial ha cubierto una aproximación práctica para combinar el tipado de entornos de ejecución con la Inyección de Dependencias en TypeScript. Aquí un resumen de los patrones y beneficios clave:

  • Tipado de Configuración (Interface IEnvironmentConfig): Garantiza que la estructura de tu configuración sea consistente y válida en toda la aplicación. TypeScript te alertará si intentas acceder a una propiedad inexistente o con un tipo incorrecto.
  • Interfaces de Servicio (IDatabaseService, IApiClient): Definen contratos claros para tus servicios, permitiendo que diferentes implementaciones (real, mock) cumplan con el mismo contrato.
  • Contenedor de DI Simple (setupDependencies): Centraliza la lógica de creación e instanciación de dependencias. Facilita el cambio de implementaciones de servicio según el entorno o las necesidades de testeo.
  • Inyección de Constructor (Application class): Las dependencias se pasan al constructor de una clase, en lugar de ser creadas internamente. Esto reduce el acoplamiento y mejora la testabilidad.
  • Variables de Entorno (.env files): Mecanismo estándar para configurar aplicaciones en diferentes entornos sin modificar el código fuente.
Paso 1: Definir Interfaces de Entorno y Servicio.
Paso 2: Crear Implementaciones (Reales y Mock) de los Servicios.
Paso 3: Implementar un Contenedor de DI para Resolver Dependencias según el Entorno.
Paso 4: Crear Clases que Reciban Dependencias por Inyección de Constructor.
Paso 5: Configurar el Punto de Entrada de la Aplicación para Orquestar.
Ampliando el Concepto de Contenedores de DI Para aplicaciones más grandes, podrías considerar librerías de Inyección de Dependencias más robustas, como `InversifyJS` o `TypeDI`. Estas librerías ofrecen características avanzadas como `@injectable()` decorators, binding de interfaces a implementaciones, scopes de vida de instancias (singleton, transient), y más. Sin embargo, para muchas aplicaciones, un contenedor de DI manual como el que hemos implementado es perfectamente adecuado y más ligero.
main.ts environmentConfig setupDependencies Mock Services Real Services Application

Al aplicar estos principios, construirás aplicaciones TypeScript que no solo son correctas en tiempo de compilación gracias al tipado, sino también arquitectónicamente sólidas, fáciles de escalar y de mantener a lo largo del tiempo. La Inyección de Dependencias y el tipado de entornos son herramientas poderosas en tu arsenal para el desarrollo de software de calidad.

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!