Repository Pattern
Version: 0.1.0 Date: October 08, 2025 SPDX-License-Identifier: BSD-3-Clause License File: See the LICENSE file in the project root. Copyright: © 2025 Michael Gardner, A Bit of Help, Inc. Authors: Michael Gardner Status: Draft
The Repository pattern for data persistence.
Pattern Overview
The Repository pattern provides an abstraction layer between the domain and data mapping layers. It acts like an in-memory collection of domain objects, hiding the complexities of database operations.
Key Idea: Your business logic shouldn't know whether data comes from SQLite, PostgreSQL, or a file. It just uses a Repository
trait.
Architecture
Components
Repository Trait (Domain Layer)
#![allow(unused)] fn main() { trait PipelineRepository { fn create(&self, pipeline: &Pipeline) -> Result<()>; fn find_by_id(&self, id: &PipelineId) -> Result<Option<Pipeline>>; fn update(&self, pipeline: &Pipeline) -> Result<()>; fn delete(&self, id: &PipelineId) -> Result<()>; } }
Repository Adapter (Infrastructure Layer)
#![allow(unused)] fn main() { struct PipelineRepositoryAdapter { repository: SQLitePipelineRepository, } impl PipelineRepository for PipelineRepositoryAdapter { // Implements trait methods } }
Concrete Repository (Infrastructure Layer)
#![allow(unused)] fn main() { struct SQLitePipelineRepository { pool: SqlitePool, mapper: PipelineMapper, } }
Layer Responsibilities
Domain Layer
Defines what operations are needed:
#![allow(unused)] fn main() { // Domain defines the interface pub trait PipelineRepository: Send + Sync { async fn create(&self, pipeline: &Pipeline) -> Result<()>; async fn find_by_id(&self, id: &PipelineId) -> Result<Option<Pipeline>>; // ... more methods } }
Domain knows:
- What operations it needs
- What domain entities look like
- Business rules and validations
Domain doesn't know:
- SQL syntax
- Database technology
- Connection pooling
Infrastructure Layer
Implements how to persist data:
#![allow(unused)] fn main() { impl PipelineRepository for PipelineRepositoryAdapter { async fn create(&self, pipeline: &Pipeline) -> Result<()> { // Convert domain entity to database row let row = self.mapper.to_persistence(pipeline); // Execute SQL sqlx::query("INSERT INTO pipelines ...") .execute(&self.pool) .await?; Ok(()) } } }
Infrastructure knows:
- SQL syntax and queries
- Database schema
- Connection management
- Error handling
Data Mapping
The Mapper separates domain models from database schema:
#![allow(unused)] fn main() { struct PipelineMapper; impl PipelineMapper { // Domain → Database fn to_persistence(&self, pipeline: &Pipeline) -> PipelineRow { PipelineRow { id: pipeline.id().to_string(), input_path: pipeline.input_path().to_string(), // ... map all fields } } // Database → Domain fn to_domain(&self, row: SqliteRow) -> Result<Pipeline> { Pipeline::new( PipelineId::from_string(&row.id)?, FilePath::new(&row.input_path)?, FilePath::new(&row.output_path)?, ) } } }
Why mapping?
- Domain entities stay pure (no database annotations)
- Database schema can change independently
- Different databases can use different schemas
- Validation happens in domain layer
Benefits
1. Testability
Business logic can be tested without a database:
#![allow(unused)] fn main() { #[cfg(test)] mod tests { use mockall::mock; mock! { PipelineRepo {} impl PipelineRepository for PipelineRepo { async fn create(&self, pipeline: &Pipeline) -> Result<()>; // ... mock other methods } } #[tokio::test] async fn test_pipeline_service() { let mut mock_repo = MockPipelineRepo::new(); mock_repo.expect_create() .returning(|_| Ok(())); let service = PipelineService::new(Arc::new(mock_repo)); // Test business logic without database } } }
2. Flexibility
Swap implementations without changing business logic:
#![allow(unused)] fn main() { // Start with SQLite let repo = SQLitePipelineRepositoryAdapter::new(pool); let service = PipelineService::new(Arc::new(repo)); // Later, switch to PostgreSQL let repo = PostgresPipelineRepositoryAdapter::new(pool); let service = PipelineService::new(Arc::new(repo)); // Business logic unchanged! }
3. Centralized Data Access
All database queries in one place:
- Easier to optimize
- Easier to audit
- Easier to cache
- Easier to add logging
4. Domain Purity
Domain layer stays technology-agnostic:
#![allow(unused)] fn main() { // Domain doesn't import sqlx, postgres, etc. // Only depends on standard Rust types pub struct Pipeline { id: PipelineId, // Not i64 or UUID from database input_path: FilePath, // Not String from database status: PipelineStatus, // Not database enum } }
Usage Example
Application Layer
#![allow(unused)] fn main() { pub struct PipelineService { repository: Arc<dyn PipelineRepository>, } impl PipelineService { pub async fn create_pipeline( &self, input: FilePath, output: FilePath, ) -> Result<Pipeline> { // Create domain entity let pipeline = Pipeline::new( PipelineId::new(), input, output, )?; // Persist using repository self.repository.create(&pipeline).await?; Ok(pipeline) } pub async fn get_pipeline( &self, id: PipelineId, ) -> Result<Option<Pipeline>> { self.repository.find_by_id(&id).await } } }
The service doesn't know or care:
- Which database is used
- How data is stored
- What the SQL looks like
It just uses the Repository
trait!
Implementation in Pipeline
Our pipeline uses this pattern for:
PipelineRepository - Stores pipeline metadata
pipeline/domain/src/repositories/pipeline_repository.rs
(trait)pipeline/src/infrastructure/repositories/sqlite_pipeline_repository.rs
(impl)
FileChunkRepository - Stores chunk metadata
pipeline/domain/src/repositories/file_chunk_repository.rs
(trait)pipeline/src/infrastructure/repositories/sqlite_file_chunk_repository.rs
(impl)
Next Steps
Continue to:
- Service Pattern - Business logic organization
- Adapter Pattern - Infrastructure integration
- Implementation: Repositories - Concrete implementations