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

327 lines
7.8 KiB
Markdown

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