Design Patterns
Learning Corner
Beginner 4 min read

The Repository Pattern

A clean interface between your code and your data

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 "I need user #42" get_user(42) REPOSITORY Translates requests DATABASE SELECT * FROM...
๐Ÿง‘โ€๐Ÿ’ป

Your Code

Asks for what it needs

๐Ÿง‘โ€๐Ÿณ

Repository

Translates & fetches

๐Ÿ—„๏ธ

Database

Stores the data

The Problem It Solves

โœ— Without Repository
// 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
โœ“ With Repository
// 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

1

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<()>;
}
2

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
    }
}
3

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

CLEAN ARCHITECTURE LAYERS HANDLERS HTTP / CLI SERVICES Business Logic REPOSITORY trait Interface Only IMPLEMENTATIONS Postgres MongoDB Mock โ†’ depends on โ†‘ implements

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

1

It's a translator

Converts your code's language to the database's language and back.

2

Interface over implementation

Define what you need, implement how you need it later.

3

Not always necessary

Simple apps might not need it. Use good judgment.

4

Enables clean testing

The biggest practical benefit for most teams.