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)
}
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.
}
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;
}
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 };
}
}
Toda la lógica de MySQL en ~200 líneas. Sin if
s, sin switch
es, 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(),
]);
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>
);
}
¿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)
}
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
Solo tuve que:
- Crear
elasticsearch.provider.tsx
(~200 líneas) - Añadirlo al registro (1 línea)
- 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');
});
});
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?
- 📦 Repositorio: github.com/AbianS/docker-db-manager
- 🔍 pr del refactor: PR
- ⭐ Dale una estrella si te resulta útil
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)