# 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<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)**:
```rust
// 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)**:
```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)
