This content originally appeared on DEV Community and was authored by AbianS

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 single 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)),
];
// Add volume if persist_data is 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)));
}
// Environment variables based on database type
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));
}
}
// ... more PostgreSQL-specific configuration
}
"MySQL" => {
args.push("-e".to_string());
args.push(format!("MYSQL_ROOT_PASSWORD={}", request.password));
// ... more MySQL-specific configuration
}
"MongoDB" => {
// ... MongoDB-specific configuration
}
"Redis" => {
// ... Redis-specific configuration
}
// ... more cases for each database
_ => return Err(format!("Database type not supported: {}", request.db_type)),
}
Ok(args)
}
Frontend with Infinite Conditionals
// In container-configuration-step.tsx (561 lines!)
function ContainerConfigurationStep() {
// Different fields based on database type
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 more lines of specific configuration */}
</div>
)
}
if (selectedDatabase === 'MySQL') {
return (
<div>
<Input label="Root Password" type="password" />
<Select label="Character Set">
<option>utf8mb4</option>
<option>utf8</option>
</Select>
{/* ... other specific configuration */}
</div>
)
}
// ... more ifs for MongoDB, Redis, etc.
}
The Real Cost
Adding support for a new database required:
-
Backend (Rust):
- Modify
docker.rs(new match case) - Update types in
models.rs - Add default values in multiple functions
- Update tests
- Modify
-
Frontend (TypeScript):
- Add form fields in
container-configuration-step.tsx - Update types
- Modify validation logic
- Adjust UI conditionals
- Add form fields in
Result: Numerous files modified, significant development time required, increased risk of breaking things.
The Solution: The Provider Pattern
The key idea was simple but powerful: each database should know how to configure itself.
The DatabaseProvider Interface
I created an interface that every database must implement:
export interface DatabaseProvider {
// Basic metadata
getName(): string;
getDisplayName(): string;
getIcon(): string;
getAvailableVersions(): string[];
getDefaultVersion(): string;
// Form rendering
renderConfigurationFields(form: UseFormReturn<any>): React.ReactNode;
// Docker logic
buildDockerArgs(config: DatabaseConfiguration): DockerArgs;
// Validation
validateConfiguration(config: DatabaseConfiguration): ValidationResult;
}
Concrete Implementation: PostgreSQL
export class PostgreSQLDatabaseProvider implements DatabaseProvider {
getName(): string {
return 'postgresql';
}
getDisplayName(): string {
return 'PostgreSQL';
}
getIcon(): string {
return '/postgresql-icon.svg';
}
getAvailableVersions(): string[] {
return ['17', '16', '15', '14', '13'];
}
getDefaultVersion(): string {
return '16';
}
renderConfigurationFields(form: UseFormReturn<any>) {
return (
<>
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" {...field} />
</FormControl>
</FormItem>
)}
/>
{/* ... more PostgreSQL-specific fields */}
</>
);
}
buildDockerArgs(config: DatabaseConfiguration): DockerArgs {
const envVars: Record<string, string> = {
POSTGRES_PASSWORD: config.password,
};
if (config.username && config.username !== 'postgres') {
envVars.POSTGRES_USER = config.username;
}
if (config.database) {
envVars.POSTGRES_DB = config.database;
}
return {
image: `postgres:${config.version}`,
ports: [`${config.port}:5432`],
envVars,
volumes: config.persistData
? [{
name: `${config.name}-data`,
mountPath: '/var/lib/postgresql/data',
}]
: [],
};
}
validateConfiguration(config: DatabaseConfiguration): ValidationResult {
const errors: string[] = [];
if (!config.password || config.password.length < 8) {
errors.push('Password must be at least 8 characters');
}
return {
isValid: errors.length === 0,
errors,
};
}
}
The Database Registry
A simple registry to manage all providers:
class DatabaseRegistry {
private providers = new Map<string, DatabaseProvider>();
register(provider: DatabaseProvider): void {
this.providers.set(provider.getName(), provider);
}
get(name: string): DatabaseProvider | undefined {
return this.providers.get(name);
}
getAll(): DatabaseProvider[] {
return Array.from(this.providers.values());
}
}
// Global instance
export const databaseRegistry = new DatabaseRegistry();
// Register all databases
databaseRegistry.register(new PostgreSQLDatabaseProvider());
databaseRegistry.register(new MySQLDatabaseProvider());
databaseRegistry.register(new MongoDBDatabaseProvider());
databaseRegistry.register(new RedisDatabaseProvider());
Using the Pattern in the UI
The configuration form became incredibly simple:
function ContainerConfigurationStep() {
const form = useForm<DatabaseConfiguration>();
const selectedDatabase = form.watch('databaseType');
// Get the provider for the selected database
const provider = databaseRegistry.get(selectedDatabase);
if (!provider) {
return <div>Select a database</div>;
}
return (
<Form {...form}>
{/* Render provider-specific fields */}
{provider.renderConfigurationFields(form)}
</Form>
);
}
From 561 lines to ~50 lines. The form doesn't need to know anything about databases, just that they have providers.
Backend Transformation
The Rust backend became completely database-agnostic:
// Frontend sends complete Docker arguments
const provider = databaseRegistry.get(config.databaseType);
const dockerArgs = provider.buildDockerArgs(config);
// Send to backend via Tauri
await invoke('create_database', {
request: {
name: config.name,
docker_args: dockerArgs,
},
});
// Backend (Rust) - completely generic
#[tauri::command]
pub async fn create_database(
app: AppHandle,
request: CreateDatabaseRequest,
) -> Result<Database, String> {
let docker_service = DockerService::new();
// Create volumes
for volume in &request.docker_args.volumes {
docker_service.create_volume_if_needed(&app, &volume.name).await?;
}
// Build Docker command (generic for all DBs)
let docker_args = docker_service
.build_docker_command_from_args(&request.name, &request.docker_args);
// Execute
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!
This content originally appeared on DEV Community and was authored by AbianS
AbianS | Sciencx (2025-10-16T15:43:16+00:00) Make Your App Grow Painlessly: How I Escaped Repetitive Code Thanks to the Provider Pattern. Retrieved from https://www.scien.cx/2025/10/16/make-your-app-grow-painlessly-how-i-escaped-repetitive-code-thanks-to-the-provider-pattern/
Please log in to upload a file.
There are no updates yet.
Click the Upload button above to add an update.