DEV Community

Cover image for Haz que tu app crezca sin dolor: Así escapé del código repetido gracias al patrón Provider
AbianS
AbianS

Posted on

Haz que tu app crezca sin dolor: Así escapé del código repetido gracias al patrón Provider

Hace unos dias escribí sobre Docker DB Manager, una aplicación de escritorio que simplifica la gestión de contenedores de bases de datos en Docker.

Pero había un problema: cada vez que añadía soporte para una nueva base de datos, tenía que escribir código duplicado en Rust y TypeScript. Era tedioso, propenso a errores y frustrante.

Hoy te cuento cómo transformé completamente la arquitectura usando el patrón Provider, convirtiendo la adición de nuevas bases de datos en algo tan simple como escribir una clase TypeScript.

¿Qué es el patrón Provider?

El patrón Provider es un patrón de diseño que permite delegar la responsabilidad de proporcionar funcionalidad específica a componentes intercambiables. En lugar de tener lógica centralizada con condicionales para cada caso, cada proveedor (provider) implementa una interfaz común y se encarga de su propia configuración y comportamiento.

La idea central: En vez de preguntar "¿qué tipo eres?" y actuar en consecuencia, le preguntamos al objeto: "¿qué necesitas?" y él nos lo proporciona.

Este patrón es especialmente útil cuando:

  • Tienes múltiples implementaciones de una misma funcionalidad
  • Cada implementación tiene su propia lógica específica
  • Quieres añadir nuevas implementaciones sin modificar código existente
  • Necesitas mantener el código desacoplado y escalable

El problema: código duplicado por todas partes

Backend en Rust con lógica específica

En docker.rs tenía un método gigante con un match statement para cada base de datos:

pub fn build_docker_command(
    &self,
    request: &CreateDatabaseRequest,
    volume_name: &Option<String>,
) -> Result<Vec<String>, String> {
    let mut args = vec![
        "run".to_string(),
        "-d".to_string(),
        "--name".to_string(),
        request.name.clone(),
        "-p".to_string(),
        format!("{}:{}", request.port, self.get_default_port(&request.db_type)),
    ];

    // Añadir volumen si persist_data es true
    if let Some(vol_name) = volume_name {
        args.push("-v".to_string());
        args.push(format!("{}:{}", vol_name, self.get_data_path(&request.db_type)));
    }

    // Variables de entorno según el tipo de base de datos
    match request.db_type.as_str() {
        "PostgreSQL" => {
            args.push("-e".to_string());
            args.push(format!("POSTGRES_PASSWORD={}", request.password));

            if let Some(username) = &request.username {
                if username != "postgres" {
                    args.push("-e".to_string());
                    args.push(format!("POSTGRES_USER={}", username));
                }
            }
            // ... más configuración específica de PostgreSQL
        }
        "MySQL" => {
            args.push("-e".to_string());
            args.push(format!("MYSQL_ROOT_PASSWORD={}", request.password));
            // ... más configuración específica de MySQL
        }
        "MongoDB" => {
            // ... configuración específica de MongoDB
        }
        "Redis" => {
            // ... configuración específica de Redis
        }
        // ... más casos para cada base de datos
        _ => return Err(format!("Database type not supported: {}", request.db_type)),
    }

    Ok(args)
}
Enter fullscreen mode Exit fullscreen mode

Frontend con condicionales infinitos

// En container-configuration-step.tsx (¡561 líneas!)
function ContainerConfigurationStep() {
  // Campos diferentes según el tipo de base de datos
  if (selectedDatabase === 'PostgreSQL') {
    return (
      <div>
        <Input label="Username" defaultValue="postgres" />
        <Input label="Password" type="password" />
        <Select label="Host Auth Method">
          <option>md5</option>
          <option>scram-sha-256</option>
        </Select>
        {/* ... 100 líneas más de configuración específica */}
      </div>
    )
  }

  if (selectedDatabase === 'MySQL') {
    return (
      <div>
        <Input label="Root Password" type="password" />
        <Select label="Character Set">
          <option>utf8mb4</option>
          <option>utf8</option>
        </Select>
        {/* ... otra configuración específica */}
      </div>
    )
  }

  // ... más ifs para MongoDB, Redis, etc.
}
Enter fullscreen mode Exit fullscreen mode

El costo real

Añadir MariaDB significaba tocar un montón de archivos. Cada cambio requería modificar tanto Rust como TypeScript, mantener tipos sincronizados y probar todo manualmente.

La solución: aplicar el patrón Provider

La transformación clave: En lugar de tener lógica centralizada que conoce todas las bases de datos, cada base de datos se convierte en un provider que se describe a sí mismo.

Esto es exactamente el patrón Provider en acción. Veamos cómo:

Paso 1: Definir el contrato con una interfaz

El primer paso es crear una interfaz que todos los providers deben cumplir. Esta interfaz es el contrato que garantiza que cada base de datos puede proporcionar toda la información necesaria:

export interface DatabaseProvider {
  // Identificación
  readonly id: string;
  readonly name: string;
  readonly icon: ReactNode;
  readonly color: string;

  // Configuración Docker
  readonly defaultPort: number;
  readonly dataPath: string;
  readonly versions: string[];

  // Métodos que cada DB debe implementar
  getBasicFields(): FormField[];
  getAuthenticationFields(): FormField[];
  getAdvancedFields(): FieldGroup[];
  buildDockerArgs(config: any): DockerRunArgs;
  getConnectionString(container: Container): string;
  validateConfig(config: any): ValidationResult;
}
Enter fullscreen mode Exit fullscreen mode

Esta interfaz define qué debe proporcionar cada base de datos sin especificar cómo. Esto es la esencia del patrón Provider: separar el "qué" del "cómo".

Paso 2: Implementar providers concretos

Cada base de datos implementa la interfaz DatabaseProvider con su lógica específica. Aquí es donde el patrón Provider brilla: la lógica específica está encapsulada, no dispersa:

MySQL Provider: toda su lógica en un solo archivo

export class MySQLDatabaseProvider implements DatabaseProvider {
  readonly id = 'MySQL';
  readonly name = 'MySQL';
  readonly icon = <SiMysql />;
  readonly defaultPort = 3306;
  readonly dataPath = '/var/lib/mysql';
  readonly versions = ['9.4.0', '9.4', '8.4.6', '8.4', '8.0'];

  getBasicFields(): FormField[] {
    return [
      {
        name: 'name',
        label: 'Container Name',
        type: 'text',
        required: true,
        validation: { min: 3 },
      },
      {
        name: 'port',
        label: 'Port',
        type: 'number',
        defaultValue: 3306,
        required: true,
      },
      {
        name: 'version',
        label: 'MySQL Version',
        type: 'select',
        options: this.versions,
        defaultValue: this.versions[0],
      },
    ];
  }

  getAuthenticationFields(): FormField[] {
    return [
      {
        name: 'username',
        label: 'Root Username',
        type: 'text',
        defaultValue: 'root',
        readonly: true,
      },
      {
        name: 'password',
        label: 'Root Password',
        type: 'password',
        required: true,
        validation: { min: 4 },
      },
    ];
  }

  getAdvancedFields(): FieldGroup[] {
    return [
      {
        label: 'Character Set & Collation',
        fields: [
          {
            name: 'mysqlSettings.characterSet',
            type: 'select',
            options: ['utf8mb4', 'utf8', 'latin1'],
            defaultValue: 'utf8mb4',
          },
        ],
      },
    ];
  }

  buildDockerArgs(config: any): DockerRunArgs {
    const command: string[] = [];

    if (config.mysqlSettings?.characterSet) {
      command.push(`--character-set-server=${config.mysqlSettings.characterSet}`);
    }

    return {
      image: `mysql:${config.version}`,
      envVars: { MYSQL_ROOT_PASSWORD: config.password },
      ports: [{ host: config.port, container: 3306 }],
      volumes: config.persistData 
        ? [{ name: `${config.name}-data`, path: this.dataPath }] 
        : [],
      command,
    };
  }

  getConnectionString(container: Container): string {
    return `mysql://root:${container.password}@localhost:${container.port}`;
  }

  validateConfig(config: any): ValidationResult {
    const errors: string[] = [];
    if (!config.password || config.password.length < 4) {
      errors.push('Password must be at least 4 characters');
    }
    return { valid: errors.length === 0, errors };
  }
}
Enter fullscreen mode Exit fullscreen mode

Toda la lógica de MySQL en ~200 líneas. Sin ifs, sin switches, sin código repetido. Esto es el patrón Provider en acción: cada implementación es autónoma y cumple el contrato definido por la interfaz.

Paso 3: El registro centralizado

Para completar el patrón Provider, necesitamos un registro que gestione todos los providers disponibles. Este registro actúa como punto de acceso único:

class DatabaseRegistry {
  private providers = new Map<string, DatabaseProvider>();

  get(id: string): DatabaseProvider | undefined {
    return this.providers.get(id);
  }

  getAll(): DatabaseProvider[] {
    return Array.from(this.providers.values());
  }
}

export const databaseRegistry = new DatabaseRegistry([
  new PostgresDatabaseProvider(),
  new MySQLDatabaseProvider(),
  new MariaDBDatabaseProvider(),
  new RedisDatabaseProvider(),
  new MongoDBDatabaseProvider(),
  new SQLServerDatabaseProvider(),
  new InfluxDBDatabaseProvider(),
  new ElasticsearchDatabaseProvider(),
]);
Enter fullscreen mode Exit fullscreen mode

Paso 4: Consumir los providers

Con el patrón Provider implementado, el código que consume estos providers se vuelve extremadamente simple y genérico. No necesita conocer nada sobre bases de datos específicas:

Formularios completamente dinámicos

Los formularios pasaron de 561 líneas con condicionales a esto:

export function ContainerConfigurationStep() {
  const provider = databaseRegistry.get(selectedDatabase);
  const basicFields = provider.getBasicFields();
  const authFields = provider.getAuthenticationFields();

  return (
    <div>
      <Section title="Basic Configuration">
        {basicFields.map(field => (
          <DynamicFormField 
            key={field.name} 
            field={field}
            value={formData[field.name]}
            onChange={(value) => updateField(field.name, value)}
          />
        ))}
      </Section>

      <Section title="Authentication">
        {authFields.map(field => (
          <DynamicFormField key={field.name} field={field} />
        ))}
      </Section>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

¿Notas algo? Cero condicionales. Cero lógica específica. El provider proporciona los campos, y el formulario simplemente los renderiza. Esto es el poder del patrón Provider.

Backend Rust simplificado

El backend ya no necesita conocer ninguna base de datos específica. Toda la inteligencia está delegada a los providers en el frontend:

#[tauri::command]
pub async fn create_container_from_docker_args(
    request: DockerRunRequest,
    app: AppHandle,
) -> Result<DatabaseContainer, String> {
    let docker_service = DockerService::new();

    // Crear volúmenes
    for volume in &request.docker_args.volumes {
        docker_service.create_volume_if_needed(&app, &volume.name).await?;
    }

    // Construir comando Docker (genérico para todas las DBs)
    let docker_args = docker_service
        .build_docker_command_from_args(&request.name, &request.docker_args);

    // Ejecutar
    let container_id = docker_service.run_container(&app, &docker_args).await?;

    Ok(database)
}
Enter fullscreen mode Exit fullscreen mode

El método build_docker_command_from_args es completamente genérico. El provider ya construyó todos los argumentos en el frontend.

Los beneficios del patrón Provider

1. Extensibilidad sin modificar código existente

El principio Open/Closed (abierto para extensión, cerrado para modificación) del SOLID se cumple perfectamente. Añadir una nueva base de datos no requiere tocar código existente:

Ejemplo real: Elasticsearch

Para probar el sistema, añadí soporte para Elasticsearch. Todo en un solo commit:

commit 344b475
feat: add Elasticsearch provider and update database registry with tests
Enter fullscreen mode Exit fullscreen mode

Solo tuve que:

  1. Crear elasticsearch.provider.tsx (~200 líneas)
  2. Añadirlo al registro (1 línea)
  3. Escribir tests

Tiempo total: 35 minutos. Sin tocar Rust, sin modificar formularios, sin actualizar tipos.

2. Separación de responsabilidades

Cada provider es responsable de:

  • Su propia configuración
  • Sus propios campos de formulario
  • Su propia lógica Docker
  • Su propia validación

El código consumidor solo necesita saber que existe un provider, no cómo funciona internamente.

3. Testing simplificado

Cada provider tiene sus propios tests, aislados y fáciles de mantener:

describe('MySQLDatabaseProvider', () => {
  const provider = new MySQLDatabaseProvider();

  it('should build correct docker args', () => {
    const config = {
      name: 'test-mysql',
      port: 3306,
      version: '8.0',
      password: 'secret123',
    };

    const args = provider.buildDockerArgs(config);

    expect(args.image).toBe('mysql:8.0');
    expect(args.envVars.MYSQL_ROOT_PASSWORD).toBe('secret123');
  });
});
Enter fullscreen mode Exit fullscreen mode

Conclusión

El patrón Provider transformó completamente la arquitectura de Docker DB Manager. Lo que antes requería cambios en 19 archivos diferentes ahora se resuelve creando un solo archivo que implementa la interfaz.

Este patrón es especialmente poderoso cuando tienes múltiples implementaciones de una misma funcionalidad. Ya sea bases de datos, proveedores de autenticación, procesadores de pagos o cualquier componente intercambiable, el patrón Provider te permite escalar sin acumular deuda técnica.

Si tu proyecto requiere cambios en múltiples lugares para añadir una feature, probablemente necesitas identificar el patrón correcto. No porque el código esté "mal", sino porque una mejor abstracción puede transformar horas de trabajo en minutos.


¿Quieres ver el código o contribuir?

Docker DB Manager es gratuito, open source y actualmente disponible para macOS (Windows y Linux proximamente).

¿Has aplicado el patrón Provider en tus proyectos? ¿Tienes preguntas sobre arquitectura de software? ¡Déjame un comentario!

Top comments (0)