Skip to main content
Services contain your application’s business logic. They orchestrate operations between controllers, repositories, and domain entities, enforcing business rules and managing workflows.

Service Architecture

Services sit in the Application Layer and coordinate:
  1. DTO validation - Transform and validate request data
  2. Business rules - Enforce domain-specific logic
  3. Value Object creation - Convert primitives to domain types
  4. Entity operations - Create, update, and manage entities
  5. Repository access - Persist and retrieve data
  6. External integrations - Call third-party services
Services handle contextual business rules that require I/O or external state. Immutable domain rules belong in Value Objects.

Basic Service Structure

src/application/services/test_item_service.rs
use std::sync::Arc;
use crate::application::dtos::{
    CreateTestItemRequest,
    UpdateTestItemRequest,
    TestItemResponse,
    PaginatedTestItemsResponse,
};
use crate::domain::entities::TestItem;
use crate::errors::ApiError;
use crate::interfaces::TestItemRepository;

pub struct TestItemService {
    repository: Arc<dyn TestItemRepository>,
}

impl TestItemService {
    pub fn new(repository: Arc<dyn TestItemRepository>) -> Self {
        Self { repository }
    }

    pub async fn create(&self, request: CreateTestItemRequest) -> Result<TestItemResponse, ApiError> {
        let item = TestItem::new(request.subject, request.optional_field);
        let created_item = self.repository.create(&item).await?;
        Ok(created_item.to_response())
    }

    pub async fn get_by_id(&self, id: &str) -> Result<Option<TestItemResponse>, ApiError> {
        let item = self.repository.get_by_id(id).await?;
        Ok(item.map(|i| i.to_response()))
    }
}

Service with Business Rules

The AuthService demonstrates complex business logic:
src/application/services/auth_service.rs
use std::sync::Arc;
use crate::application::dtos::{AuthResponse, LoginRequest, RegisterUserRequest};
use crate::config::AppConfig;
use crate::domain::entities::User;
use crate::domain::value_objects::{EmailAddress, Username};
use crate::errors::ApiError;
use crate::interfaces::UserRepository;
use crate::utils::auth::hash_password;
use crate::utils::jwt::create_token;
use crate::shared::validator::validate_strong_password;

pub struct AuthService {
    user_repository: Arc<dyn UserRepository>,
    config: Arc<AppConfig>,
}

impl AuthService {
    pub fn new(user_repository: Arc<dyn UserRepository>, config: Arc<AppConfig>) -> Self {
        Self {
            user_repository,
            config,
        }
    }

    pub async fn register(&self, request: RegisterUserRequest) -> Result<AuthResponse, ApiError> {
        // 1. Validate password strength (Application Layer rule)
        if validate_strong_password(&request.password).is_err() {
            return Err(ApiError::ValidationError(
                "Password does not meet security requirements".to_string()
            ));
        }

        // 2. Check uniqueness (requires I/O)
        if self.user_repository.exists_by_email(&request.email).await? {
            return Err(ApiError::Conflict("User already exists".to_string()));
        }
        
        // 3. Create Value Objects (enforces domain rules)
        let email_vo = EmailAddress::new(request.email)?;
        let username_vo = Username::new(request.username)?;
        
        let password_hash = hash_password(&request.password, &self.config)?;
        
        // 4. Create entity with validated Value Objects
        let user = User::new(email_vo, username_vo, password_hash)?;
        
        // 5. Persist
        let created_user = self.user_repository.create(&user).await?;
        
        // 6. Generate JWT token
        let token = create_token(
            &created_user.id,
            created_user.email.as_str(),
            &created_user.role.to_string(),
            &self.config,
        )?;
        
        Ok(AuthResponse {
            user: created_user.to_response(),
            token,
        })
    }
}

Three-Layer Validation

Ironclad follows Domain-Driven Design principles with strict separation:
1
HTTP Layer (DTOs)
2
Pure syntactic validation:
3
  • “String must not be empty”
  • “Maximum length of 50 characters”
  • “Must be valid email format”
  • 4
    #[derive(Validate)]
    pub struct RegisterUserRequest {
        #[validate(email(message = "Invalid email format"))]
        pub email: String,
    }
    
    5
    Application Layer (Services)
    6
    Contextual business rules requiring I/O:
    7
  • “Email already exists in database”
  • “Password must meet strength requirements”
  • “User cannot exceed resource quota”
  • 8
    if self.user_repository.exists_by_email(&request.email).await? {
        return Err(ApiError::Conflict("User already exists".to_string()));
    }
    
    9
    Domain Layer (Value Objects)
    10
    Immutable business rules and state integrity:
    11
  • “Username only accepts alphanumeric characters”
  • “Email must contain @”
  • Self-validate upon instantiation
  • 12
    impl EmailAddress {
        pub fn new(value: String) -> Result<Self, DomainError> {
            if value.trim().is_empty() || !value.contains('@') {
                return Err(DomainError::Validation("Invalid email format".into()));
            }
            Ok(Self(value))
        }
    }
    

    CRUD Operations

    Create

    src/application/services/test_item_service.rs
    pub async fn create(&self, request: CreateTestItemRequest) -> Result<TestItemResponse, ApiError> {
        let item = TestItem::new(request.subject, request.optional_field);
        let created_item = self.repository.create(&item).await?;
        Ok(created_item.to_response())
    }
    

    Read with Pagination

    src/application/services/test_item_service.rs
    pub async fn get_all(&self, page: i32, per_page: i32) -> Result<PaginatedTestItemsResponse, ApiError> {
        if page < 1 || per_page < 1 || per_page > 100 {
            return Err(ApiError::ValidationError("Invalid pagination parameters".to_string()));
        }
    
        let (items, total) = self.repository.get_paginated(page, per_page).await?;
        let item_responses: Vec<TestItemResponse> = items
            .into_iter()
            .map(|i| i.to_response())
            .collect();
    
        Ok(PaginatedTestItemsResponse::new(item_responses, total, page, per_page))
    }
    

    Update

    src/application/services/test_item_service.rs
    pub async fn update(&self, id: &str, request: UpdateTestItemRequest) -> Result<TestItemResponse, ApiError> {
        let mut item = self.repository.get_by_id(id).await?
            .ok_or_else(|| ApiError::NotFound("Test item not found".to_string()))?;
    
        if let Some(subject) = request.subject {
            item.update_subject(subject);
        }
    
        if request.optional_field.is_some() {
            item.update_optional_field(request.optional_field);
        }
    
        self.repository.update(&item).await?;
        Ok(item.to_response())
    }
    

    Delete

    src/application/services/test_item_service.rs
    pub async fn delete(&self, id: &str) -> Result<(), ApiError> {
        let deleted = self.repository.delete(id).await?;
        if !deleted {
            return Err(ApiError::NotFound("Test item not found".to_string()));
        }
        Ok(())
    }
    

    Authentication Logic

    src/application/services/auth_service.rs
    pub async fn login(&self, request: LoginRequest) -> Result<AuthResponse, ApiError> {
        let user = self
            .user_repository
            .get_by_email(&request.email)
            .await?
            .ok_or(ApiError::Unauthorized)?;
    
        if !user.is_active() {
            return Err(ApiError::Forbidden("Account is disabled".to_string()));
        }
    
        if !crate::utils::auth::verify_password(&request.password, &user.password_hash)? {
            return Err(ApiError::Unauthorized);
        }
    
        let token = create_token(
            &user.id,
            user.email.as_str(),
            &user.role.to_string(),
            &self.config,
        )?;
    
        Ok(AuthResponse {
            user: user.to_response(),
            token,
        })
    }
    

    Dependency Injection

    Services receive dependencies through their constructor:
    pub struct AuthService {
        user_repository: Arc<dyn UserRepository>,
        config: Arc<AppConfig>,
    }
    
    impl AuthService {
        pub fn new(user_repository: Arc<dyn UserRepository>, config: Arc<AppConfig>) -> Self {
            Self {
                user_repository,
                config,
            }
        }
    }
    
    Use Arc<dyn Trait> for repositories to enable dependency injection and testing.

    Error Handling

    Services return Result<T, ApiError> to propagate errors:
    pub async fn get_by_id(&self, id: &str) -> Result<Option<TestItemResponse>, ApiError> {
        let item = self.repository.get_by_id(id).await?;
        Ok(item.map(|i| i.to_response()))
    }
    
    The ? operator automatically converts:
    • DomainErrorApiError::BadRequest
    • Database errors → ApiError::DatabaseError
    • Not found → ApiError::NotFound

    Service Responsibilities

    Services SHOULD:
    • Orchestrate business workflows
    • Validate contextual rules (uniqueness, quotas)
    • Transform DTOs to domain objects
    • Call repositories for persistence
    • Handle external integrations
    • Convert entities to response DTOs
    Services SHOULD NOT:
    • Handle HTTP concerns (status codes, headers)
    • Contain domain validation logic (use Value Objects)
    • Directly access databases (use repositories)
    • Manage transactions (repository responsibility)

    Data Flow Through Services

    Testing Services

    Services are easy to test with mock repositories:
    #[cfg(test)]
    mod tests {
        use super::*;
        use mockall::predicate::*;
        use mockall::mock;
    
        mock! {
            TestItemRepo {}
            #[async_trait]
            impl TestItemRepository for TestItemRepo {
                async fn create(&self, item: &TestItem) -> Result<TestItem, ApiError>;
            }
        }
    
        #[tokio::test]
        async fn test_create_item() {
            let mut mock_repo = MockTestItemRepo::new();
            mock_repo.expect_create()
                .returning(|item| Ok(item.clone()));
    
            let service = TestItemService::new(Arc::new(mock_repo));
            let request = CreateTestItemRequest {
                subject: "Test".to_string(),
                optional_field: None,
            };
    
            let result = service.create(request).await;
            assert!(result.is_ok());
        }
    }
    

    Best Practices

    • Never expose entities directly - Always convert to DTOs
    • Validate before persistence - Create Value Objects before entities
    • Check uniqueness in service - Don’t rely on database constraints alone
    • Use transactions for multi-step operations - Ensure atomicity

    Next Steps

    Build docs developers (and LLMs) love