API Development Practices
Error Handling Architecture
Section titled “Error Handling Architecture”The Capsule API uses an onion architecture for error handling, with distinct layers that each have specific responsibilities for how errors are created, propagated, and transformed.
Architectural Layers
Section titled “Architectural Layers”┌─────────────────────────────────────────────────────────────┐│ Network Layer (Salvo) ││ Converts all unexpected errors to HTTP 500 responses │├─────────────────────────────────────────────────────────────┤│ Service Layer (Business Logic) ││ Uses Result<T, ServiceError> for expected client errors │├─────────────────────────────────────────────────────────────┤│ Data/External Layer ││ Returns raw errors (DbErr, io::Error, etc.) │└─────────────────────────────────────────────────────────────┘Layer 1: Network Layer (Salvo Routes)
Section titled “Layer 1: Network Layer (Salvo Routes)”The network layer is the outermost layer and is responsible for:
- Authentication and Authorization - Validate JWTs and permissions
- Request Parsing - Extract and validate request data
- Response Serialization - Convert service responses to HTTP responses
- Error Transformation - Convert all errors to appropriate HTTP status codes
Error Handling Strategy
Section titled “Error Handling Strategy”// Use typed response enums for expected outcomespub enum MyEndpointResponses { Success(SuccessData), BadRequest(String), // 400 - Client error with message Unauthorized(String), // 401 - Missing or invalid auth Forbidden, // 403 - Insufficient permissions NotFound, // 404 - Resource not found Conflict(String), // 409 - State conflict InternalServerError(InternalServerError), // 500 - Internal server error}Layer 2: Service Layer (Business Logic)
Section titled “Layer 2: Service Layer (Business Logic)”The service layer contains the core business logic and is responsible for:
- Business Rule Enforcement - Validate business invariants
- Orchestration - Coordinate multiple data operations
- Transaction Management - Ensure data consistency
- Error Classification - Distinguish between client errors and fatal errors
Error Handling Strategy
Section titled “Error Handling Strategy”Service layer functions should use typed error enums for expected error states that have a “happy path” (where a client action can resolve the issue):
// Good: Domain-specific error with recoverable variants#[derive(Debug, Error)]pub enum FriendshipError { #[error("Database error: {0}")] DbError(#[from] DbErr),
#[error("Not found")] NotFound, // Client can create the resource
#[error("Not authorized")] NotAuthorized, // Client can get proper auth
#[error("Request is not pending")] NotPending, // Expected state, client can retry}For truly fatal/unexpected errors, use eyre::Report to bubble up with context:
use eyre::{Result, WrapErr, bail};
impl MyService { pub async fn complex_operation(&self) -> Result<MyData> { let data = self.data_layer .fetch_something() .await .wrap_err("Failed to fetch initial data")?;
// Fatal: configuration is broken if data.is_corrupt() { bail!("Data corruption detected for id={}", data.id); }
Ok(data) }}Guidelines
Section titled “Guidelines”- Use
Errvariant only for unexpected/unrecoverable errors - Errors that otherwise indicate a happy path should not use Err variants - Use specific error enums for expected failures - Errors that the client can handle (not found, unauthorized, validation failures)
- Wrap context on error propagation - Use
.wrap_err()or.context()to add meaningful context - Never expose/log sensitive details - Error messages should describe what happened, and not unnecessary/sensitive details
Layer 3: Data/External Layer
Section titled “Layer 3: Data/External Layer”The data layer interacts directly with databases, file systems, caches, and external services.
Error Handling Strategy
Section titled “Error Handling Strategy”Return raw errors from external dependencies. Let the service layer decide how to handle them:
// Good: Return raw errorsimpl Query { pub async fn find_user_by_id( db: &DbConn, id: String ) -> Result<Option<user::Model>, DbErr> { User::find_by_id(id).one(db).await }}
// Good: Raw file system errorspub async fn read_file(path: &Path) -> io::Result<Vec<u8>> { tokio::fs::read(path).await}Do not wrap errors at this layer:
// Bad: Wrapping at data layer loses type informationpub async fn find_user_by_id(db: &DbConn, id: String) -> Result<Option<user::Model>> { User::find_by_id(id) .one(db) .await .map_err(|e| eyre::eyre!("Database error: {}", e)) // ❌ Don't do this}Result Types and When to Use Them
Section titled “Result Types and When to Use Them”Result<T, SpecificError>
Section titled “Result<T, SpecificError>”Use for service-layer operations where specific error variants matter:
// Friendship operations have specific, recoverable error statespub async fn send_friend_request( db: &DatabaseConnection, user_id: &str, friend_id: &str,) -> Result<SendRequestResult, FriendshipError> { ... }Result<T, eyre::Report>
Section titled “Result<T, eyre::Report>”Use for operations where any error is fatal/unexpected:
// Server initialization - any error is fatalpub async fn create_router(conn: DatabaseConnection, env: &Environment) -> eyre::Result<Router> { // Errors here mean the server can't start}Option<T>
Section titled “Option<T>”Use when absence is a normal, expected outcome:
// User might not exist - that's not an errorpub async fn find_user_by_id(db: &DbConn, id: String) -> Result<Option<user::Model>, DbErr> { ... }API Response Patterns
Section titled “API Response Patterns”REST Endpoints (Salvo)
Section titled “REST Endpoints (Salvo)”Define typed response enums that implement Writer and EndpointOutRegister:
pub enum GetAssetResponses { Success(AssetData), NotFound, Unauthorized(String), Forbidden,}
#[async_trait]impl Writer for GetAssetResponses { async fn write(self, _req: &mut Request, _depot: &mut Depot, res: &mut Response) { match self { Self::Success(data) => { res.status_code(StatusCode::OK); res.render(Json(data)); } Self::NotFound => { res.status_code(StatusCode::NOT_FOUND); } Self::Unauthorized(msg) => { res.status_code(StatusCode::UNAUTHORIZED); res.render(Text::Plain(msg)); } Self::Forbidden => { res.status_code(StatusCode::FORBIDDEN); } } }}GraphQL (async-graphql)
Section titled “GraphQL (async-graphql)”Use async_graphql::Result<T> which wraps errors appropriately:
#[Object]impl AssetQuery { async fn get_asset(&self, ctx: &Context<'_>, id: ID) -> Result<AssetMetadata> { let db = ctx.data::<DatabaseConnection>()?; let user = ctx.data::<UserContext>()?;
let asset = AssetService::find_by_id(db, &id.to_string()) .await? .ok_or_else(|| Error::new("Asset not found"))?;
// Permission check if !user.can_access(&asset) { return Err(Error::new("Access denied")); }
Ok(asset.into()) }}Logging Best Practices
Section titled “Logging Best Practices”Structured Logging
Section titled “Structured Logging”Use tracing with structured fields:
use tracing::{info, warn, error, instrument};
#[instrument(skip(db), fields(user_id = %user_id))]pub async fn create_asset(db: &DbConn, user_id: &str, input: CreateAssetInput) -> Result<Asset> { info!("Creating new asset");
let asset = do_create(db, input).await.map_err(|e| { error!(?e, "Failed to create asset"); e })?;
info!(asset_id = %asset.id, "Asset created successfully"); Ok(asset)}Log Levels
Section titled “Log Levels”- ERROR: Unexpected failures, requires investigation
- WARN: Recoverable issues, unusual situations
- INFO: Important business events (creation, deletion, auth)
- DEBUG: Detailed execution flow (for development)
- TRACE: Very detailed, per-request level
Sensitive Data
Section titled “Sensitive Data”Never log:
- Passwords or password hashes
- JWT tokens or refresh tokens
- Personal identifiable information (PII) in production
- File contents
Testing Guidelines
Section titled “Testing Guidelines”Unit Tests
Section titled “Unit Tests”Test business logic in isolation:
#[cfg(test)]mod tests { use super::*;
#[test] fn test_validate_username() { assert!(is_valid_username("alice_123")); assert!(!is_valid_username("ab")); // Too short assert!(!is_valid_username("a@b")); // Invalid char }}Integration Tests
Section titled “Integration Tests”Use the capsule-api-testing crate for database-backed tests:
#[tokio::test]async fn test_create_user() { let db = testing::setup_db().await;
let result = UserService::create_user(&db, CreateUserArgs { ... }).await; assert!(result.is_ok());
// Verify in database let user = UserService::find_by_id(&db, &result.unwrap().id).await; assert!(user.is_some());}Security Practices
Section titled “Security Practices”General Guidelines
Section titled “General Guidelines”- Input Validation: Validate all user input at the network layer
- Authorization: Check permissions before every operation
- Rate Limiting: Apply rate limits to authentication and resource-intensive endpoints
- Parameterized Queries: SeaORM handles this, but be careful with raw SQL
- Secret Management: Use
SecretStringfor sensitive data, never log tokens - Limit Dependencies: Only depend on the minimum number of crates necessary. This specifically includes:
sea_ormcode should exist only incapsule-api-entity,capsule-api-migration,capsule-api-service,capsule-api-testing- ID generation of any sort (e.q.,
uuid,nanoid) should exist only incapsule-api-entity,capsule-api-service
Dependency Hierarchy
Section titled “Dependency Hierarchy”To ease auditing sensitive dependencies/crates, we enforce the following hierarchy of crates (amongst the API crates) (from least to most sensitive):
capsule-apicapsule-api-library; capsule-api-media; capsule-api-sync; capsule-api-upload; capsule-api-authcapsule-api-service; capsule-api-model; capsule-api-environmentcapsule-api-entity; capsule-api-migration
# Omitted: capsule-api-environment, capsule-api-testingThe crates in each line must only at most depend on the crate in the same line or the next line. Additionally some of the crates have feature flags guarding certain functionality strictly to certain crates (e.g. auth feature in capsule-api-service for capsule-api-auth).
Note: Some crates in capsule-api may have been not mentioned here so use some judgement.