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
- Separation of Concerns: HTTP != business logic
- Testability: Services testable without HTTP layer
- Reusability: Same services usable from CLI, agents, other services
- 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):
#![allow(unused)] fn main() { // crates/vapora-backend/src/api/projects.rs pub async fn create_project( State(app_state): State<AppState>, Json(req): Json<CreateProjectRequest>, ) -> Result<(StatusCode, Json<Project>), 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<AppState>, Path(project_id): Path<String>, ) -> Result<Json<Project>, 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):
#![allow(unused)] fn main() { // crates/vapora-backend/src/services/project_service.rs pub struct ProjectService { db: Surreal<Ws>, } impl ProjectService { pub fn new(db: Surreal<Ws>) -> Self { Self { db } } /// Create new project with validation and defaults pub async fn create_project( &self, tenant_id: &str, title: &str, description: &Option<String>, ) -> Result<Project> { // 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<Project> { // 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::<Option<Project>>(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<Project>, u32)> { // 1. Get total count let total = self.db .query("SELECT count(id) FROM projects WHERE tenant_id = $1") .bind(tenant_id) .await? .take::<Option<u32>>(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::<Vec<Project>>(0)? .unwrap_or_default(); Ok((projects, total)) } } }
AppState (Depends On Services):
#![allow(unused)] fn main() { // 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:
#![allow(unused)] fn main() { #[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
# 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)