A few days ago I wrote about Docker DB Manager, a desktop application that simplifies managing database containers in Docker.
But there was a problem: every time I added support for a new database, I had to write duplicate code in both Rust and TypeScript. It was tedious, error-prone, and frustrating.
Today I'll tell you how I completely transformed the architecture using the Provider pattern, turning the addition of new databases into something as simple as writing a TypeScript class.
What is the Provider Pattern?
The Provider pattern is a design pattern that allows you to delegate the responsibility of providing specific functionality to interchangeable components. Instead of having centralized logic with conditionals for each case, each provider implements a common interface and handles its own configuration and behavior.
The core idea: Instead of asking "what type are you?" and acting accordingly, we ask the object: "what do you need?" and it provides it.
This pattern is especially useful when:
- You have multiple implementations of the same functionality
- Each implementation has its own specific logic
- You want to add new implementations without modifying existing code
- You need to keep the code decoupled and scalable
The Problem: Duplicate Code Everywhere
Backend in Rust with Specific Logic
In docker.rs
I had a giant method with a match
statement for each database:
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 with Infinite Conditionals
// 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.
}
The Real Cost
Adding MariaDB meant touching 19 different files and about 3-4 hours of work. Each change required modifying both Rust and TypeScript, keeping types in sync, and testing everything manually.
The Solution: Apply the Provider Pattern
The key transformation: Instead of having centralized logic that knows about all databases, each database becomes a provider that describes itself.
This is exactly the Provider pattern in action. Let's see how:
Step 1: Define the Contract with an Interface
The first step is to create an interface that all providers must comply with. This interface is the contract that guarantees each database can provide all the necessary information:
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;
}
This interface defines what each database should provide without specifying how. This is the essence of the Provider pattern: separating the "what" from the "how".
Step 2: Implement Concrete Providers
Each database implements the DatabaseProvider
interface with its specific logic. This is where the Provider pattern shines: the specific logic is encapsulated, not scattered:
MySQL Provider: all its logic in a single file
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 };
}
}
All MySQL logic in ~200 lines. No if
s, no switch
es, no repeated code. This is the Provider pattern in action: each implementation is autonomous and fulfills the contract defined by the interface.
Step 3: The Centralized Registry
To complete the Provider pattern, we need a registry that manages all available providers. This registry acts as a single point of access:
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(),
]);
Step 4: Consume the Providers
With the Provider pattern implemented, the code that consumes these providers becomes extremely simple and generic. It doesn't need to know anything about specific databases:
Completely Dynamic Forms
The forms went from 561 lines with conditionals to this:
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>
);
}
Notice something? Zero conditionals. Zero specific logic. The provider provides the fields, and the form simply renders them. This is the power of the Provider pattern.
Simplified Rust Backend
The backend no longer needs to know about any specific database. All the intelligence is delegated to the providers in the 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)
}
The build_docker_command_from_args
method is completely generic. The provider already built all the arguments in the frontend.
Benefits of the Provider Pattern
1. Extensibility Without Modifying Existing Code
The Open/Closed principle (open for extension, closed for modification) of SOLID is perfectly fulfilled. Adding a new database doesn't require touching existing code:
2. Separation of Concerns
Each provider is responsible for:
- Its own configuration
- Its own form fields
- Its own Docker logic
- Its own validation
The consuming code only needs to know that a provider exists, not how it works internally.
3. Simplified Testing
Each provider has its own tests, isolated and easy to maintain:
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');
});
});
Conclusion
The Provider pattern completely transformed Docker DB Manager's architecture. What previously required changes in 19 different files is now solved by creating a single file that implements the interface.
This pattern is especially powerful when you have multiple implementations of the same functionality. Whether it's databases, authentication providers, payment processors, or any interchangeable component, the Provider pattern allows you to scale without accumulating technical debt.
If your project requires changes in multiple places to add a feature, you probably need to identify the right pattern. Not because the code is "bad," but because a better abstraction can transform hours of work into minutes.
Want to see the code or contribute?
- 📦 Repository: github.com/AbianS/docker-db-manager
- 🔍 Refactor Pr: PR
- ⭐ Give it a star if you find it useful
Docker DB Manager is free, open source, and currently available for macOS (Windows and Linux in development).
Have you applied the Provider pattern in your projects? Have questions about software architecture? Leave me a comment!
Top comments (0)