Vapora/docs/adrs/0024-service-architecture.md
Jesús Pérez 7110ffeea2
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: extend doc: adr, tutorials, operations, etc
2026-01-12 03:32:47 +00:00

7.8 KiB

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

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

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

// 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:

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