title: "Database Service Pattern" description: "Design and implementation of the database service pattern with pluggable providers" category: patterns tags:
- patterns
- database
- architecture
- providers related:
- reference/patterns/repository-pattern.md
- reference/api/database-api.md
- examples/database-service-example.md last_updated: March 27, 2025 version: 1.0
Database Service Pattern
Overview
The Database Service Pattern provides a generic abstraction for database operations with pluggable provider implementations. This enables applications to work with different database technologies through a consistent interface while allowing for easy switching between implementations.
Problem Statement
Applications typically need to interact with databases, but direct coupling to specific database technologies creates several challenges:
- Difficult to switch between database providers (e.g., PostgreSQL to MongoDB)
- Testing is complicated by dependencies on actual database instances
- Code becomes tightly coupled to specific database APIs
- Difficult to implement caching or other cross-cutting concerns
- Limited ability to leverage different databases for different use cases
Solution: Database Service Pattern with Pluggable Providers
The Database Service Pattern in Navius uses a provider-based architecture with these components:
- DatabaseOperations Trait: Defines core database operations
- DatabaseProvider Trait: Creates database instances
- DatabaseProviderRegistry: Manages and selects appropriate providers
- DatabaseConfig: Configures database connections and behavior
- DatabaseService: Orchestrates database operations
Pattern Structure
┌────────────────────┐ creates ┌─────────────────────┐
│ DatabaseService │─────────────────│DatabaseProviderRegistry│
└────────┬───────────┘ └─────────┬───────────┘
│ │ selects
│ ▼
│ ┌─────────────────────┐
│ │ DatabaseProvider │
│ └─────────┬───────────┘
│ │ creates
│ ▼
│ uses ┌─────────────────────┐
└─────────────────────────────│ DatabaseOperations │
└─────────────────────┘
Implementation
1. Database Operations Interface
The DatabaseOperations
trait defines the contract for all database implementations:
#![allow(unused)] fn main() { #[async_trait] pub trait DatabaseOperations: Send + Sync { /// Get a value from the database async fn get(&self, collection: &str, key: &str) -> Result<Option<String>, ServiceError>; /// Set a value in the database async fn set(&self, collection: &str, key: &str, value: &str) -> Result<(), ServiceError>; /// Delete a value from the database async fn delete(&self, collection: &str, key: &str) -> Result<bool, ServiceError>; /// Query the database with a filter async fn query(&self, collection: &str, filter: &str) -> Result<Vec<String>, ServiceError>; /// Execute a database transaction with multiple operations async fn transaction<F, T>(&self, operations: F) -> Result<T, ServiceError> where F: FnOnce(&dyn DatabaseOperations) -> Result<T, ServiceError> + Send + 'static, T: Send + 'static; } }
2. Database Provider Interface
The DatabaseProvider
trait enables creating database instances:
#![allow(unused)] fn main() { #[async_trait] pub trait DatabaseProvider: Send + Sync { /// The type of database this provider creates type Database: DatabaseOperations; /// Create a new database instance async fn create_database(&self, config: DatabaseConfig) -> Result<Self::Database, ServiceError>; /// Check if this provider supports the given configuration fn supports(&self, config: &DatabaseConfig) -> bool; /// Get the name of this provider fn name(&self) -> &str; } }
3. Database Service
The DatabaseService
manages database instances and provides access to them:
#![allow(unused)] fn main() { pub struct DatabaseService { provider_registry: Arc<RwLock<DatabaseProviderRegistry>>, default_config: DatabaseConfig, } impl DatabaseService { pub fn new(registry: DatabaseProviderRegistry) -> Self { Self { provider_registry: Arc::new(RwLock::new(registry)), default_config: DatabaseConfig::default(), } } pub fn with_default_config(mut self, config: DatabaseConfig) -> Self { self.default_config = config; self } pub async fn create_database(&self) -> Result<Box<dyn DatabaseOperations>, ServiceError> { // Use registry to create appropriate database instance } } }
4. Provider Registry
The DatabaseProviderRegistry
stores available providers and selects the appropriate one:
#![allow(unused)] fn main() { pub struct DatabaseProviderRegistry { providers: HashMap<String, Box<dyn AnyDatabaseProvider>>, } impl DatabaseProviderRegistry { pub fn new() -> Self { Self { providers: HashMap::new(), } } pub fn register<P: DatabaseProvider + 'static>(&mut self, name: &str, provider: P) { self.providers.insert(name.to_string(), Box::new(provider)); } pub async fn create_database( &self, provider_name: &str, config: DatabaseConfig ) -> Result<Box<dyn DatabaseOperations>, ServiceError> { // Find provider and create database } } }
Benefits
- Abstraction: Decouples application from specific database technologies
- Testability: Simplifies testing with in-memory database implementations
- Flexibility: Easy to switch between database providers
- Consistency: Provides uniform interface for different database technologies
- Extensibility: New database providers can be added without changing client code
- Cross-Cutting Concerns: Enables adding logging, metrics, and caching consistently
Implementation Considerations
1. Transaction Support
Different databases have different transaction models:
- Relational databases have ACID transactions
- Some NoSQL databases have limited transaction support
- In-memory implementations may need to simulate transactions
The pattern should provide a consistent abstraction that works across different implementations:
#![allow(unused)] fn main() { // Example transaction usage db.transaction(|tx| { tx.set("users", "user-1", r#"{"name":"Alice"}"#)?; tx.set("accounts", "account-1", r#"{"owner":"user-1","balance":100}"#)?; Ok(()) }).await?; }
2. Query Language
Database technologies use different query languages (SQL, NoSQL query APIs). The pattern should provide:
- A simple string-based query interface for basic filtering
- Support for native query formats where needed
- Helpers for common query patterns
3. Connection Pooling
Database connections are often expensive resources:
- Implement connection pooling in database providers
- Configure pool sizes and connection timeouts
- Handle connection errors gracefully
4. Error Handling
Database errors should be mapped to application-specific errors:
- Create meaningful error categories (NotFound, Conflict, etc.)
- Include useful context in error messages
- Avoid exposing internal database details in errors
Example Implementations
In-Memory Database
#![allow(unused)] fn main() { pub struct InMemoryDatabase { data: Arc<RwLock<HashMap<String, HashMap<String, String>>>>, } #[async_trait] impl DatabaseOperations for InMemoryDatabase { async fn get(&self, collection: &str, key: &str) -> Result<Option<String>, ServiceError> { let data = self.data.read().await; if let Some(collection_data) = data.get(collection) { return Ok(collection_data.get(key).cloned()); } Ok(None) } async fn set(&self, collection: &str, key: &str, value: &str) -> Result<(), ServiceError> { let mut data = self.data.write().await; let collection_data = data.entry(collection.to_string()).or_insert_with(HashMap::new); collection_data.insert(key.to_string(), value.to_string()); Ok(()) } // Other methods implementation... } }
PostgreSQL Database
#![allow(unused)] fn main() { pub struct PostgresDatabase { pool: PgPool, } #[async_trait] impl DatabaseOperations for PostgresDatabase { async fn get(&self, collection: &str, key: &str) -> Result<Option<String>, ServiceError> { let query = format!( "SELECT data FROM {} WHERE id = $1", sanitize_identifier(collection) ); let result = sqlx::query_scalar(&query) .bind(key) .fetch_optional(&self.pool) .await .map_err(|e| ServiceError::database_error(e.to_string()))?; Ok(result) } // Other methods implementation... } }
API Example
#![allow(unused)] fn main() { // Get the database service let db_service = service_registry.get::<DatabaseService>(); // Create a database instance let db = db_service.create_database().await?; // Store user data let user_data = r#"{"id":"user-123","name":"Alice","role":"admin"}"#; db.set("users", "user-123", user_data).await?; // Retrieve user data if let Some(data) = db.get("users", "user-123").await? { let user: User = serde_json::from_str(&data)?; println!("Found user: {}", user.name); } // Query users by role let admins = db.query("users", "role='admin'").await?; println!("Found {} admin users", admins.len()); // Execute a transaction db.transaction(|tx| { tx.set("users", "user-1", r#"{"name":"Alice"}"#)?; tx.set("accounts", "account-1", r#"{"owner":"user-1","balance":100}"#)?; Ok(()) }).await?; }
Related Patterns
- Repository Pattern: Often used with Database Service Pattern to provide domain-specific data access
- Factory Pattern: Used to create database instances
- Strategy Pattern: Different database providers implement different strategies
- Adapter Pattern: Adapts specific database APIs to the common interface
- Builder Pattern: Used for configuration building