## Keyboard shortcuts

Press `←` or `→` to navigate between chapters

Press `S` or `/` to search in the book

Press `?` to show this help

Press `Esc` to hide this help

# 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):

    #![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)