Think of it as a
Translator
between your code and your database
The Restaurant Analogy
Imagine you're at a restaurant. You don't walk into the kitchen and grab ingredients yourself โ you tell the waiter what you want, and they handle everything.
Your Code
Asks for what it needs
Repository
Translates & fetches
Database
Stores the data
The Problem It Solves
// SQL scattered everywhere
fn register_user() {
sqlx::query!("INSERT INTO...")
}
fn send_email() {
sqlx::query!("SELECT * FROM...")
} - SQL mixed with business logic
- Hard to test without real DB
- Changing DB = rewrite everything
// Clean, testable code
fn register_user(repo: &dyn UserRepo) {
repo.save(user)
}
fn send_email(repo: &dyn UserRepo) {
repo.find_by_id(id)
} - Business logic stays clean
- Easy to mock for tests
- Swap databases freely
How It Works
Define the interface (trait)
This is the contract. It says what operations are available, not how they work.
pub trait UserRepository {
fn find_by_id(&self, id: Uuid) -> Result<User>;
fn find_by_email(&self, email: &str) -> Result<Option<User>>;
fn save(&self, user: &User) -> Result<()>;
fn delete(&self, id: Uuid) -> Result<()>;
} Implement for your database
The implementation handles the actual database operations. This is where the SQL lives.
pub struct PostgresUserRepo {
pool: PgPool,
}
impl UserRepository for PostgresUserRepo {
fn find_by_id(&self, id: Uuid) -> Result<User> {
sqlx::query_as!(User,
"SELECT * FROM users WHERE id = $1", id
).fetch_one(&self.pool).await
}
} Use in your application
Your business logic only knows about the trait โ it doesn't care which implementation is used.
pub struct UserService<R: UserRepository> {
repo: R,
}
impl<R: UserRepository> UserService<R> {
pub fn register(&self, email: &str) -> Result<User> {
// Check if user exists
if self.repo.find_by_email(email)?.is_some() {
return Err(Error::AlreadyExists);
}
let user = User::new(email);
self.repo.save(&user)?;
Ok(user)
}
} Architecture
Key insight: Dependencies point inward. Your business logic doesn't know about databases.
Why Use It?
Testability
Swap real DB for mock in tests. No containers needed.
Flexibility
Switch from Postgres to MongoDB without touching business logic.
Separation of Concerns
Data access logic lives in one place. Clean boundaries.
Maintainability
Change query logic in one place. No grep-and-replace nightmares.
When to Use It
โ Use when:
- Multiple data sources (DB + API + cache)
- You need unit tests without database containers
- Complex domain logic that shouldn't know about SQL
- Team is large and you want clear boundaries
โ Skip if:
- Simple CRUD app with one database
- Prototyping / hackathon speed matters more
- Your ORM already provides good abstraction
- Solo project where abstraction is overhead
Trade-offs
Pros
- Cleaner architecture
- Easy to test
- Database independence
- Single source of truth for data access
Cons
- More boilerplate code
- Extra abstraction layer
- Can hide ORM capabilities
- Learning curve for juniors
Key Takeaways
It's a translator
Converts your code's language to the database's language and back.
Interface over implementation
Define what you need, implement how you need it later.
Not always necessary
Simple apps might not need it. Use good judgment.
Enables clean testing
The biggest practical benefit for most teams.