# ADR-024: Service-Oriented Module Architecture **Status**: Accepted | Implemented **Date**: 2024-11-01 **Deciders**: Backend Architecture Team **Technical Story**: Separating HTTP concerns from business logic via service layer --- ## Decision Implementar **service-oriented architecture**: API layer (thin) delega a service layer (thick). --- ## Rationale 1. **Separation of Concerns**: HTTP != business logic 2. **Testability**: Services testable without HTTP layer 3. **Reusability**: Same services usable from CLI, agents, other services 4. **Maintainability**: Clear responsibility boundaries --- ## Alternatives Considered ### ❌ Handlers Directly Query Database - **Pros**: Simple, fewer files - **Cons**: Business logic in HTTP layer, not reusable, hard to test ### ❌ Anemic Service Layer (Just CRUD) - **Pros**: Simple - **Cons**: Business logic still in handlers ### ✅ Service-Oriented with Thick Services (CHOSEN) - Services encapsulate business logic --- ## Trade-offs **Pros**: - ✅ Clear separation HTTP ≠ business logic - ✅ Services independently testable - ✅ Reusable across contexts - ✅ Easy to add new endpoints **Cons**: - ⚠️ More files (API + Service) - ⚠️ Slight latency from extra layer - ⚠️ Coordination between layers --- ## Implementation **API Layer (Thin)**: ```rust // crates/vapora-backend/src/api/projects.rs pub async fn create_project( State(app_state): State, Json(req): Json, ) -> Result<(StatusCode, Json), ApiError> { // 1. Extract user context let user = get_current_user()?; // 2. Delegate to service let project = app_state .project_service .create_project( &user.tenant_id, &req.title, &req.description, ) .await .map_err(ApiError::from)?; // 3. Return HTTP response Ok((StatusCode::CREATED, Json(project))) } pub async fn get_project( State(app_state): State, Path(project_id): Path, ) -> Result, ApiError> { let user = get_current_user()?; // Delegate to service let project = app_state .project_service .get_project(&user.tenant_id, &project_id) .await .map_err(ApiError::from)?; Ok(Json(project)) } ``` **Service Layer (Thick)**: ```rust // crates/vapora-backend/src/services/project_service.rs pub struct ProjectService { db: Surreal, } impl ProjectService { pub fn new(db: Surreal) -> Self { Self { db } } /// Create new project with validation and defaults pub async fn create_project( &self, tenant_id: &str, title: &str, description: &Option, ) -> Result { // 1. Validate input if title.is_empty() { return Err(VaporaError::ValidationError("Title cannot be empty".into())); } if title.len() > 255 { return Err(VaporaError::ValidationError("Title too long".into())); } // 2. Create project let project = Project { id: uuid::Uuid::new_v4().to_string(), tenant_id: tenant_id.to_string(), title: title.to_string(), description: description.clone(), status: ProjectStatus::Active, created_at: Utc::now(), updated_at: Utc::now(), ..Default::default() }; // 3. Persist to database self.db .create("projects") .content(&project) .await?; // 4. Audit log audit_log::log_project_created(tenant_id, &project.id, title)?; Ok(project) } /// Get project with permission check pub async fn get_project( &self, tenant_id: &str, project_id: &str, ) -> Result { // 1. Query database let project = self.db .query("SELECT * FROM projects WHERE id = $1 AND tenant_id = $2") .bind((project_id, tenant_id)) .await? .take::>(0)? .ok_or_else(|| VaporaError::ProjectNotFound(project_id.to_string()))?; // 2. Permission check (implicit via tenant_id query) Ok(project) } /// List projects for tenant with pagination pub async fn list_projects( &self, tenant_id: &str, limit: u32, offset: u32, ) -> Result<(Vec, u32)> { // 1. Get total count let total = self.db .query("SELECT count(id) FROM projects WHERE tenant_id = $1") .bind(tenant_id) .await? .take::>(0)? .unwrap_or(0); // 2. Get paginated results let projects = self.db .query( "SELECT * FROM projects \ WHERE tenant_id = $1 \ ORDER BY created_at DESC \ LIMIT $2 START $3" ) .bind((tenant_id, limit, offset)) .await? .take::>(0)? .unwrap_or_default(); Ok((projects, total)) } } ``` **AppState (Depends On Services)**: ```rust // crates/vapora-backend/src/api/state.rs pub struct AppState { pub project_service: ProjectService, pub task_service: TaskService, pub agent_service: AgentService, // Other services... } impl AppState { pub fn new( project_service: ProjectService, task_service: TaskService, agent_service: AgentService, ) -> Self { Self { project_service, task_service, agent_service, } } } ``` **Testable Services**: ```rust #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_create_project() { let db = setup_test_db().await; let service = ProjectService::new(db); let result = service .create_project("tenant:1", "My Project", &None) .await; assert!(result.is_ok()); let project = result.unwrap(); assert_eq!(project.title, "My Project"); } #[tokio::test] async fn test_create_project_empty_title() { let db = setup_test_db().await; let service = ProjectService::new(db); let result = service .create_project("tenant:1", "", &None) .await; assert!(result.is_err()); } } ``` **Key Files**: - `/crates/vapora-backend/src/api/` (thin API handlers) - `/crates/vapora-backend/src/services/` (thick service logic) - `/crates/vapora-backend/src/api/state.rs` (AppState) --- ## Verification ```bash # Test service logic independently cargo test -p vapora-backend test_service_logic # Test API handlers cargo test -p vapora-backend test_api_handlers # Verify separation (API shouldn't directly query DB) grep -r "\.query(" crates/vapora-backend/src/api/ 2>/dev/null | grep -v service # Check service reusability (used in multiple places) grep -r "ProjectService::" crates/vapora-backend/src/ ``` **Expected Output**: - API layer contains only HTTP logic - Services contain business logic - Services independently testable - No direct DB queries in API layer --- ## Consequences ### Code Organization - `/api/` for HTTP concerns - `/services/` for business logic - Clear separation of responsibilities ### Testing - API tests mock services - Service tests use real database - Fast unit tests + integration tests ### Maintainability - Business logic changes in one place - Adding endpoints: just add API handler - Reusing logic: call service from multiple places ### Extensibility - CLI tool can use same services - Agents can use same services - No duplication of business logic --- ## References - `/crates/vapora-backend/src/api/` (API layer) - `/crates/vapora-backend/src/services/` (service layer) - ADR-022 (Error Handling) --- **Related ADRs**: ADR-022 (Error Handling), ADR-023 (Testing)