Repository Architecture
Ironclad uses a trait-based repository pattern:- Trait definition (
src/interfaces/repositories/) - Abstract data access contract - Implementation (
src/infrastructure/persistence/) - Concrete database implementation - Dependency injection - Services depend on traits, not implementations
Repositories have no knowledge of business rules. They receive validated entities and persist them.
Repository Trait
Define the data access contract using traits:src/interfaces/repositories/user_repository.rs
use crate::domain::entities::User;
use crate::errors::ApiError;
use async_trait::async_trait;
/// User Repository - Data access contract
#[async_trait]
pub trait UserRepository: Send + Sync {
/// Create new user
async fn create(&self, user: &User) -> Result<User, ApiError>;
/// Get user by ID
async fn get_by_id(&self, id: &str) -> Result<Option<User>, ApiError>;
/// Get user by email
async fn get_by_email(&self, email: &str) -> Result<Option<User>, ApiError>;
/// Get all users
async fn get_all(&self) -> Result<Vec<User>, ApiError>;
/// Get users with pagination
async fn get_paginated(&self, page: i32, per_page: i32) -> Result<(Vec<User>, i32), ApiError>;
/// Update user
async fn update(&self, user: &User) -> Result<(), ApiError>;
/// Delete user by ID
async fn delete(&self, id: &str) -> Result<bool, ApiError>;
/// Count total users
async fn count(&self) -> Result<i32, ApiError>;
/// Check if user exists with this email
async fn exists_by_email(&self, email: &str) -> Result<bool, ApiError>;
}
Note the trait methods:
- Accept entities as parameters (already validated)
- Accept primitives for queries (IDs, emails)
- Return entities or primitive types
- Use
async_traitfor async methods
Repository Implementation
Implement the trait for your database (PostgreSQL example):src/infrastructure/persistence/postgres/user_repository.rs
use sqlx::PgPool;
use async_trait::async_trait;
use chrono::Utc;
use crate::domain::entities::User;
use crate::errors::ApiError;
use crate::interfaces::repositories::UserRepository;
pub struct PostgresUserRepository {
pool: PgPool,
}
impl PostgresUserRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl UserRepository for PostgresUserRepository {
async fn create(&self, user: &User) -> Result<User, ApiError> {
let query = r#"
INSERT INTO users (id, email, username, password_hash, role, is_active, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
"#;
let created_user = sqlx::query_as::<_, User>(query)
.bind(&user.id)
.bind(&user.email.as_str()) // Extract primitive from Value Object
.bind(&user.username.as_str())
.bind(&user.password_hash)
.bind(user.role.as_str())
.bind(user.is_active)
.bind(user.created_at)
.bind(user.updated_at)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(created_user)
}
async fn get_by_id(&self, id: &str) -> Result<Option<User>, ApiError> {
let query = "SELECT * FROM users WHERE id = $1";
let user = sqlx::query_as::<_, User>(query)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(user)
}
async fn get_by_email(&self, email: &str) -> Result<Option<User>, ApiError> {
let query = "SELECT * FROM users WHERE email = $1";
let user = sqlx::query_as::<_, User>(query)
.bind(email)
.fetch_optional(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(user)
}
async fn get_paginated(&self, page: i32, per_page: i32) -> Result<(Vec<User>, i32), ApiError> {
let offset = (page - 1) * per_page;
let query = r#"
SELECT * FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#;
let users = sqlx::query_as::<_, User>(query)
.bind(per_page)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
let total = self.count().await?;
Ok((users, total))
}
async fn update(&self, user: &User) -> Result<(), ApiError> {
let query = r#"
UPDATE users
SET email = $1, username = $2, password_hash = $3, role = $4,
is_active = $5, updated_at = $6
WHERE id = $7
"#;
sqlx::query(query)
.bind(&user.email.as_str())
.bind(&user.username.as_str())
.bind(&user.password_hash)
.bind(user.role.as_str())
.bind(user.is_active)
.bind(Utc::now())
.bind(&user.id)
.execute(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<bool, ApiError> {
let query = "DELETE FROM users WHERE id = $1";
let result = sqlx::query(query)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
async fn count(&self) -> Result<i32, ApiError> {
let query = "SELECT COUNT(*) as count FROM users";
let row: (i64,) = sqlx::query_as(query)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(row.0 as i32)
}
async fn exists_by_email(&self, email: &str) -> Result<bool, ApiError> {
let query = "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)";
let row: (bool,) = sqlx::query_as(query)
.bind(email)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(row.0)
}
}
Key Implementation Patterns
Extracting Primitives from Value Objects
Repositories decompose entities to extract primitive values:.bind(&user.email.as_str()) // EmailAddress -> &str
.bind(&user.username.as_str()) // Username -> &str
.bind(user.role.as_str()) // Role -> &str
Repositories must extract primitives for database binding. Never persist Value Objects directly.
Hydrating Entities from Database
SQLx automatically maps rows to entities viaFromRow:
let user = sqlx::query_as::<_, User>(query)
.bind(id)
.fetch_optional(&self.pool)
.await?
FromRow implementation uses from_trusted() for Value Objects:
impl sqlx::FromRow<'_, PgRow> for User {
fn from_row(row: &PgRow) -> Result<Self, sqlx::Error> {
Ok(User {
id: row.try_get("id")?,
email: EmailAddress::from_trusted(row.try_get("email")?),
username: Username::from_trusted(row.try_get("username")?),
// ...
})
}
}
Simpler Repository Example
src/interfaces/repositories/test_item_repository.rs
use crate::domain::entities::TestItem;
use crate::errors::ApiError;
use async_trait::async_trait;
#[async_trait]
pub trait TestItemRepository: Send + Sync {
async fn create(&self, item: &TestItem) -> Result<TestItem, ApiError>;
async fn get_by_id(&self, id: &str) -> Result<Option<TestItem>, ApiError>;
async fn get_all(&self) -> Result<Vec<TestItem>, ApiError>;
async fn get_paginated(&self, page: i32, per_page: i32) -> Result<(Vec<TestItem>, i32), ApiError>;
async fn update(&self, item: &TestItem) -> Result<(), ApiError>;
async fn delete(&self, id: &str) -> Result<bool, ApiError>;
async fn count(&self) -> Result<i32, ApiError>;
}
src/infrastructure/persistence/postgres/test_item_repository.rs
use sqlx::PgPool;
use async_trait::async_trait;
use chrono::Utc;
use crate::domain::entities::TestItem;
use crate::errors::ApiError;
use crate::interfaces::repositories::TestItemRepository;
pub struct PostgresTestItemRepository {
pool: PgPool,
}
impl PostgresTestItemRepository {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}
#[async_trait]
impl TestItemRepository for PostgresTestItemRepository {
async fn create(&self, item: &TestItem) -> Result<TestItem, ApiError> {
let query = r#"
INSERT INTO test_items (id, subject, optional_field, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5)
RETURNING *
"#;
let created_item = sqlx::query_as::<_, TestItem>(query)
.bind(&item.id)
.bind(&item.subject)
.bind(&item.optional_field)
.bind(item.created_at)
.bind(item.updated_at)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(created_item)
}
async fn get_by_id(&self, id: &str) -> Result<Option<TestItem>, ApiError> {
let query = "SELECT * FROM test_items WHERE id = $1";
let item = sqlx::query_as::<_, TestItem>(query)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(item)
}
async fn get_paginated(&self, page: i32, per_page: i32) -> Result<(Vec<TestItem>, i32), ApiError> {
let offset = (page - 1) * per_page;
let query = r#"
SELECT * FROM test_items
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#;
let items = sqlx::query_as::<_, TestItem>(query)
.bind(per_page)
.bind(offset)
.fetch_all(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
let total = self.count().await?;
Ok((items, total))
}
async fn update(&self, item: &TestItem) -> Result<(), ApiError> {
let query = r#"
UPDATE test_items
SET subject = $1, optional_field = $2, updated_at = $3
WHERE id = $4
"#;
sqlx::query(query)
.bind(&item.subject)
.bind(&item.optional_field)
.bind(Utc::now())
.bind(&item.id)
.execute(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(())
}
async fn delete(&self, id: &str) -> Result<bool, ApiError> {
let query = "DELETE FROM test_items WHERE id = $1";
let result = sqlx::query(query)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
async fn count(&self) -> Result<i32, ApiError> {
let query = "SELECT COUNT(*) as count FROM test_items";
let row: (i64,) = sqlx::query_as(query)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(row.0 as i32)
}
}
Common Repository Patterns
Pagination
pub async fn get_paginated(
&self,
page: i32,
per_page: i32
) -> Result<(Vec<User>, i32), ApiError> {
let offset = (page - 1) * per_page;
let query = r#"
SELECT * FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#;
let users = sqlx::query_as::<_, User>(query)
.bind(per_page)
.bind(offset)
.fetch_all(&self.pool)
.await?;
let total = self.count().await?;
Ok((users, total))
}
Existence Checks
pub async fn exists_by_email(&self, email: &str) -> Result<bool, ApiError> {
let query = "SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)";
let row: (bool,) = sqlx::query_as(query)
.bind(email)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(row.0)
}
Counting Records
pub async fn count(&self) -> Result<i32, ApiError> {
let query = "SELECT COUNT(*) as count FROM users";
let row: (i64,) = sqlx::query_as(query)
.fetch_one(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(row.0 as i32)
}
Deletion with Confirmation
pub async fn delete(&self, id: &str) -> Result<bool, ApiError> {
let query = "DELETE FROM users WHERE id = $1";
let result = sqlx::query(query)
.bind(id)
.execute(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Ok(result.rows_affected() > 0)
}
Dependency Injection
Services depend on the repository trait, not the implementation:pub struct AuthService {
user_repository: Arc<dyn UserRepository>, // Trait, not PostgresUserRepository
config: Arc<AppConfig>,
}
impl AuthService {
pub fn new(user_repository: Arc<dyn UserRepository>, config: Arc<AppConfig>) -> Self {
Self {
user_repository,
config,
}
}
}
This allows you to:
- Swap implementations (PostgreSQL → MongoDB)
- Mock repositories for testing
- Change persistence without touching business logic
Error Handling
Convert database errors to application errors:let user = sqlx::query_as::<_, User>(query)
.bind(id)
.fetch_optional(&self.pool)
.await
.map_err(|e| ApiError::DatabaseError(e.to_string()))?;
Repository Responsibilities
Repositories SHOULD:
- Execute SQL queries
- Map database rows to entities
- Extract primitives from Value Objects for queries
- Handle database-specific errors
- Implement query optimization
- Validate business rules
- Contain business logic
- Know about HTTP, controllers, or DTOs
- Perform complex calculations
Testing Repositories
Test repositories using a test database or mocks:#[cfg(test)]
mod tests {
use super::*;
use sqlx::PgPool;
#[sqlx::test]
async fn test_create_user(pool: PgPool) {
let repo = PostgresUserRepository::new(pool);
let email = EmailAddress::new("[email protected]".to_string()).unwrap();
let username = Username::new("testuser".to_string()).unwrap();
let user = User::new(email, username, "hash".to_string()).unwrap();
let result = repo.create(&user).await;
assert!(result.is_ok());
}
}
Best Practices
- Use parameterized queries - Prevent SQL injection
- Handle Optional results - Use
fetch_optionalfor single records - Return tuple for pagination -
(Vec<T>, i32)for items and total - Use transactions for multi-step operations - Ensure atomicity
- Keep queries simple - Complex logic belongs in services
Next Steps
- Learn about Services that use repositories
- Understand Entities that repositories persist
- Explore database configuration