actix-web
SKILL.md
Actix-web Framework Guide
Applies to: Actix-web 4+, Rust Web APIs, High-Performance Services Complements:
.claude/skills/rust-guide/SKILL.md
Core Principles
- Type-Safe Extraction: Use extractors (
web::Json,web::Path,web::Query) for request data - Thin Handlers: Handlers delegate to services; no business logic in handlers
- Structured Errors: Implement
ResponseErrorfor all error types; never return raw strings - Shared State via
web::Data: Application state is injected, never global - Middleware for Cross-Cutting: Auth, logging, CORS belong in middleware, not handlers
Project Structure
myproject/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point, server bootstrap
│ ├── config.rs # Configuration loading
│ ├── routes.rs # Route registration
│ ├── handlers/
│ │ ├── mod.rs
│ │ ├── users.rs # User-related handlers
│ │ └── health.rs # Health check endpoint
│ ├── models/
│ │ ├── mod.rs
│ │ └── user.rs # Domain models + DTOs
│ ├── services/
│ │ ├── mod.rs
│ │ └── user_service.rs # Business logic layer
│ ├── repositories/
│ │ ├── mod.rs
│ │ └── user_repository.rs # Database access layer
│ ├── middleware/
│ │ ├── mod.rs
│ │ └── auth.rs # Authentication middleware
│ └── errors/
│ ├── mod.rs
│ └── app_error.rs # Centralized error types
├── tests/
│ └── integration_tests.rs
└── migrations/
main.rsbootstraps server, loads config, creates pool, wires routes- Business logic lives in
services/, not in handlers - Database access isolated in
repositories/ - One error enum per crate with
ResponseErrorimpl
Dependencies (Cargo.toml)
[dependencies]
actix-web = "4"
actix-rt = "2"
actix-cors = "0.7"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres", "uuid", "chrono"] }
validator = { version = "0.16", features = ["derive"] }
thiserror = "1.0"
anyhow = "1.0"
log = "0.4"
env_logger = "0.10"
tracing = "0.1"
tracing-actix-web = "0.7"
uuid = { version = "1.0", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
[dev-dependencies]
actix-rt = "2"
Application Entry Point
use actix_cors::Cors;
use actix_web::{middleware, web, App, HttpServer};
use sqlx::postgres::PgPoolOptions;
pub struct AppState {
pub pool: sqlx::PgPool,
pub config: config::Config,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenvy::dotenv().ok();
env_logger::init_from_env(env_logger::Env::new().default_filter_or("info"));
let config = config::Config::load().expect("Failed to load configuration");
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&config.database_url)
.await
.expect("Failed to create database pool");
sqlx::migrate!("./migrations")
.run(&pool)
.await
.expect("Failed to run migrations");
let app_state = web::Data::new(AppState {
pool,
config: config.clone(),
});
HttpServer::new(move || {
let cors = Cors::default()
.allow_any_origin()
.allow_any_method()
.allow_any_header()
.max_age(3600);
App::new()
.app_data(app_state.clone())
.wrap(cors)
.wrap(middleware::Logger::default())
.wrap(middleware::Compress::default())
.configure(routes::configure)
})
.bind(format!("{}:{}", config.host, config.port))?
.run()
.await
}
Routing
use actix_web::web;
pub fn configure(cfg: &mut web::ServiceConfig) {
cfg.service(
web::scope("/api/v1")
.route("/health", web::get().to(handlers::health::health_check))
.service(
web::scope("/auth")
.route("/register", web::post().to(handlers::users::register))
.route("/login", web::post().to(handlers::users::login)),
)
.service(
web::scope("/users")
.wrap(crate::middleware::auth::AuthMiddleware)
.route("", web::get().to(handlers::users::list_users))
.route("/{id}", web::get().to(handlers::users::get_user))
.route("/{id}", web::put().to(handlers::users::update_user))
.route("/{id}", web::delete().to(handlers::users::delete_user)),
),
);
}
Routing Guardrails
- Group routes by resource under
web::scope - Apply middleware at the scope level, not per-route
- Use
web::ServiceConfigfor modular route registration - Version API routes (
/api/v1/...) - Keep route definitions separate from handler implementations
Extractors
Extractors are how Actix-web injects request data into handler functions.
| Extractor | Purpose | Example |
|---|---|---|
web::Json<T> |
Deserialize JSON body | body: web::Json<CreateDto> |
web::Path<T> |
URL path parameters | path: web::Path<Uuid> |
web::Query<T> |
Query string parameters | query: web::Query<PaginationQuery> |
web::Data<T> |
Shared application state | state: web::Data<AppState> |
web::Form<T> |
URL-encoded form data | form: web::Form<LoginForm> |
HttpRequest |
Raw request (headers, extensions) | req: HttpRequest |
Extractor Rules
- Always validate extracted data (use
validatorcrate withValidatederive) - Prefer typed extractors over manually parsing
HttpRequest - Use
web::Datafor immutable shared state (wrapped inArcinternally) - Limit JSON body size with
.app_data(web::JsonConfig::default().limit(4096))
Handlers
Handlers are async functions that take extractors and return impl Responder.
use actix_web::{web, HttpResponse};
pub async fn register(
state: web::Data<AppState>,
body: web::Json<CreateUserDto>,
) -> AppResult<HttpResponse> {
let service = UserService::new(state.pool.clone(), state.config.clone());
let response = service.register(body.into_inner()).await?;
Ok(HttpResponse::Created().json(response))
}
pub async fn get_user(
state: web::Data<AppState>,
path: web::Path<Uuid>,
) -> AppResult<HttpResponse> {
let service = UserService::new(state.pool.clone(), state.config.clone());
let user = service.get_user(path.into_inner()).await?;
Ok(HttpResponse::Ok().json(user))
}
Handler Rules
- Return
Result<HttpResponse, AppError>(aliased asAppResult<HttpResponse>) - Use
.into_inner()to unwrap extractors before passing to services - No business logic in handlers -- delegate to service layer
- One handler per HTTP method per resource action
Application State
pub struct AppState {
pub pool: sqlx::PgPool,
pub config: Config,
}
// Register in HttpServer closure:
let app_state = web::Data::new(AppState { pool, config });
App::new().app_data(app_state.clone())
// Access in handlers:
pub async fn handler(state: web::Data<AppState>) -> impl Responder {
let pool = &state.pool;
// use pool...
}
State Rules
- Wrap in
web::Data(internally usesArc) - Clone
web::DatainHttpServer::newclosure (cheap Arc clone) - Keep state immutable; use
tokio::sync::RwLockif mutation is required - Do not store request-scoped data in
AppState
Error Handling
use actix_web::{http::StatusCode, HttpResponse, ResponseError};
#[derive(Debug)]
pub enum AppError {
NotFound(String),
BadRequest(String),
Unauthorized(String),
Forbidden(String),
Conflict(String),
Validation(String),
Internal(String),
Database(sqlx::Error),
}
impl ResponseError for AppError {
fn error_response(&self) -> HttpResponse {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()),
AppError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
AppError::Validation(msg) => (StatusCode::UNPROCESSABLE_ENTITY, msg.clone()),
AppError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error".into()),
AppError::Database(_) => (StatusCode::INTERNAL_SERVER_ERROR, "Database error".into()),
};
HttpResponse::build(status).json(serde_json::json!({
"success": false,
"error": { "code": status.as_u16(), "message": message }
}))
}
}
pub type AppResult<T> = Result<T, AppError>;
Error Handling Rules
- Implement
ResponseErrortrait for all custom error types - Use
From<T>impls for automatic conversion:sqlx::Error,validator::ValidationErrors - Log internal/database errors server-side; return generic messages to clients
- Define
AppResult<T>type alias to reduce boilerplate
Middleware
Actix-web middleware uses the Transform + Service traits.
pub struct AuthMiddleware;
impl<S, B> Transform<S, ServiceRequest> for AuthMiddleware
where
S: Service<ServiceRequest, Response = ServiceResponse<B>, Error = Error> + 'static,
B: 'static,
{
type Response = ServiceResponse<B>;
type Error = Error;
type Transform = AuthMiddlewareService<S>;
type InitError = ();
type Future = Ready<Result<Self::Transform, Self::InitError>>;
fn new_transform(&self, service: S) -> Self::Future {
ok(AuthMiddlewareService { service: Rc::new(service) })
}
}
Middleware Rules
- Use
Transformtrait for middleware factories,Servicefor middleware logic - Store authenticated user data in request extensions (
req.extensions_mut().insert(...)) - Apply middleware at scope level:
.service(web::scope("/protected").wrap(AuthMiddleware)) - Use built-in middleware:
Logger,Compress,NormalizePath,DefaultHeaders - Order matters: middleware wraps from bottom to top (last
.wrap()runs first)
Configuration
use serde::Deserialize;
#[derive(Clone, Deserialize)]
pub struct Config {
pub host: String,
pub port: u16,
pub database_url: String,
pub jwt_secret: String,
pub jwt_expiration_hours: i64,
}
impl Config {
pub fn load() -> anyhow::Result<Self> {
let config = config::Config::builder()
.add_source(config::Environment::default().separator("__").try_parsing(true))
.set_default("host", "127.0.0.1")?
.set_default("port", 8080)?
.build()?;
Ok(config.try_deserialize()?)
}
}
Commands
# Development
cargo run # Start server
cargo watch -x run # Watch mode (requires cargo-watch)
RUST_LOG=actix_web=debug cargo run # Debug logging
# Build
cargo build --release # Production build
cargo check # Fast type-check
# Quality
cargo fmt # Format code
cargo clippy -- -D warnings # Lint with deny
cargo test # Run all tests
cargo tarpaulin # Coverage
# Database
sqlx migrate run # Run migrations
sqlx migrate add <name> # Create migration
Best Practices
Application Structure
- Use
web::Datafor shared application state - Organize routes with
web::scopeandServiceConfig - Service layer for business logic, repository layer for data access
- Keep handlers thin: extract, delegate, respond
Performance
- Use connection pooling (
sqlx::PgPoolordeadpool) - Enable
middleware::Compressfor response compression - Use async/await throughout; avoid blocking operations
- Configure worker count for production:
.workers(num_cpus::get()) - Set JSON payload limits to prevent abuse
Security
- Validate all inputs with
validatorcrate - Use HTTPS in production (terminate at load balancer or use
actix-tls) - Implement rate limiting (
actix-governoror custom middleware) - Sanitize error messages: never expose internal details to clients
- Use
actix-corswith explicit origins in production (notallow_any_origin)
Testing
- Use
actix_web::testmodule for integration tests - Create test app with
test::init_serviceand shared test state - Assert on status codes, response bodies, and headers
- Test middleware independently from handlers
Advanced Topics
For detailed patterns and examples, see:
- references/patterns.md -- Handler examples, database integration, authentication, actors, testing patterns
External References
Weekly Installs
16
Repository
ar4mirez/samuelGitHub Stars
3
First Seen
Feb 20, 2026
Security Audits
Installed on
gemini-cli16
amp16
github-copilot16
codex16
kimi-cli16
opencode16