327 lines
7.8 KiB
Markdown
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)
|