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

  1. Type-Safe Extraction: Use extractors (web::Json, web::Path, web::Query) for request data
  2. Thin Handlers: Handlers delegate to services; no business logic in handlers
  3. Structured Errors: Implement ResponseError for all error types; never return raw strings
  4. Shared State via web::Data: Application state is injected, never global
  5. 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.rs bootstraps 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 ResponseError impl

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::ServiceConfig for 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 validator crate with Validate derive)
  • Prefer typed extractors over manually parsing HttpRequest
  • Use web::Data for immutable shared state (wrapped in Arc internally)
  • 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 as AppResult<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 uses Arc)
  • Clone web::Data in HttpServer::new closure (cheap Arc clone)
  • Keep state immutable; use tokio::sync::RwLock if 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 ResponseError trait 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 Transform trait for middleware factories, Service for 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::Data for shared application state
  • Organize routes with web::scope and ServiceConfig
  • Service layer for business logic, repository layer for data access
  • Keep handlers thin: extract, delegate, respond

Performance

  • Use connection pooling (sqlx::PgPool or deadpool)
  • Enable middleware::Compress for 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 validator crate
  • Use HTTPS in production (terminate at load balancer or use actix-tls)
  • Implement rate limiting (actix-governor or custom middleware)
  • Sanitize error messages: never expose internal details to clients
  • Use actix-cors with explicit origins in production (not allow_any_origin)

Testing

  • Use actix_web::test module for integration tests
  • Create test app with test::init_service and shared test state
  • Assert on status codes, response bodies, and headers
  • Test middleware independently from handlers

Advanced Topics

For detailed patterns and examples, see:

External References

Weekly Installs
16
Repository
ar4mirez/samuel
GitHub Stars
3
First Seen
Feb 20, 2026
Installed on
gemini-cli16
amp16
github-copilot16
codex16
kimi-cli16
opencode16