Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Caelix

Caelix is a Rust backend framework built around modules, dependency injection, controllers, guards, interceptors, lifecycle hooks, cookies, validated uploads, OpenAPI, structured logging, domain events, WebSockets, typed NATS/Redis microservices, and explicit service-level caching.

The public package is:

  • caelix: the facade exporting traits, macros, optional HTTP runtimes, and microservice transports.

The fastest way to start an app is:

cargo install caelix-cli
caelix new demo-api
cd demo-api
cargo run

Generated applications depend on caelix = "0.0.32" from crates.io. The generator refuses to overwrite existing files, so it is safe to run against a feature name and stop when a generated file already exists.

Overview

Caelix applications are built from a root module. Modules describe imports, injectable providers, controllers, gateways, event handlers, and microservice handlers. The selected runtime builds the container, registers its transport handlers, and runs lifecycle hooks.

The public caelix package exports the HTTP runtimes, framework traits and macros, extractors, cookies, uploads, OpenAPI, logging, events, gateways, cache, and NATS/Redis microservices behind feature flags.

Start A Project

Install the CLI from crates.io:

cargo install caelix-cli

Create and run an application:

caelix new demo-api
cd demo-api
caelix run

The generated application uses caelix = "0.0.32" from crates.io:

demo-api/
  Cargo.toml
  src/
    app.rs
    lib.rs
    main.rs

src/main.rs starts the Actix adapter. src/app.rs contains the root AppModule.

Build A Blog Feature

Generate a feature module:

cargo add sqlx --features runtime-tokio-rustls,postgres,macros
caelix g module posts

The command creates:

src/posts/
  controller.rs
  mod.rs
  service.rs

Add application config as a normal provider. It can own both settings and application resources such as a SQLx pool.

#![allow(unused)]
fn main() {
// src/config.rs
use caelix::{
    BoxFuture, Container, Injectable, ProviderDependency, Result,
    ServiceUnavailableException, provider_dependencies,
};
use sqlx::PgPool;

pub struct AppConfig {
    pub database_url: String,
    pub pool: PgPool,
}

impl Injectable for AppConfig {
    fn dependencies() -> Vec<ProviderDependency> {
        provider_dependencies![]
    }

    fn create(_container: &Container) -> BoxFuture<'_, Result<Self>> {
        Box::pin(async {
            let database_url = std::env::var("DATABASE_URL")
                .map_err(|_| ServiceUnavailableException::new("DATABASE_URL must be set"))?;

            let pool = PgPool::connect(&database_url)
                .await?;

            Ok(Self { database_url, pool })
        })
    }
}
}

Generated feature files are not wired into the root app automatically. Register the config and blog module in src/lib.rs and src/app.rs:

#![allow(unused)]
fn main() {
// src/lib.rs
pub mod app;
pub mod config;
pub mod posts;

pub use app::AppModule;
}
#![allow(unused)]
fn main() {
// src/app.rs
use caelix::{Module, ModuleMetadata};

use crate::{config::AppConfig, posts::PostsModule};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider::<AppConfig>()
            .import::<PostsModule>()
    }
}
}

Create a table for posts:

create table posts (
  id bigserial primary key,
  title text not null,
  body text not null
);

Now replace the generated placeholder with DTOs, SQLx writes, and a post-created event emitted after the insert succeeds.

#![allow(unused)]
fn main() {
// src/posts/service.rs
use std::sync::Arc;

use caelix::{EventBus, NotFoundException, Result, injectable};
use serde::{Deserialize, Serialize};

use crate::config::AppConfig;

#[derive(Clone, Serialize, sqlx::FromRow)]
pub struct PostDto {
    pub id: i64,
    pub title: String,
    pub body: String,
}

#[derive(Deserialize)]
pub struct CreatePostDto {
    pub title: String,
    pub body: String,
}

#[derive(Deserialize)]
pub struct ListPostsQuery {
    pub limit: Option<i64>,
}

#[derive(Clone)]
pub struct PostCreated {
    pub id: i64,
    pub title: String,
}

#[injectable]
pub struct PostsService {
    config: Arc<AppConfig>,
    events: Arc<EventBus>,
}

impl PostsService {
    pub async fn list(&self, limit: Option<i64>) -> Result<Vec<PostDto>> {
        let posts = sqlx::query_as::<_, PostDto>(
            "select id, title, body from posts order by id desc limit $1",
        )
        .bind(limit.unwrap_or(20))
        .fetch_all(&self.config.pool)
        .await?;

        Ok(posts)
    }

    pub async fn find(&self, id: i64) -> Result<PostDto> {
        let post = sqlx::query_as::<_, PostDto>(
            "select id, title, body from posts where id = $1",
        )
        .bind(id)
        .fetch_optional(&self.config.pool)
        .await?;

        post.ok_or_else(|| NotFoundException::new(format!("post {id} not found")))
    }

    pub async fn create(&self, input: CreatePostDto) -> Result<PostDto> {
        let post = sqlx::query_as::<_, PostDto>(
            "insert into posts (title, body) values ($1, $2) returning id, title, body",
        )
        .bind(input.title)
        .bind(input.body)
        .fetch_one(&self.config.pool)
        .await?;

        self.events
            .emit(PostCreated {
                id: post.id,
                title: post.title.clone(),
            })
            .await?;

        Ok(post)
    }
}
}

Then add controller routes using path params, query params, JSON bodies, typed responses, and typed errors:

#![allow(unused)]
fn main() {
// src/posts/controller.rs
use std::sync::Arc;

use caelix::StatusCode;
use caelix::{Response, Result, controller, injectable};

use super::{CreatePostDto, ListPostsQuery, PostDto, PostsService};

#[injectable]
pub struct PostsController {
    posts: Arc<PostsService>,
}

#[controller("/posts")]
impl PostsController {
    #[get("")]
    pub async fn list(&self, #[query] query: ListPostsQuery) -> Result<Vec<PostDto>> {
        self.posts.list(query.limit).await
    }

    #[get("/{id}")]
    pub async fn find(&self, #[param] id: i64) -> Result<PostDto> {
        self.posts.find(id).await
    }

    #[post("")]
    pub async fn create(&self, #[body] input: CreatePostDto) -> Result<Response<PostDto>> {
        let post = self.posts.create(input).await?;
        Ok(Response::WithStatus(StatusCode::CREATED, post))
    }
}
}

Register event support explicitly with EventModule. Import it before PostsService, because the service injects Arc<EventBus>.

#![allow(unused)]
fn main() {
// src/posts/mod.rs
mod controller;
mod service;

pub use controller::PostsController;
pub use service::{CreatePostDto, ListPostsQuery, PostCreated, PostDto, PostsService};

use caelix::{BoxFuture, EventHandler, EventModule, Module, ModuleMetadata, RegisterableEventHandler, Result, injectable};

#[injectable]
pub struct LogPostCreated;

impl EventHandler<PostCreated> for LogPostCreated {
    fn handle(&self, event: PostCreated) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            println!("created post {}: {}", event.id, event.title);
            Ok(())
        })
    }
}

impl RegisterableEventHandler for LogPostCreated {
    type Event = PostCreated;
}

pub struct PostsModule;

impl Module for PostsModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<EventModule>()
            .provider::<PostsService>()
            .provider::<LogPostCreated>()
            .controller::<PostsController>()
            .event_handler::<LogPostCreated>()
    }
}
}

Run the app and try the feature:

cargo run
curl -i -X POST http://127.0.0.1:8080/posts \
  -H 'content-type: application/json' \
  -d '{"title":"First post","body":"Hello from Caelix"}'
HTTP/1.1 201 Created
content-type: application/json

{"id":1,"title":"First post","body":"Hello from Caelix"}
curl -i 'http://127.0.0.1:8080/posts?limit=10'
curl -i http://127.0.0.1:8080/posts/1
curl -i http://127.0.0.1:8080/posts/404

Missing posts return Caelix’s standard error shape:

{
  "status": 404,
  "error": "Not Found",
  "message": "post 404 not found"
}

Minimal Application

A minimal app needs a module and an entry point for the selected backend (Actix is the default; Axum is enabled with the axum feature).

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
    }
}
}
use caelix::Application;
use demo_api::AppModule;

#[caelix::main]
async fn main() -> std::io::Result<()> {
    Application::new::<AppModule>()
        .await
        .map_err(|err| std::io::Error::other(err.message))?
        .listen("127.0.0.1:8080")
        .await
}

Application::new::<AppModule>() builds the dependency container, validates module metadata, registers controller routes, runs startup lifecycle hooks, and logs the route table.

Startup errors are returned from Application::new, so application code decides whether to propagate or handle them.

Project Layout

The generator creates a small root module. Feature code usually lives under src/<feature>/.

src/
  app.rs
  lib.rs
  main.rs
  users/
    mod.rs
    service.rs
    controller.rs

Feature modules typically export their service, controller, and module types from mod.rs, then the root app imports the feature module:

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};

use crate::users::UsersModule;

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().import::<UsersModule>()
    }
}
}

Feature names are normalized by the CLI. users maps to src/users; auth-session maps to src/auth_session, route path /auth-session, and types such as AuthSessionModule, AuthSessionService, and AuthSessionController.

CLI Guide

The CLI binary is named caelix.

Install it from crates.io:

cargo install caelix-cli

After installation, run commands with the caelix binary.

Run An Application

caelix run

This resets the terminal, clears its viewport and scrollback buffer, then delegates to cargo run in the current project. Use --watch to restart the application when files under src/ or Cargo.toml change:

caelix run --watch

Arguments after -- are passed directly to the application:

caelix run --watch -- --port 4000 --verbose

Validate An Application

caelix doctor

This runs the normal application binary as cargo run -- --doctor. Caelix completes application construction, module validation, provider construction, lifecycle startup hooks, and route/runtime setup, then runs shutdown hooks and exits without binding an HTTP port.

It returns a non-zero status when compilation, configuration, startup, or shutdown fails. Doctor mode intentionally permits normal startup side effects because it verifies the same initialization path as caelix run; the only skipped step is network binding and request serving.

Create An Application

caelix new demo-api

Pass --backend axum to generate a project that opts into Caelix’s Axum adapter and includes tower-http for native Tower layers:

caelix new demo-api --backend axum

The command creates:

  • Cargo.toml
  • AGENTS.md
  • src/main.rs
  • src/lib.rs
  • src/app.rs

The default generated Cargo.toml uses caelix = "0.0.32" from crates.io and the Actix backend. The Axum option disables default features and enables axum instead.

The generated AGENTS.md gives AI coding agents the app-level Caelix conventions: explicit module registration, provider/controller registration, injectable field shape, service-level cache behavior, and the usual cargo test check.

Generated src/main.rs starts the selected adapter:

use caelix::Application;
use demo_api::AppModule;

#[caelix::main]
async fn main() -> std::io::Result<()> {
    Application::new::<AppModule>()
        .await
        .map_err(|err| std::io::Error::other(err.message))?
        .listen("127.0.0.1:8080")
        .await
}

Generated src/app.rs defines an empty root module:

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
    }
}
}

Update Caelix

caelix update

This command reads the current caelix dependency from Cargo.toml, fetches the latest published caelix version from crates.io, and updates only that dependency when a newer version exists. It preserves existing Cargo.toml comments, formatting, dependency order, and feature settings.

After editing Cargo.toml, the CLI runs:

cargo update -p caelix

caelix update does not regenerate or re-sync scaffolded source files.

Generate A Feature Module

caelix g module users

This is equivalent to:

caelix generate module users

It creates:

  • src/users/mod.rs
  • src/users/service.rs
  • src/users/controller.rs

The generated module registers the service as a provider and the controller as a controller. The CLI prints manual registration steps for adding the module to the app.

Add the generated module to src/lib.rs:

#![allow(unused)]
fn main() {
pub mod users;
}

Then import it in the root module:

#![allow(unused)]
fn main() {
use crate::users::UsersModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().import::<UsersModule>()
    }
}
}

Generate A Service

caelix g service users

This creates src/users/service.rs. If src/users/mod.rs does not exist, it creates a feature mod.rs that exports the service.

Generate A Controller

caelix g controller users

This creates src/users/controller.rs. If src/users/service.rs exists, the controller injects Arc<UsersService> and calls it. If the service does not exist, the controller is generated without that dependency and the CLI prints a note.

Generated controller with a service:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{Result, controller, injectable};

use super::UsersService;

#[injectable]
pub struct UsersController {
    service: Arc<UsersService>,
}

#[controller("/users")]
impl UsersController {
    #[get("")]
    pub async fn hello(&self) -> Result<String> {
        Ok(self.service.hello())
    }
}
}

Name Normalization

InputDirectoryRouteTypes
userssrc/users/usersUsersModule, UsersService, UsersController
auth-sessionsrc/auth_session/auth-sessionAuthSessionModule, AuthSessionService, AuthSessionController
Admin Userssrc/admin_users/admin-usersAdminUsersModule, AdminUsersService, AdminUsersController

Overwrite Behavior

The CLI refuses to overwrite existing generated files. If any target file already exists, the command returns an error like:

src/users/service.rs already exists; refusing to overwrite

For caelix g module users, all three target files must be missing: mod.rs, service.rs, and controller.rs.

Command Reference

caelix new

caelix new <name>

Pass --backend axum to generate an Axum-backed app. The default is Actix.

Creates a new Caelix application directory named <name>.

The command creates Cargo.toml, AGENTS.md, src/main.rs, src/lib.rs, and src/app.rs. The package name comes from the target directory and is converted to kebab case.

caelix doctor

caelix doctor

Runs cargo run -- --doctor in the current Cargo project. The normal application binary completes full startup validation, runtime route setup, and shutdown without binding a network socket. It returns a non-zero exit status when compilation, configuration, startup, or shutdown fails.

caelix generate module

caelix generate module <name>
caelix g module <name>

Creates a feature module, service, and controller below src/<normalized-name>/.

Generation requires a Cargo.toml in the current directory. It refuses to create files when run outside a Cargo project.

caelix generate service

caelix generate service <name>
caelix g service <name>

Creates service.rs and creates mod.rs only when missing.

Generation requires a Cargo.toml in the current directory.

caelix generate controller

caelix generate controller <name>
caelix g controller <name>

Creates controller.rs and creates mod.rs only when missing. A matching service is injected only when src/<feature>/service.rs already exists.

Generation requires a Cargo.toml in the current directory.

If the service does not exist, the controller is generated without a service dependency and the CLI prints a note.

Name Rules

Names are trimmed and cannot be empty or contain / or \. The normalized Rust module name cannot start with an ASCII digit.

Examples:

InputDirectoryRouteTypes
userssrc/users/usersUsersModule, UsersService, UsersController
auth-sessionsrc/auth_session/auth-sessionAuthSessionModule, AuthSessionService, AuthSessionController
admin userssrc/admin_users/admin-usersAdminUsersModule, AdminUsersService, AdminUsersController

Invalid names include an empty string, names containing / or \, and names whose normalized Rust module name starts with an ASCII digit.

Overwrite Rules

The CLI refuses to overwrite files. Typical errors:

src/users/service.rs already exists; refusing to overwrite

Registration

Generation does not edit src/app.rs or src/lib.rs. Register generated modules manually:

#![allow(unused)]
fn main() {
pub mod users;
}
#![allow(unused)]
fn main() {
use crate::users::UsersModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().import::<UsersModule>()
    }
}
}

Modules

A module implements Module and returns ModuleMetadata. Metadata is the only thing Caelix needs to build the dependency graph and route table.

#![allow(unused)]
fn main() {
use caelix::{CacheModule, Module, ModuleMetadata};

pub struct UsersModule;

impl Module for UsersModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<CacheModule>()
            .provider::<UsersService>()
            .controller::<UsersController>()
    }
}
}

ModuleMetadata is a builder:

#![allow(unused)]
fn main() {
pub struct ModuleMetadata {
    pub imports: Vec<ModuleDef>,
    pub providers: Vec<ProviderDef>,
    pub controllers: Vec<ControllerDef>,
    pub event_handlers: Vec<EventHandlerDef>,
}

impl ModuleMetadata {
    pub fn new() -> Self;
    pub fn import<M: Module + 'static>(self) -> Self;
    pub fn provider<T: Injectable>(self) -> Self;
    pub fn global() -> Self;
    pub fn export<T: Send + Sync + 'static>(self) -> Self;
    pub fn provider_async_factory<T, Fut, E>(self, dependencies: Vec<ProviderDependency>, factory: impl Fn(Arc<Container>) -> Fut + Send + Sync + 'static) -> Self;
    pub fn controller<C: Controller + Injectable + 'static>(self) -> Self;
    pub fn event_handler<H>(self) -> Self;
    pub fn event_handler_for<E, H>(self) -> Self;
}
}

The common registrations are:

  • .import::<OtherModule>()
  • .provider::<T>()
  • .provider_async_factory::<T, _, _>(provider_dependencies![...], factory)
  • .controller::<C>()
  • .event_handler::<H>()
  • .event_handler_for::<Event, H>()

Visibility and Registration Order

Imported modules are registered before the module that imports them. Inside a module, providers are registered before controllers because controllers are providers too. Event handlers are registered after their provider instances exist. Modules that emit events or register handlers must import EventModule before providers that inject Arc<EventBus>.

An import does not make every provider public. Export the provider from its owning module, then import that module where it is consumed:

#![allow(unused)]
fn main() {
pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<DatabaseModule>()
            .import::<UsersModule>()
    }
}
}

If DatabaseModule registers DatabasePool, it must call .export::<DatabasePool>() before a provider in UsersModule can resolve Arc<DatabasePool>. A module may re-export an export from one of its direct imports. Global modules use ModuleMetadata::global() and make only their explicit exports visible application-wide after they are imported somewhere in the reachable graph.

Each module and provider type has one owner. Repeated module imports are deduplicated, while duplicate production provider registrations are startup errors rather than overwrite order.

Controllers Are Providers

.controller::<UsersController>() adds a controller definition and a provider definition for the same type. Controller dependencies are resolved from the container exactly like service dependencies.

#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersController {
    users: Arc<UsersService>,
}
}

Do not also register the same controller with .provider::<UsersController>(); .controller::<UsersController>() already constructs it.

Startup Failures

Application startup fails when metadata references a provider that was not registered into the container. Common causes are:

  • A controller depends on Arc<UsersService>, but UsersService is not registered with .provider::<UsersService>().
  • An event handler is listed with .event_handler::<SendWelcomeEmail>(), but the handler was not also registered as a provider.
  • A provider injects Arc<EventBus> or a module registers event handlers, but the module did not import EventModule.
  • A provider uses a private or non-exported dependency from another module.
  • A factory provider returns an error.
  • A lifecycle hook returns an Err(HttpException).

Application::new::<AppModule>() returns these startup errors as caelix::Result<Application>.

Providers

Providers implement Injectable. Caelix constructs providers during application startup and stores them as Arc<T> in the container.

Macro Providers

The #[injectable] macro implements Injectable for unit structs and named-field structs.

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{Logger, injectable};

#[injectable]
pub struct UsersService {
    logger: Arc<Logger>,
    repository: Arc<UsersRepository>,
}
}

Every named field must be Arc<T>. The macro resolves each field from the container. Arc<Logger> is special: it receives a logger scoped to the struct name instead of resolving the default application logger.

Unit structs work well for stateless services:

#![allow(unused)]
fn main() {
#[injectable]
pub struct HealthService;

impl HealthService {
    pub fn status(&self) -> &'static str {
        "ok"
    }
}
}

Tuple structs and non-struct items are rejected by the macro. Named fields that are not Arc<T> are also rejected.

Manual Injectable

Implement Injectable manually when construction needs owned state, custom initialization, or lifecycle hooks. A manual implementation has two parts:

  • dependencies() declares every provider type resolved with container.resolve::<T>() in create.
  • create() performs the actual resolution and construction.

Use provider_dependencies![...] for the declaration. It is required: omitting dependencies() is a compile error, including for a dependency-free provider. This is also enforced at construction time. Caelix gives create a scoped container and rejects container.resolve::<T>() when T is absent from the declaration. The list therefore cannot be used to bypass module visibility.

Caelix uses the declaration before construction to check module visibility, report missing dependencies, arrange startup order, and reject dependency cycles. A scoped logger from container.resolve_logger(...) is not a provider dependency, so do not list Logger.

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{
    BoxFuture, Container, Injectable, Logger, ProviderDependency, Result,
    provider_dependencies,
};

pub struct UsersService {
    repository: Arc<UsersRepository>,
    logger: Arc<Logger>,
}

impl Injectable for UsersService {
    fn dependencies() -> Vec<ProviderDependency> {
        provider_dependencies![UsersRepository]
    }

    fn create(container: &Container) -> BoxFuture<'_, Result<Self>> {
        Box::pin(async move {
            Ok(Self {
                repository: container.resolve::<UsersRepository>()?,
                logger: container.resolve_logger("UsersService"),
            })
        })
    }

    fn on_module_init(&self) -> BoxFuture<'_, Result<()>> {
        Box::pin(async { Ok(()) })
    }
}
}

Keep the declaration in sync with create. If a provider resolves two services, it declares both:

#![allow(unused)]
fn main() {
impl Injectable for ReportService {
    fn dependencies() -> Vec<ProviderDependency> {
        provider_dependencies![UsersRepository, AuditService]
    }

    fn create(container: &Container) -> BoxFuture<'_, Result<Self>> {
        Box::pin(async move {
            Ok(Self {
                repository: container.resolve::<UsersRepository>()?,
                audit: container.resolve::<AuditService>()?,
            })
        })
    }
}
}

For a dependency-free manual provider, return provider_dependencies![]. Dependencies must also be visible to the module: declare them locally or import a module that explicitly exports them. The declaration applies only during construction; resolving application services later from a request or runtime-owned container is not part of this provider-construction contract.

Async Factories

Use an async factory when construction needs fallible async work, such as opening a database pool. Its first argument is always a dependency declaration, including provider_dependencies![] when the factory resolves nothing. It has the same scheduling and visibility role as Injectable::dependencies().

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{Container, Module, ModuleMetadata, provider_dependencies};

pub struct DatabasePool;

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider_async_factory::<DatabasePool, _, _>(
                provider_dependencies![],
                |_container: Arc<Container>| async move {
                    DatabasePool::connect("postgres://localhost/app").await
                },
            )
    }
}
}

The factory receives an Arc<Container>. Every provider type resolved from it must appear in the first argument; closures cannot be inspected to infer this list. Caelix applies the same scoped-resolution check used for handwritten Injectable implementations. A scoped logger obtained through container.resolve_logger(...) is not a provider dependency.

#![allow(unused)]
fn main() {
ModuleMetadata::new()
    .provider::<Config>()
    .provider_async_factory::<DatabasePool, _, _>(
        provider_dependencies![Config],
        |container: Arc<Container>| async move {
            let config = container.resolve::<Config>()?;
            DatabasePool::connect(&config.database_url).await
        },
    )
}

Factory dependencies follow normal module visibility rules. If Config is owned by ConfigModule, ConfigModule must export Config and the module declaring the factory must import ConfigModule. Declaring Config in the factory list does not make it public or register it.

Async factory providers are construction-only. They use no-op lifecycle callbacks, so providers that need on_module_init, on_bootstrap, or on_shutdown should implement Injectable directly and use .provider::<T>().

Owned Resources

An application-owned provider can store external resources directly:

#![allow(unused)]
fn main() {
pub struct AppConfig {
    pub database_url: String,
    pub pool: PgPool,
}
}

When AppConfig is registered with .provider::<AppConfig>(), services can inject Arc<AppConfig> and use config.pool. This does not register PgPool as its own provider. Injecting Arc<PgPool> only works if PgPool itself is registered separately.

Application crates usually cannot write impl Injectable for PgPool because Rust’s orphan rules prevent implementing a foreign trait for a foreign type. For fallible startup errors instead of expect failures, keep using .provider_async_factory::<PgPool, _, _>(...) or wrap the pool in an application-owned newtype and implement Injectable for that wrapper.

Provider Visibility

Providers are visible only within their declaring module, through explicit exports from direct imports, or through explicit exports of global modules. Imports are not registration-order visibility.

#[injectable] records Arc<T> fields automatically. Manual implementations and async factories must declare each resolved dependency with provider_dependencies![T, ...]. The declaration is mandatory for handwritten providers, and Caelix rejects container.resolve::<T>() during construction when T is absent from it.

Controllers

Controllers are injectable providers with route metadata generated by #[controller].

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{Response, Result, StatusCode, controller, injectable};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
pub struct ListUsersQuery {
    pub limit: Option<usize>,
}

#[derive(Deserialize)]
pub struct CreateUserDto {
    pub email: String,
    pub name: String,
}

#[derive(Serialize)]
pub struct UserDto {
    pub id: i64,
    pub email: String,
    pub name: String,
}

#[injectable]
pub struct UsersController {
    users: Arc<UsersService>,
}

#[controller("/users")]
impl UsersController {
    #[get("")]
    pub async fn list(&self, #[query] query: ListUsersQuery) -> Result<Vec<UserDto>> {
        self.users.list(query.limit).await
    }

    #[get("/{id}")]
    pub async fn get_user(&self, #[param] id: i64) -> Result<UserDto> {
        self.users.find(id).await
    }

    #[post("")]
    pub async fn create_user(&self, #[body] input: CreateUserDto) -> Result<Response<UserDto>> {
        let user = self.users.create(input).await?;
        Ok(Response::WithStatus(StatusCode::CREATED, user))
    }
}
}

Supported route attributes are #[get], #[post], #[patch], #[put], and #[delete].

Controller parameters use #[param], #[query], #[body], #[file], #[files], #[multipart], #[user], and #[cookie("name")]. #[validate] validates a decoded value. See the canonical Extractors guide for accepted Rust types, feature requirements, combinations, order, and error behavior. Extractor arguments must be simple identifiers.

Routing example

A single path segment can be extracted directly:

#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<UserDto> {
    self.users.find(id).await
}
}

For multiple path params, use a tuple or a Serde-deserializable struct:

#![allow(unused)]
fn main() {
#[get("/{org_id}/users/{user_id}")]
pub async fn find_in_org(&self, #[param] ids: (i64, i64)) -> Result<UserDto> {
    let (org_id, user_id) = ids;
    self.users.find_in_org(org_id, user_id).await
}
}

Generated Route Metadata

The macro implements Controller for the type. Registered routes are exposed through Controller::routes() and logged at application startup. Display paths use :id style in logs, even though route attributes use Actix’s {id} syntax.

Extractors

Controller extractors turn HTTP request data into typed method arguments. Their behavior is shared by Actix and Axum; applications do not use backend extractor types in controller signatures.

AttributeAccepted Rust typeFeature
#[param]any Serde-deserializable scalar, tuple, or structruntime
#[query]any Serde-deserializable typeruntime
#[body]any Serde-deserializable DTOruntime; uploads for multipart
#[user]a cloneable concrete type stored in RequestContextruntime
#[cookie("name")]String or Option<String>runtime
#[file]UploadedFile or Option<UploadedFile>uploads
#[files]Vec<UploadedFile>uploads
#[multipart]MultipartFormuploads
#[validate]a value implementing validator::Validatevalidator

Arguments must be simple identifiers. Each extracted parameter has exactly one extractor marker; #[validate] is an additional marker on a decoded value.

#![allow(unused)]
fn main() {
#[post("/{account_id}")]
async fn update(
    &self,
    #[param] account_id: i64,
    #[query] options: UpdateOptions,
    #[body] #[validate] input: UpdateAccount,
    #[user] actor: CurrentUser,
    #[cookie("session")] session: String,
) -> Result<Response<Account>> {
    self.accounts.update(account_id, options, input, actor, session).await
}
}

Path and query types use Serde conversion. A body accepts JSON when Content-Type is application/json or omitted. With uploads, it also accepts multipart text fields using Serde form semantics. #[user] clones a typed value previously inserted by a guard or interceptor. A missing user returns 401 Unauthorized with Not authenticated.

Cookie extraction is described fully in Cookies and Sessions. A required cookie is String; an optional cookie is Option<String>.

Upload combinations

#[body] may be combined with #[file] or #[files]. A required file makes multipart mandatory. If every file is optional, JSON remains valid and supplies None. Vec<UploadedFile> collects repeated parts and requires multipart. #[multipart] MultipartForm owns the complete form and cannot be combined with #[body], #[file], or #[files].

File checks run in request order: declared size, declared or detected MIME type, then the async controller validator named by validate = method. DTO #[validate] runs after successful decoding. See Multipart Uploads for file types, lifecycle, and #[upload(limit = bytes)].

Failure normalization and order

Extraction finishes before the controller method runs. Caelix normalizes failures:

  • 400 Bad Request: malformed path/query/body/multipart data, missing required cookie or file, duplicate single-file parts, validation failure, or rejected MIME.
  • 401 Unauthorized: missing #[user] context value.
  • 413 Payload Too Large: application/route body limit or file-size violation.
  • 415 Unsupported Media Type: a declared content type the route cannot accept.

Malformed input cannot reach validation because no typed value exists. After decoding, DTO validation and then file validation complete before method invocation. A failed multipart request drops all staged files.

With openapi, body, path, query, file, and cookie declarations contribute their corresponding operation parameters or request body. Runtime errors keep the standard error response shape.

Cookies and Sessions

Caelix provides runtime-neutral incoming cookie lookup and secure response-cookie construction. It deliberately does not own session storage or cryptography.

Read cookies

#![allow(unused)]
fn main() {
#[get("/me")]
async fn me(
    &self,
    #[cookie("session")] session: String,
    #[cookie("theme")] theme: Option<String>,
) -> Result<Response<User>> {
    Ok(Response::Body(self.sessions.user(session, theme).await?))
}
}

#[cookie] requires a non-empty string literal. String is required and a missing value returns 400 Bad Request; Option<String> becomes None. Guards and interceptors can use ctx.cookie("session") -> Option<&str>.

Caelix combines repeated Cookie headers, percent-decodes names and values, and keeps the first value when a name occurs more than once. Parsing happens once when RequestContext is created. With openapi, the macro emits a string cookie parameter whose required flag matches the Rust type.

Set and remove cookies

#![allow(unused)]
fn main() {
use caelix::{Cookie, Response, SameSite};
use std::time::Duration;

let response = Response::Body(user)
    .with_cookie(
        Cookie::new("session", token)
            .same_site(SameSite::Strict)
            .max_age(Duration::from_secs(60 * 60)),
    )
    .with_cookie(Cookie::new("theme", "dark").http_only(false));
}

Cookie::new defaults to HttpOnly, Secure, SameSite=Lax, and Path=/. Builder methods are http_only, secure, same_site, path, domain, max_age, and expires. Response::with_cookie and HttpResponse::with_cookie append in call order; adapters preserve every cookie as a separate Set-Cookie header.

Remove a stored cookie with matching scope:

#![allow(unused)]
fn main() {
Response::no_content().with_cookie(
    Cookie::removal("session").path("/auth").domain("example.com"),
)
}

removal uses an empty value, Max-Age=0, and the Unix epoch. Its path and domain must match the original cookie or the browser will retain the original.

Local HTTP and security boundaries

Browsers do not send Secure cookies over plain local HTTP. For local-only development, explicitly use .secure(false); keep the default behind HTTPS. Choose SameSite according to the request flow and add application-level CSRF protection wherever cookies authenticate cross-site requests.

Cookie values are opaque to Caelix. A session provider owns lookup, rotation, expiry, revocation, and server-side state. Caelix does not sign or encrypt values. Use an audited signing/encryption library in an injectable session service, keep keys outside source control, and do not confuse signing (integrity) with encryption (confidentiality).

Test the complete exchange

Send incoming cookies with .header("cookie", "..."). Inspect response headers when asserting Set-Cookie; multiple cookies are multiple header values, not a comma-separated field. Test required/optional extraction, percent decoding, secure attributes, and removal scope. The repository’s cookie integration tests exercise the same controller expansion on both runtimes.

OpenAPI and Swagger UI

Enable the openapi feature. Caelix re-exports utoipa, including ToSchema, so no separate dependency is required:

caelix = { version = "0.0.32", features = ["openapi"] }

Opt in when building the application. Caelix serves OpenAPI 3.1 JSON at /openapi.json and Swagger UI at /docs.

#![allow(unused)]
fn main() {
use caelix::openapi::OpenApiConfig;

let app = Application::new::<AppModule>()
    .await?
    .with_openapi(OpenApiConfig::new("Payments API", "1.0.0"))?;
}

OpenApiConfig::json_path(...) and OpenApiConfig::ui_path(...) customize these paths. They must not collide with controller routes.

Document DTOs with utoipa::ToSchema. The controller macro infers JSON request bodies from #[body], multipart request bodies from upload routes, and successful 200 responses from Result<T> and Result<Response<T>>.

For a route with #[body] and #[file], OpenAPI adds multipart/form-data: the DTO fields remain schema-backed and file fields are binary properties. Required single files are listed as required; optional file properties are not. Routes whose files are all optional retain their JSON request content type as well. A direct #[multipart] MultipartForm route is documented as a free-form multipart request body.

#![allow(unused)]
fn main() {
use caelix::openapi::{ToSchema, errors, request_header, response, utoipa};

#[derive(ToSchema)]
struct PaymentDto {
    id: String,
}

#[controller("/payments")]
impl PaymentController {
    #[post("")]
    #[request_header(name = "Idempotency-Key", schema = String, required)]
    #[response(status = 201, body = PaymentDto, headers(("Location", String, "Payment URL")))]
    #[errors(BadRequestException, ConflictException)]
    async fn create(&self, #[body] input: PaymentDto) -> Result<Response<PaymentDto>> {
        // ...
    }
}
}

#[request_header] only adds documentation; it does not extract or authenticate request headers. #[response(BodyType)] overrides inferred response schema, while content_type and optional headers(...) can document raw or streaming output without inventing a body schema. #[errors(...)] documents only the exception markers listed, using Caelix’s shared error envelope. Custom error marker types can implement caelix::openapi::OpenApiError.

Security schemes

Security schemes are documentation-only. Register reusable schemes on OpenApiConfig, then add an imported #[security(...)] marker to the routes that require them. Caelix adds the resulting components.securitySchemes and operation-level security objects to /openapi.json, which makes Swagger UI expose its Authorize controls.

This does not add guards, extract credentials, verify tokens, read cookies, or alter authorization. Keep runtime authorization explicit with #[use_guard(...)], #[user], and your own authentication services.

Standard schemes

Caelix reserves four standard security-scheme names. Register the builder that corresponds to the route marker you plan to use:

BuilderOpenAPI componentRoute requirement
.bearer_auth()BearerAuth: HTTP bearer, JWT formatSecurity::BearerAuth
.api_key_auth("X-API-Key")ApiKeyAuth: API key in a headerSecurity::ApiKeyAuth
.cookie_auth("session")CookieAuth: API key in a cookieSecurity::CookieAuth
.oauth2(...)OAuth2: supplied OAuth2 flowsSecurity::OAuth2(&[...])

For example, this configures bearer, header API-key, cookie, and OAuth2 client-credentials components in Swagger UI:

#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, security};

let openapi = OpenApiConfig::new("Payments API", "1.0.0")
    .bearer_auth()
    .api_key_auth("X-API-Key")
    .cookie_auth("session")
    .oauth2(security::OAuth2::new([
        security::Flow::ClientCredentials(security::ClientCredentials::new(
            "https://identity.example.com/oauth/token",
            security::Scopes::from_iter([
                ("payments:read", "Read payments"),
                ("payments:write", "Create and update payments"),
            ]),
        )),
    ]));
}

Pass that configuration to the normal fallible application builder:

#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, Security, security};

let app = Application::new::<AppModule>()
    .await?
    .with_openapi(openapi)?;

#[controller("/account")]
impl AccountController {
    #[security(Security::BearerAuth)]
    #[get("/me")]
    async fn me(&self, #[user] user: CurrentUser) -> Result<UserDto> {
        // ...
    }
}
}

OAuth2 scopes and combined requirements

Security::OAuth2(&["payments:read"]) requests those OAuth2 scopes for one operation. Keep the scope names aligned with the OAuth2 flow you registered. API-key, bearer, and cookie requirements always carry an empty OpenAPI scope list.

Multiple #[security(...)] attributes on the same method produce one OpenAPI Security Requirement Object, so they are combined as AND. This is useful when an endpoint requires both a user token and a tenant key:

#![allow(unused)]
fn main() {
#[security(Security::BearerAuth)]
#[security(Security::ApiKeyAuth)]
#[security(Security::OAuth2(&["payments:write"]))]
#[use_guard(WritePaymentGuard)]
#[post("")]
async fn create(&self, #[body] input: CreatePaymentDto) -> Result<Response<PaymentDto>> {
    // Runtime guard behavior is unchanged by the documentation attributes.
}
}

OpenAPI also supports OR alternatives, but Caelix’s current marker syntax intentionally documents the common AND case only. Model a route with optional or alternative authentication explicitly in your application and register its custom documentation scheme as appropriate.

Application-defined schemes

Use .security_scheme(...) and Security::Custom when the standard names do not fit—for example, a tenant key, a signed request, or an OpenID Connect component:

#![allow(unused)]
fn main() {
use caelix::openapi::{OpenApiConfig, Security, security};

let openapi = OpenApiConfig::new("Payments API", "1.0.0").security_scheme(
    "TenantAuth",
    security::SecurityScheme::ApiKey(security::ApiKey::Header(
        security::ApiKeyValue::new("X-Tenant-Key"),
    )),
);

#[security(Security::Custom {
    name: "TenantAuth",
    scopes: &[],
})]
#[get("/tenant/payments")]
async fn list(&self) -> Result<Vec<PaymentDto>> {
    // ...
}
}

Security::Custom may include scopes for an application-defined OAuth2 scheme. Its name must exactly match the component name registered with .security_scheme(...).

Configuration validation

with_openapi(...) returns caelix::Result<Self>. Caelix validates the completed document before the application starts and returns an OpenAPI Configuration Error when:

  • a route names a scheme that has not been registered;
  • BearerAuth, ApiKeyAuth, CookieAuth, or OAuth2 is registered under the standard name with the wrong builder; or
  • the OpenAPI JSON or Swagger UI paths collide with controller routes.

This catches documentation drift early while keeping security configuration and runtime authentication intentionally separate. Use #[request_header(...)] for ordinary request metadata such as idempotency keys and tenant identifiers; do not use it to describe bearer tokens, API keys, or session cookies.

Responses And Errors

Handlers usually return caelix::Result<T>, where T implements IntoCaelixResponse. The controller macro converts successful values and HttpException errors into adapter responses.

#![allow(unused)]
fn main() {
#[get("")]
pub async fn list(&self) -> Result<Vec<UserDto>> {
    Ok(self.users.list().await?)
}
}

Common response forms:

#![allow(unused)]
fn main() {
use http::StatusCode;
use caelix::Response;

Response::Body(value)
Response::WithStatus(StatusCode::CREATED, value)
Response::no_content()
Response::text(StatusCode::OK, "plain text")
Response::bytes(StatusCode::OK, bytes)
}

Handlers can also return String, &'static str, Response<T>, raw HttpResponse, or any Result<T> where T: IntoCaelixResponse.

JSON Responses

Response::Body(value) serializes value as JSON with status 200 OK.

#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] id: i64) -> Result<Response<UserDto>> {
    Ok(Response::Body(self.users.find(id).await?))
}
}

Response::WithStatus(status, value) serializes JSON with a custom status:

#![allow(unused)]
fn main() {
#[post("")]
pub async fn create(&self, #[body] input: CreateUserDto) -> Result<Response<UserDto>> {
    let user = self.users.create(input).await?;
    Ok(Response::WithStatus(StatusCode::CREATED, user))
}
}

If JSON serialization fails, Caelix returns a generic 500 Internal Server Error JSON body.

Actix adapter errors for supported controller extractors are also returned in the Caelix JSON error shape. Invalid route params, such as a malformed Uuid passed to a #[param] argument, invalid #[query] values, and invalid JSON bodies return 400 Bad Request JSON responses. Missing required body, query, or path fields use the validation error shape with an errors object. Requests that do not match any registered route return a 404 Not Found JSON response.

Empty, Text, Bytes, And Raw

Use Response<()> helpers for non-JSON response bodies:

#![allow(unused)]
fn main() {
#[delete("/{id}")]
pub async fn delete(&self, #[param] id: i64) -> Result<Response<()>> {
    self.users.delete(id).await?;
    Ok(Response::no_content())
}

#[get("/health")]
pub async fn health(&self) -> Result<Response<()>> {
    Ok(Response::text(StatusCode::OK, "ok"))
}
}

Response::bytes uses application/octet-stream. HttpResponse::new, HttpResponse::json, HttpResponse::text, and HttpResponse::bytes are available when you need the fully materialized response type.

Streaming Responses

HttpResponse holds a ResponseBody that is either fully buffered (Vec<u8>) or a streaming async sequence of Bytes chunks. Use streaming when the body is not ready as one blob — large exports, live feeds, or files on disk.

Migration note (breaking in 0.0.x): HttpResponse.body is no longer Vec<u8>. Use response.body_bytes() / response.body.as_buffered() / as_buffered_mut() for buffered bodies. Direct response.body.extend(...) or == b"..." comparisons need updating. Optional response headers live in response.headers as owned (String, String) pairs (with_header(name, value) accepts dynamic values) and are applied by the Actix adapter.

Streaming helpers return HttpResponse directly. Handlers typically return Result<HttpResponse> (which already implements IntoCaelixResponse as identity):

#![allow(unused)]
fn main() {
use caelix::{Bytes, HttpResponse, Response, Result, StreamExt};

#[get("/export")]
async fn export_csv(&self) -> Result<HttpResponse> {
    let rows = self.repo.stream_all_users();
    let bytes_stream = rows.map(|row| {
        row.map(|r| Bytes::from(format!("{},{}\n", r.id, r.name)))
    });
    Ok(Response::stream("text/csv", bytes_stream))
}
}

StreamExt (for .map / .filter on streams) is re-exported from caelix so you do not need a direct futures-util dependency for the common helpers.

Server-Sent Events

Response::sse frames each stream item as JSON in SSE wire format (data: …\n\n) with content type text/event-stream, and sets Cache-Control: no-cache plus X-Accel-Buffering: no. It does not yet implement the full SSE protocol (id:, event:, retry:, Last-Event-ID resume):

#![allow(unused)]
fn main() {
#[get("/live-orders")]
async fn live_orders(&self) -> Result<HttpResponse> {
    let stream = self.events.subscribe::<OrderPlacedEvent>();
    Ok(Response::sse(stream))
}
}

File streaming

Response::file opens a path asynchronously and streams disk chunks (not the whole file in memory). Open errors are mapped by kind: missing path → 404 Not Found, permission denied → 403 Forbidden, other IO failures → 500 Internal Server Error.

#![allow(unused)]
fn main() {
#[get("/report.pdf")]
async fn report(&self) -> Result<HttpResponse> {
    Response::file("/var/data/report.pdf", "application/pdf").await
}
}

The Actix adapter maps buffered bodies with .body(...) and streaming bodies with .streaming(...) (chunked transfer encoding), and applies HttpResponse.headers. Mid-stream errors cannot change the already-sent status line; they close the stream after logging.

Exceptions

Use typed exception constructors for client errors:

#![allow(unused)]
fn main() {
return Err(NotFoundException::new("user not found"));
}

Most HTTP client and server status families have a matching exception constructor, for example BadRequestException, UnauthorizedException, ForbiddenException, ConflictException, UnprocessableEntityException, TooManyRequestsException, and ServiceUnavailableException.

Validation errors can include field details:

#![allow(unused)]
fn main() {
use std::collections::BTreeMap;

let mut errors = BTreeMap::new();
errors.insert("email".to_string(), vec!["must be a valid email".to_string()]);

return Err(BadRequestException::new("Validation failed").with_errors(errors));
}

Server error responses are production-safe: if an HttpException has a 5xx status, the response body message is Internal Server Error rather than the internal error text. Generated controller routes log returned 5xx exceptions through the ExceptionHandler logger, including the internal source when one is attached.

#![allow(unused)]
fn main() {
let user = repository.find(id).await?;
}

caelix::Result<T> accepts ? from any error convertible into anyhow::Error. This includes anyhow::Result, standard errors such as std::io::Error, and conventional third-party errors that are Send, Sync, and 'static. Unexpected errors become 500 Internal Server Error exceptions. Use a typed Caelix exception when the failure should produce a deliberate non-500 response.

#![allow(unused)]
fn main() {
async fn load_config() -> Result<String> {
    let config = tokio::fs::read_to_string("config.toml").await?;
    Ok(config)
}
}

The original error and its context chain are retained on HttpException::source, but the client receives only:

{
  "status": 500,
  "error": "Internal Server Error",
  "message": "Internal Server Error"
}

Cookies

Cookie::new defaults to HttpOnly, Secure, SameSite::Lax, and Path=/. These defaults reduce script access, accidental cleartext transport, and common cross-site request risks. Local plain-HTTP development can explicitly use .secure(false); production cookies should normally retain Secure.

#![allow(unused)]
fn main() {
let response = Response::Body(user)
    .with_cookie(Cookie::new("session", opaque_session_token))
    .with_cookie(Cookie::new("theme", "dark").http_only(false));
}

Cookies can also be attached to raw, file, streaming, and SSE HttpResponse values with .with_cookie(...). Every cookie becomes its own Set-Cookie header and call order is preserved.

To log out, match the original path and domain:

#![allow(unused)]
fn main() {
Response::no_content().with_cookie(
    Cookie::removal("session").path("/").domain("example.com"),
)
}

Caelix does not maintain a cookie jar or create, persist, rotate, sign, encrypt, or resolve sessions. Applications should use opaque random session tokens and validate them in their own session service. CSRF token generation and verification are separate concerns.

Guards And Interceptors

Guards decide whether a request may continue.

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{BoxFuture, Guard, RequestContext, Result, UnauthorizedException, guard};

#[guard]
pub struct AuthGuard {
    auth: Arc<AuthService>,
}

impl Guard for AuthGuard {
    fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>> {
        Box::pin(async move {
            let Some(token) = ctx.bearer_token() else {
                return Ok(false);
            };

            let user = self.auth
                .verify(token)
                .await
                .map_err(|_| UnauthorizedException::new("invalid token"))?;

            ctx.set(CurrentUser { id: user.id });
            Ok(true)
        })
    }
}
}

#[guard] uses the same expansion as #[injectable], so named fields must be Arc<T>.

Attach guards at the controller or method level:

#![allow(unused)]
fn main() {
use caelix::{Result, controller};

#[controller("/admin")]
#[use_guard(AuthGuard)]
impl AdminController {
    #[get("")]
    async fn index(&self) -> Result<&'static str> {
        Ok("ok")
    }
}
}

Method-level guards are appended after controller-level guards. Guards run before extractors are passed to the controller method. If a guard returns Ok(false), the generated wrapper returns 403 Forbidden with message Access denied. If it returns Err(HttpException), that exception is returned to the client.

Guards and interceptors follow normal module visibility rules. Register them as providers, export them from the module that owns them, and import that module wherever a controller uses them. A global module makes only its explicitly exported guards and interceptors available everywhere.

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};

impl Module for AuthModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider::<AuthGuard>()
            .export::<AuthGuard>()
    }
}

impl Module for AdminModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<AuthModule>()
            .controller::<AdminController>()
    }
}
}

Request Context Enrichment

Guards are a good place to authenticate and attach typed request state:

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CurrentUser {
    pub id: i64,
}

impl Guard for AuthGuard {
    fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>> {
        Box::pin(async move {
            let Some(token) = ctx.bearer_token() else {
                return Ok(false);
            };

            let claims = self.auth.verify(token).await?;
            ctx.set(CurrentUser { id: claims.sub })?;
            Ok(true)
        })
    }
}
}

Controllers can then request CurrentUser with #[user].

Interceptors wrap the converted HttpResponse.

#![allow(unused)]
fn main() {
use caelix::{BoxFuture, HttpResponse, Interceptor, Next, RequestContext, Result, injectable};

#[injectable]
pub struct AuditInterceptor;

impl Interceptor for AuditInterceptor {
    fn intercept<'a>(&'a self, ctx: &'a RequestContext, next: Next<'a>) -> BoxFuture<'a, Result<HttpResponse>> {
        Box::pin(async move {
            let response = next.run().await?;
            println!("{} {} -> {}", ctx.method(), ctx.path(), response.status);
            Ok(response)
        })
    }
}
}

Interceptors can also transform the response:

#![allow(unused)]
fn main() {
use caelix::HttpResponse;

#[injectable]
pub struct HeaderInterceptor;

impl Interceptor for HeaderInterceptor {
    fn intercept<'a>(
        &'a self,
        _ctx: &'a RequestContext,
        next: Next<'a>,
    ) -> BoxFuture<'a, Result<HttpResponse>> {
        Box::pin(async move {
            let mut response = next.run().await?;
            if response.content_type == "application/json" {
                if let Some(body) = response.body.as_buffered_mut() {
                    body.extend_from_slice(b"\n");
                }
            }
            Ok(response)
        })
    }
}
}

Body transforms apply only to buffered responses. Streaming bodies (ResponseBody::Streaming) are opaque after the handler returns — interceptors can still change status or content type, but should not assume body_bytes() is present.

HttpResponse stores status, body, content type, and a simple list of owned header name/value pairs (headers: Vec<(String, String)>). Use with_header when chaining builders, or insert_header to mutate an existing response (typical in interceptors). That is enough for dynamic values such as Content-Disposition filenames or X-Request-Id. It is not a full typed HeaderMap (no multi-value merge helpers, no typed header enums). Use native Actix middleware when you need richer header-level response rewriting.

#![allow(unused)]
fn main() {
let mut response = next.run().await?;
response.insert_header("X-Request-Id", request_id);
Ok(response)
}

Execution Order

For each request, the generated wrapper:

  1. Builds RequestContext from method, path, and headers.
  2. Runs controller-level guards in listed order.
  3. Runs method-level guards in listed order.
  4. Resolves the controller.
  5. Builds the interceptor chain from controller-level then method-level interceptors.
  6. Extracts handler arguments and calls the method inside the innermost Next.
  7. Converts the handler return value into HttpResponse.
  8. Runs interceptors back outward.

Interceptors run in onion order. The first listed interceptor sees the request first and the response last.

Request Context

RequestContext contains the request method, path, correlation identifiers, headers, bearer token helper, and typed extensions.

#![allow(unused)]
fn main() {
let method = ctx.method();
let path = ctx.path();
let request_id = ctx.request_id();
let trace_id = ctx.trace_id();
let token = ctx.bearer_token();
}

Header lookup is case-insensitive. Caelix lowercases header names when the context is created. An incoming X-Request-Id is retained when valid; otherwise Caelix generates a UUID. trace_id() reads the W3C traceparent trace ID, then X-Trace-Id, and finally falls back to the request ID. Generated controller responses include both identifiers as response headers.

bearer_token() reads the Authorization header and strips the exact Bearer prefix:

Authorization: Bearer eyJhbGciOi...

Guards and interceptors can attach typed values:

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct CurrentUser {
    pub id: i64,
}

ctx.set(CurrentUser { id: 42 })?;
}

Typed values are stored by concrete Rust type. A later ctx.set::<CurrentUser>(...) replaces the earlier value for that type.

Guards and interceptors can read values back:

#![allow(unused)]
fn main() {
if let Some(user) = ctx.get::<CurrentUser>()? {
    tracing::info!("user {}", user.id);
}
}

Controllers can read a typed value with #[user]:

#![allow(unused)]
fn main() {
#[get("/me")]
pub async fn me(&self, #[user] user: CurrentUser) -> Result<String> {
    Ok(user.id.to_string())
}
}

If the typed value is missing, the generated wrapper returns 401 Unauthorized with message Not authenticated.

#[user] clones the value out of the context, so user types must be cloneable when used this way.

Cookies

Incoming cookies are available with ctx.cookie("session"). Parsing, controller extraction, response cookies, and session security boundaries are documented in Cookies and Sessions.

Lifecycle Hooks

Injectable has default lifecycle hooks:

#![allow(unused)]
fn main() {
fn on_module_init(&self) -> BoxFuture<'_, Result<()>>
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>>
fn on_shutdown(&self) -> BoxFuture<'_, Result<()>>
}

The #[injectable] macro uses the default no-op hooks. Implement Injectable manually when a provider needs custom lifecycle behavior.

#![allow(unused)]
fn main() {
use std::sync::atomic::{AtomicBool, Ordering};

use caelix::{BoxFuture, Container, Injectable, ProviderDependency, Result, provider_dependencies};

pub struct Worker {
    started: AtomicBool,
}

impl Injectable for Worker {
    fn dependencies() -> Vec<ProviderDependency> {
        provider_dependencies![]
    }

    fn create(_container: &Container) -> BoxFuture<'_, Result<Self>> {
        Box::pin(async {
            Ok(Self {
                started: AtomicBool::new(false),
            })
        })
    }

    fn on_module_init(&self) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            // Run after this provider is constructed, during registration.
            Ok(())
        })
    }

    fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            self.started.store(true, Ordering::SeqCst);
            Ok(())
        })
    }

    fn on_shutdown(&self) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            self.started.store(false, Ordering::SeqCst);
            Ok(())
        })
    }
}
}

When Hooks Run

  • on_module_init runs when an injectable provider is registered.
  • on_bootstrap runs after module provider validation and event handler registration.
  • on_shutdown runs when Application::listen exits or when startup reaches a bind failure after the application was built.

Controllers are providers, so controller types can define lifecycle hooks when they implement Injectable manually.

Failures

Lifecycle failures are converted into startup or shutdown errors that include the hook name and provider type. Application::new returns startup failures as caelix::Result<Application>.

#![allow(unused)]
fn main() {
fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>> {
    Box::pin(async {
        Err(caelix::ServiceUnavailableException::new("worker failed to start"))
    })
}
}

For 5xx exceptions, the client response body is sanitized. Startup errors are returned to the caller, and generated controller routes log returned 5xx exceptions server-side before sending the sanitized response.

Async Factory Limitation

Providers registered with .provider_async_factory::<T, _, _>(provider_dependencies![...], ...) are construction-only. Caelix stores no lifecycle callbacks for the concrete type, so factory providers use no-op on_module_init, on_bootstrap, and on_shutdown. If a provider needs lifecycle hooks, implement Injectable and register it with .provider::<T>().

Events

EventBus is opt-in. Import EventModule in a module that emits events or registers event handlers. Event handlers are normal injectable providers and must be registered as providers before being registered as event handlers.

#![allow(unused)]
fn main() {
#[derive(Clone)]
pub struct UserCreated {
    pub id: i64,
}

use std::sync::Arc;

use caelix::{
    BoxFuture, EventBus, EventHandler, EventModule, Module, ModuleMetadata,
    RegisterableEventHandler, Result, injectable,
};

#[injectable]
pub struct SendWelcomeEmail;

impl EventHandler<UserCreated> for SendWelcomeEmail {
    fn handle(&self, event: UserCreated) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            println!("created user {}", event.id);
            Ok(())
        })
    }
}

impl RegisterableEventHandler for SendWelcomeEmail {
    type Event = UserCreated;
}

#[injectable]
pub struct UsersService {
    events: Arc<EventBus>,
}

impl UsersService {
    pub async fn create(&self, input: CreateUserDto) -> Result<UserDto> {
        let user = UserDto {
            id: 1,
            email: input.email,
        };

        self.events.emit(UserCreated { id: user.id }).await?;
        Ok(user)
    }
}

pub struct UsersModule;

impl Module for UsersModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<EventModule>()
            .provider::<UsersService>()
            .provider::<SendWelcomeEmail>()
            .event_handler::<SendWelcomeEmail>()
    }
}
}

Use .event_handler_for::<Event, Handler>() when explicit event registration is clearer.

#![allow(unused)]
fn main() {
ModuleMetadata::new()
    .import::<EventModule>()
    .provider::<SendWelcomeEmail>()
    .event_handler_for::<UserCreated, SendWelcomeEmail>()
}

Emit events by resolving EventBus from a provider and calling emit.

#![allow(unused)]
fn main() {
let events = container.resolve::<EventBus>()?;
events.emit(UserCreated { id: 42 }).await?;
}

emit runs handlers registered for the event type in registration order. If a handler returns an error, emit stops and returns that error; later handlers for the same event are not run, and live subscribers do not receive the event. Only after all handlers succeed does emit fan out to stream subscribers.

Event payloads must be Clone + Send + Sync + 'static because the same event value may be passed to multiple handlers and broadcast subscribers.

Live subscriptions

EventBus::subscribe::<E>() returns an async Stream of events of type E and creates the broadcast channel for that type. Events emitted before the subscription are not replayed. Slow consumers may lag; lagged events are dropped (with a warning log) rather than blocking emit.

emit alone never allocates a broadcast channel — it only sends when someone has already called subscribe for that event type.

#![allow(unused)]
fn main() {
use caelix::{EventBus, HttpResponse, Response, Result};

#[get("/live-orders")]
async fn live_orders(&self) -> Result<HttpResponse> {
    let stream = self.events.subscribe::<OrderPlacedEvent>();
    Ok(Response::sse(stream))
}
}

Handlers registered with .event_handler / .event_handler_for still run on every emit. Live subscribers are an additional fan-out after successful handler processing.

Registration Forms

.event_handler::<H>() requires:

#![allow(unused)]
fn main() {
impl EventHandler<UserCreated> for SendWelcomeEmail { /* ... */ }

impl RegisterableEventHandler for SendWelcomeEmail {
    type Event = UserCreated;
}
}

.event_handler_for::<UserCreated, SendWelcomeEmail>() avoids the RegisterableEventHandler implementation:

#![allow(unused)]
fn main() {
ModuleMetadata::new()
    .provider::<SendWelcomeEmail>()
    .event_handler_for::<UserCreated, SendWelcomeEmail>()
}

Both forms still require the handler to be registered as a provider.

Microservices

Caelix uses the same dependency-injected module graph for HTTP applications and broker consumers. Handler metadata is transport-neutral; a running MicroserviceApplication selects NATS or Redis.

Select a transport

[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["microservices-nats"] }
serde = { version = "1", features = ["derive"] }

Use microservices-redis for Redis, or enable both when one binary needs both client option types. These features do not select an HTTP runtime.

NATS commands use Core NATS queue groups and events use JetStream. Redis commands and events use Streams; temporary Pub/Sub channels carry command replies. Both provide competing command consumers, durable at-least-once events, typed JSON envelopes, end-to-end deadlines, bounded concurrency, and graceful shutdown.

Complete service

use caelix::{
    MessageContext, MicroserviceApplication, Module, ModuleMetadata,
    NatsTransportOptions, Result,
};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct Sum { left: i64, right: i64 }

#[derive(Serialize)]
struct Total { value: i64 }

#[derive(Deserialize)]
struct AuditEvent { order_id: i64 }

#[caelix::injectable]
struct MathMessages;

#[caelix::microservice]
impl MathMessages {
    #[caelix::message_pattern("math.sum")]
    async fn sum(&self, #[caelix::payload] input: Sum) -> Result<Total> {
        Ok(Total { value: input.left + input.right })
    }

    #[caelix::event_pattern("audit.created")]
    async fn audit(
        &self,
        #[caelix::context] context: MessageContext,
        #[caelix::payload] event: AuditEvent,
    ) -> Result<()> {
        // Persist `(context.event_id(), event.order_id)` atomically in real code.
        let _ = (context.event_id(), event.order_id);
        Ok(())
    }
}

struct AppModule;
impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().microservice::<MathMessages>()
    }
}

#[caelix::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
    let options = NatsTransportOptions::new("nats://127.0.0.1:4222")
        .service_name("math")
        .jetstream_stream("CAELIX_EVENTS");
    MicroserviceApplication::<AppModule>::new(options).await?.run().await?;
    Ok(())
}

.microservice::<T>() registers T as a normal provider, so constructor injection and lifecycle hooks work unchanged. Startup builds the container, collects handler definitions, validates topology, connects, and subscribes.

Continue with Handlers and Client, then the NATS or Redis transport guide. Production concerns and tests are in Operations, Testing, and Interoperability.

Microservice Handlers and Client

#[microservice] applies to an inherent impl of an injectable class. #[message_pattern("subject")] declares request/reply; #[event_pattern] declares an event. Subjects are non-empty dot-separated tokens. Commands cannot use wildcards; events may use a whole-token * or terminal >.

Handlers must be non-generic async fn methods with &self, exactly one named #[payload] parameter, and at most one #[context] MessageContext in either argument order. Payloads implement DeserializeOwned + Send. Commands return Result<T> where T: Serialize + Send; events return Result<()>.

MessageContext exposes subject, application headers, command correlation_id, propagated deadline, shutdown cancellation_token, durable delivery metadata, one-based delivery_attempt, and stable event event_id. Use event_id as a deduplication key.

Typed client

#![allow(unused)]
fn main() {
let client = MicroserviceClient::connect(
    NatsTransportOptions::new("nats://127.0.0.1:4222")
        .service_name("orders"),
).await?;

let order: Order = client.request("orders.create", input).await?;
client.emit("orders.created", event).await?;
}

MicroserviceClient is also registered in the microservice container, so providers can inject Arc<MicroserviceClient>. request<P, R> serializes a typed payload, applies rpc_timeout as an end-to-end deadline, and decodes a typed response. emit<P> publishes a durable event and returns after broker acceptance, not handler completion.

Current public request methods create the envelope headers internally; handlers can read propagated headers from MessageContext, but the typed client does not currently expose a per-request header builder.

Client failures are categorized as Timeout, NoResponder, Decode, Protocol, Transport, or Remote(RemoteError). A remote error contains a stable code, safe message, optional safe JSON details, and a retryable flag. Internal exception sources and unsafe server messages never cross the broker.

NATS Microservices

Commands subscribe through Core NATS queue groups. All replicas with the same service_name compete for each command. Events publish to JetStream and use a durable consumer per service, so every service group receives an event and one replica in each group handles it.

#![allow(unused)]
fn main() {
let options = NatsTransportOptions::new("nats://127.0.0.1:4222")
    .service_name("billing")
    .jetstream_stream("CAELIX_EVENTS")
    .dead_letter_subject("caelix.dead");
}

service_name is required when command handlers exist. jetstream_stream is required for event topology and names an existing stream whose subjects cover the event patterns.

Options

MethodDefaultMeaning
new(server)requiredNATS server URL
service_name(name)nonestable command queue group and event consumer identity
rpc_timeout(duration)5 scommand request deadline
jetstream_stream(name)nonedurable event stream
dead_letter_subject(subject)nonefinal-failure event destination
max_request_bytes(n)1 MiBencoded command envelope limit; minimum 1
max_response_bytes(n)1 MiBencoded response limit; minimum 256
max_event_bytes(n)1 MiBencoded event envelope limit; minimum 1
max_handler_concurrency(n)64simultaneous invocations per subscription; minimum 1
shutdown_timeout(duration)10 swait for transport tasks
max_event_deliveries(n)5attempt that dead-letters; minimum 1
event_retry_delay(duration)1 sdelayed NAK interval
event_ack_wait(duration)30 sunacknowledged redelivery wait; minimum 10 ms

Commands are ordinary NATS request/reply messages and require an active responder. Events are acknowledged only after a successful handler. Retryable failures are negatively acknowledged with the configured delay. At the delivery limit, a configured dead-letter subject receives the event and safe failure metadata; otherwise the transport terminates the retry cycle according to its consumer handling.

Keep the JetStream stream durable and size/age-limited according to broker operations. During shutdown, Caelix cancels intake, waits up to shutdown_timeout for tasks, then runs module shutdown hooks.

Redis Microservices

Redis 6.2 or newer is required because recovery uses Streams consumer-group commands including automatic claiming. Each command subject maps to a deterministic Stream. The client subscribes to a unique temporary Pub/Sub reply channel before appending the command. Events share a configured Stream.

Replicas with the same service_name share consumer groups. One replica handles each command; each service group receives every event, with one replica handling it inside that group.

#![allow(unused)]
fn main() {
let options = RedisTransportOptions::new("redis://127.0.0.1/")
    .service_name("billing")
    .event_stream("caelix:events")
    .dead_letter_stream("caelix:dead");
}

Options

MethodDefaultMeaning
new(server)requiredRedis URL
service_name(name)nonerequired stable consumer-group identity
event_stream(key)caelix:eventsshared event Stream
dead_letter_stream(key)nonedistinct final-failure Stream
rpc_timeout(duration)5 scomplete request/reply deadline
max_request_bytes(n)1 MiBcommand envelope limit; minimum 1
max_response_bytes(n)1 MiBresponse envelope limit; minimum 256
max_event_bytes(n)1 MiBevent envelope limit; minimum 1
max_handler_concurrency(n)64concurrent handler bound; minimum 1
shutdown_timeout(duration)10 sgraceful task deadline
max_event_deliveries(n)5final delivery attempt; minimum 1
event_retry_delay(duration)1 sidle interval before event reclaim; minimum 10 ms
command_recovery_delay(duration)30 sidle interval before abandoned command reclaim; minimum 10 ms
approximate_max_event_entries(n)unlimitedopt-in MAXLEN ~ event trimming

An expired command is acknowledged without invoking user code. Pending commands from a failed replica are reclaimed after command_recovery_delay; handlers must therefore be idempotent even though a healthy request normally executes once. Events are at least once and retry through pending-entry recovery.

Event retention is unlimited unless trimming is enabled. Approximate trimming can delete entries still pending in a slower service group; set it only with a retention policy covering the slowest consumer. A dead-letter Stream must be non-empty and differ from the event Stream. Shutdown stops intake, drains within the configured timeout, and runs lifecycle hooks.

Microservice Operations, Testing, and Interoperability

Reliability

Broker delivery is at least once after consumer failure. Make command writes and event effects idempotent. Store MessageContext::event_id() with the business transaction and reject an already-committed ID. For commands, use a business idempotency key in the payload because correlation IDs identify attempts, not business operations.

Concurrency is bounded per subscription by max_handler_concurrency; also size database pools and downstream limits. Respect the propagated deadline and cancellation_token() in long-running work. Shutdown stops intake, waits for tasks, then runs normal provider/module hooks.

Remote failures are sanitized. Log internal exception sources on the service; return stable public codes and safe details to callers. Monitor broker connectivity, request latency/timeouts, no-responder errors, pending/redelivered events, delivery attempts, dead-letter destinations, handler saturation, and shutdown timeouts.

Tests

Handler metadata and invocation can be tested without a broker:

#![allow(unused)]
fn main() {
let container = caelix::build_container::<AppModule>().await?;
let handler = OrdersMessages::definition()
    .handlers()
    .into_iter()
    .find(|handler| handler.pattern == "orders.create")
    .unwrap();
let result = handler.invoke(&container, context, serde_json::json!({"id": 1})).await?;
}

Use broker-backed integration tests for envelopes, queue/consumer competition, deadlines, reconnection, retry, dead letters, and shutdown. Run isolated NATS with JetStream or Redis 6.2+, use unique stream/subject prefixes, start MicroserviceRuntime, create a real MicroserviceClient, and always call shutdown(). The repository integration tests and event probes cover the supported transports.

NestJS interoperability

Maintained executable examples live in examples/nestjs-nats-interoperability and examples/nestjs-redis-interoperability. They are the authoritative cross-framework demonstrations.

Caelix uses its own versioned JSON envelope for correlation IDs, deadlines, headers, typed payloads, safe failures, and event IDs. NestJS’s stock transport serializer is therefore not wire-compatible by itself; the examples install the matching custom envelope adapter. Keep that adapter aligned whenever the protocol version changes.

WebSockets

Caelix supports two different real-time protocols:

  • RFC 6455 WebSockets, exposed through caelix::websocket and available with both the Actix and Axum runtimes.
  • Socket.IO, exposed through caelix::socket_io and available only when the Axum-selecting socketio feature is enabled.

They share Caelix’s gateway registration model, but they are not interchangeable. An RFC 6455 client speaks raw WebSocket frames and receives text or binary messages. A Socket.IO client speaks the Socket.IO protocol and can use events, acknowledgements, namespaces, and rooms.

RFC 6455 WebSocket gateways

The gateway API below is runtime-neutral. The same WebSocketGateway implementation works with Actix and Axum; each runtime mounts the registered #[gateway] path as a WebSocket upgrade route. Runtime-specific setup is covered in the Axum WebSocket support section below.

Define a gateway

A WebSocket gateway is an injectable provider that implements WebSocketGateway. The #[gateway("/path")] attribute supplies the route; the module explicitly registers the gateway just like a controller or service.

This gateway echoes text and binary messages and sends a greeting after the handshake:

#![allow(unused)]
fn main() {
use std::sync::Arc;

use caelix::{
    gateway, injectable, BoxFuture, Bytes, Module, ModuleMetadata, Result,
};
use caelix::websocket::{WebSocketGateway, WebSocketRequest, WebSocketSession};

#[injectable]
struct ChatGateway;

#[gateway("/chat")]
impl WebSocketGateway for ChatGateway {
    fn on_connect(
        &self,
        session: Arc<WebSocketSession>,
        request: WebSocketRequest,
    ) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            println!("client {} connected to {}", session.id(), request.path());
            session.send_text("connected").await
        })
    }

    fn on_text(
        &self,
        session: Arc<WebSocketSession>,
        text: String,
    ) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            session.send_text(format!("received text: {text}")).await
        })
    }

    fn on_binary(
        &self,
        session: Arc<WebSocketSession>,
        data: Bytes,
    ) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move { session.send_binary(data).await })
    }
}

struct ChatModule;

impl Module for ChatModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().gateway::<ChatGateway>()
    }
}
}

The BoxFuture return type is part of the framework-neutral callback trait. For a callback that does not need to perform asynchronous work, return a boxed async block:

#![allow(unused)]
fn main() {
fn on_text(
    &self,
    _session: Arc<WebSocketSession>,
    _text: String,
) -> BoxFuture<'_, Result<()>> {
    Box::pin(async { Ok(()) })
}
}

Gateways are ordinary injectable providers. Constructor injection, imported modules, provider lifecycle hooks, and explicit module registration work the same way as they do for the rest of Caelix:

#![allow(unused)]
fn main() {
struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().import::<ChatModule>()
    }
}
}

Register a gateway in either an application module or an imported module, but do not register the same gateway path twice in the module tree.

Callback lifecycle

WebSocketGateway provides five callbacks. All except on_error and on_close can return a caelix::Result<()> through BoxFuture.

CallbackWhen it runsTypical use
on_connectAfter the WebSocket handshakeInspect the request, initialize state, send a welcome message
on_textFor each complete text messageDecode commands or JSON text and send a response
on_binaryFor each complete binary messageProcess bytes or forward a binary payload
on_errorWhen a callback fails or the transport reports a protocol errorLog the failure and collect metrics
on_closeOnce when the connection endsRelease application-level resources

Fragmented frames are reassembled before on_text or on_binary runs. Ping frames receive an automatic pong reply; application code normally only needs to use session.ping(...) when it wants an explicit heartbeat.

A gateway can send messages at any time while the session is open:

#![allow(unused)]
fn main() {
fn send_heartbeat(
    &self,
    session: Arc<WebSocketSession>,
) -> BoxFuture<'_, Result<()>> {
    Box::pin(async move {
        session.ping(b"heartbeat").await?;
        session.send_text("server heartbeat").await
    })
}
}

WebSocketSession is cloneable and safe to share with tasks owned by the gateway. Use session.id() for a per-connection identifier and session.is_open() before scheduling work that may outlive a callback.

Reading request metadata

on_connect receives a WebSocketRequest containing the path, raw query string, optional peer address, and case-insensitive request headers:

#![allow(unused)]
fn main() {
fn on_connect(
    &self,
    session: Arc<WebSocketSession>,
    request: WebSocketRequest,
) -> BoxFuture<'_, Result<()>> {
    Box::pin(async move {
        let token = request
            .header("authorization")
            .or_else(|| request.header("x-api-key"));

        println!(
            "{} connected with query {:?} and token present: {}",
            session.id(),
            request.query_string(),
            token.is_some()
        );

        Ok(())
    })
}
}

Browser WebSocket clients cannot set arbitrary handshake headers. For browser authentication, use a short-lived token in the query string or authenticate the normal HTTP request before issuing the upgrade. Server-side clients can usually send an Authorization header.

Closing and handling failures

Use WebSocketCloseFrame and WebSocketCloseCode for an application-initiated close:

#![allow(unused)]
fn main() {
use caelix::websocket::{WebSocketCloseCode, WebSocketCloseFrame};

fn stop_session(
    &self,
    session: Arc<WebSocketSession>,
) -> BoxFuture<'_, Result<()>> {
    Box::pin(async move {
        session
            .close(Some(WebSocketCloseFrame::new(
                WebSocketCloseCode::GoingAway,
                "server is shutting down",
            )))
            .await
    })
}
}

The most useful close codes are Normal (1000), GoingAway (1001), Policy (1008), MessageTooBig (1009), and Internal (1011). A callback error invokes on_error and closes the connection with 1011. A malformed frame invokes on_error and closes with Protocol (1002).

on_close receives an optional close frame when the runtime has one (for example, for a peer close or a handler/protocol close). It also runs when the transport disappears, even if there is no close frame. Keep cleanup in on_close rather than in only one of the message callbacks.

#![allow(unused)]
fn main() {
fn on_error(
    &self,
    session: Arc<WebSocketSession>,
    error: caelix::websocket::WebSocketError,
) -> BoxFuture<'_, ()> {
    Box::pin(async move {
        eprintln!("websocket {} failed: {error}", session.id());
    })
}

fn on_close(
    &self,
    session: Arc<WebSocketSession>,
    frame: Option<WebSocketCloseFrame>,
) -> BoxFuture<'_, ()> {
    Box::pin(async move {
        println!("websocket {} closed: {frame:?}", session.id());
    })
}
}

Limit message size

The default maximum complete message size is 1 MiB. Configure it before calling listen or into_router:

#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
    .await?
    .websocket_max_message_size(8 * 1024 * 1024)
    .into_router();
}

The limit applies to the assembled text or binary message, not just one fragment. Keep it proportional to the memory available to the service, and prefer application-level limits for JSON fields or uploads that should be smaller than the transport limit.

Browser client

The browser’s built-in WebSocket client speaks RFC 6455 directly:

<script>
  const socket = new WebSocket(
    "ws://localhost:3000/chat?token=temporary-token"
  );
  socket.binaryType = "arraybuffer";

  socket.addEventListener("open", () => {
    socket.send("hello");
    socket.send(new Uint8Array([0, 1, 2, 255]));
  });

  socket.addEventListener("message", ({ data }) => {
    if (typeof data === "string") {
      console.log("text from server:", data);
    } else {
      console.log("binary from server:", new Uint8Array(data));
    }
  });

  socket.addEventListener("close", ({ code, reason }) => {
    console.log("closed", code, reason);
  });
</script>

The server’s on_text callback receives hello and on_binary receives the four bytes. WebSocket itself does not define event names or acknowledgements; if an application needs those, define a message format explicitly or use Socket.IO.

Axum WebSocket support

This section covers the Axum runtime adapter only. The gateway callbacks, session methods, close frames, and message semantics are the runtime-neutral API described above.

Enable Axum

Axum is selected explicitly because Actix is Caelix’s default runtime:

[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }

The axum feature and the actix feature are mutually exclusive. With this configuration, the gateway source above is mounted by Caelix’s Axum adapter. If an application selects the Actix feature instead, the same gateway source is mounted by the Actix adapter; it does not use Axum types.

Start an Axum WebSocket application

Application::new builds the container and validates gateway metadata. into_router mounts the regular HTTP routes and the WebSocket upgrade routes; listen does both steps for a production server:

use caelix::Application;

#[caelix::main]
async fn main() -> std::io::Result<()> {
    Application::new::<ChatModule>()
        .await
        .map_err(|error| std::io::Error::other(error.message))?
        .listen("127.0.0.1:3000")
        .await
}

The /chat route from the gateway example is now available as an RFC 6455 upgrade endpoint. The browser client connects to it with ws:// or wss://; it does not use Socket.IO framing.

Mount the Axum router yourself

If another Axum server owns the listener, obtain the fully configured router:

#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
    .await?
    .into_router();

let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
}

into_router includes Caelix controller routes, the WebSocket gateway routes, the configured body limit, and the fallback response. Calling it is the Axum-specific escape hatch for embedding Caelix in a larger Axum application.

Add Axum and Tower middleware

The application exposes Axum’s Tower-layer boundary. Middleware added before into_router applies to the HTTP upgrade request and to the resulting Axum router:

#![allow(unused)]
fn main() {
use tower_http::trace::TraceLayer;

let app = Application::new::<ChatModule>()
    .await?
    .layer(TraceLayer::new_for_http())
    .into_router();
}

Use Axum/Tower middleware for concerns such as tracing, compression of normal HTTP responses, or request-level policy. WebSocket message authorization and connection cleanup belong in the gateway callbacks because they occur after the HTTP upgrade.

Configure the Axum message limit

Axum applies a default maximum complete WebSocket message size of 1 MiB. Set the limit before building the router:

#![allow(unused)]
fn main() {
let app = Application::new::<ChatModule>()
    .await?
    .websocket_max_message_size(8 * 1024 * 1024)
    .into_router();
}

The limit is for the assembled message, including fragmented messages. A message that exceeds it is rejected by the WebSocket transport rather than being delivered to on_text or on_binary as a partial payload.

Socket.IO support (Axum only)

Socket.IO is a higher-level protocol built on top of Engine.IO. It is useful when the client needs named events, acknowledgement callbacks, namespaces, rooms, and Socket.IO’s connection behavior. It is not a drop-in replacement for a raw WebSocket endpoint.

The Caelix integration is backed by socketioxide and selects Axum transitively:

[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["socketio"] }

This feature cannot be combined with the default Actix backend. It exposes caelix::socket_io::{SocketIoHandle, SocketRef, Data, AckSender} and the Application::with_socket_io::<AppModule>() method.

Define namespaces and events

For Socket.IO, put #[gateway("/namespace")] on an inherent implementation and annotate each async event method with #[on_message("event")]. A handler accepts either payload: T or socket: SocketRef, payload: T and returns Result<Reply>.

The payload type must be deserializable by Socket.IO. It can be a scalar such as String, or a structured type:

#![allow(unused)]
fn main() {
use caelix::{gateway, injectable, Module, ModuleMetadata, Result};
use caelix::socket_io::SocketRef;
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct ChatMessage {
    text: String,
}

#[injectable]
struct ChatGateway;

#[gateway("/chat")]
impl ChatGateway {
    #[on_message("echo")]
    async fn echo(&self, message: ChatMessage) -> Result<ChatMessage> {
        Ok(message)
    }

    #[on_message("join")]
    async fn join(&self, socket: SocketRef, room: String) -> Result<String> {
        socket.join(room);
        Ok("joined".to_owned())
    }

    #[on_message("announce")]
    async fn announce(&self, socket: SocketRef, message: ChatMessage) -> Result<String> {
        let _ = socket
            .within("general")
            .emit("chat-message", &message)
            .await;

        Ok("sent".to_owned())
    }
}

struct ChatModule;

impl Module for ChatModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().gateway::<ChatGateway>()
    }
}
}

The gateway path is a Socket.IO namespace, so this example listens on /chat. Add another gateway with #[gateway("/admin")] to register another namespace. Namespace paths must be distinct from one another, just as raw WebSocket gateway paths must be distinct.

If the server does not need the socket itself, omit the SocketRef argument:

#![allow(unused)]
fn main() {
#[on_message("ping")]
async fn ping(&self, value: String) -> Result<String> {
    Ok(format!("pong: {value}"))
}
}

Handlers must be async and must take one payload argument. The macro registers the Socket.IO Data<T> extractor for that argument and supplies an AckSender internally.

Start Socket.IO

Socket.IO is created during Application::new, but it is mounted only when with_socket_io is called. Call it before listen or into_router:

use caelix::Application;

#[caelix::main]
async fn main() -> std::io::Result<()> {
    Application::new::<ChatModule>()
        .await
        .map_err(|error| std::io::Error::other(error.message))?
        .with_socket_io::<ChatModule>()
        .listen("127.0.0.1:3000")
        .await
}

with_socket_io::<ChatModule>() visits the module tree, registers every Socket.IO gateway with its namespace, and attaches the Socket.IO Tower layer. Calling it twice on the same application is an error. A Socket.IO gateway is not mounted by the raw RFC 6455 route builder; it needs this explicit step.

Acknowledgements and errors

When a client supplies an acknowledgement callback, a successful handler return is sent to that callback. The return value is still useful without an acknowledgement callback: the handler runs normally, but there is no reply to deliver.

For a handler that returns an error, Caelix serializes this shape when an ack was requested and emits the same value through the socket’s generic error event:

{
  "error": "Bad Request",
  "message": "invalid chat message"
}

For example, a validation handler can return a normal Caelix exception:

#![allow(unused)]
fn main() {
#[on_message("validate")]
async fn validate(&self, message: ChatMessage) -> Result<String> {
    if message.text.trim().is_empty() {
        return Err(caelix::BadRequestException::new("message is empty"));
    }

    Ok("valid".to_owned())
}
}

The error is also emitted as an event named error, so clients should attach an error listener when they need to observe failures independently of an ack.

Rooms and broadcasts

SocketRef exposes Socket.IO’s native room and namespace operations. Joining a room is synchronous; emitting to a room is asynchronous:

#![allow(unused)]
fn main() {
#[on_message("subscribe")]
async fn subscribe(&self, socket: SocketRef, room: String) -> Result<String> {
    socket.join(room.clone());
    Ok(format!("subscribed to {room}"))
}

#[on_message("broadcast")]
async fn broadcast(&self, socket: SocketRef, message: String) -> Result<String> {
    let _ = socket
        .within("announcements")
        .emit("announcement", &message)
        .await;

    Ok("broadcast complete".to_owned())
}
}

Use the room name supplied by the client only after applying the application’s authorization rules. A room is not an authorization boundary by itself.

Inject the Socket.IO server handle

SocketIoHandle is registered in the Caelix container before application providers are built. Services can inject it with the normal Arc<T> provider pattern:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{injectable, Module, ModuleMetadata};
use caelix::socket_io::SocketIoHandle;

#[injectable]
struct NotificationService {
    socket_io: Arc<SocketIoHandle>,
}

impl NotificationService {
    fn server(&self) -> &caelix::socket_io::SocketIo {
        self.socket_io.io()
    }
}

struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider::<NotificationService>()
            .gateway::<ChatGateway>()
    }
}
}

Use SocketRef inside a handler when the operation belongs to the current connection. Inject SocketIoHandle when a long-lived service needs access to the Socket.IO server while processing application events. Keep those services independent of a particular client socket where possible.

JavaScript client

Install a Socket.IO client, then connect to the namespace that matches the gateway path:

npm install socket.io-client
import { io } from "socket.io-client";

const socket = io("http://localhost:3000/chat", {
  transports: ["websocket"],
});

socket.on("connect", () => {
  socket.emit("echo", { text: "hello" }, (reply) => {
    console.log("echo acknowledgement:", reply);
  });

  socket.timeout(3000).emit("join", "general", (error, reply) => {
    if (error) console.error("join timed out", error);
    else console.log(reply);
  });
});

socket.on("chat-message", (message) => {
  console.log("message from the general room:", message);
});

socket.on("error", (error) => {
  console.error("server handler error:", error);
});

The io URL’s /chat suffix is the Socket.IO namespace and must match the Rust gateway path. It is not merely an HTTP route prefix. Socket.IO may use polling and then upgrade by default; forcing transports: ["websocket"] is optional and is useful when the deployment only permits WebSocket transport.

Test the integration

The Socket.IO integration is compiled only with its feature. Run its Rust compatibility test with:

cargo test -p caelix --no-default-features --features socketio --test socketio

The repository test uses the official socket.io-client package to verify namespace connections, acknowledgements, room broadcasts, and the error event. For an application test, start Application::new::<AppModule>(), call with_socket_io, use into_router() with an ephemeral listener, and connect with a real Socket.IO client so the transport and protocol are exercised together.

Choosing between the integrations

Use raw WebSockets when the protocol should be small and explicit, clients already use the browser WebSocket API, or the application only needs text and binary messages. Use Socket.IO when named events, acknowledgements, room broadcasts, or Socket.IO client compatibility are central requirements.

Both integrations use injectable gateways and explicit module registration. Only Socket.IO requires with_socket_io, and only Socket.IO gateways use #[on_message].

Service-Level Cache

Caelix cache support is explicit service-level caching. It does not add automatic HTTP response caching.

Import CacheModule and inject Cache into a service:

#![allow(unused)]
fn main() {
use std::{sync::Arc, time::Duration};

use caelix::{Cache, CacheModule, Module, ModuleMetadata, Result, injectable};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .import::<CacheModule>()
            .import::<UsersModule>()
    }
}

#[injectable]
pub struct UsersService {
    cache: Arc<Cache>,
}

impl UsersService {
    pub async fn find_cached(&self, id: i64) -> Result<Option<UserDto>> {
        let key = format!("users:{id}");
        if let Some(user) = self.cache.get(&key).await? {
            return Ok(Some(user));
        }

        let user = self.find(id).await?;
        if let Some(user) = &user {
            self.cache.set_with_ttl(&key, user, Duration::from_secs(60)).await?;
        }

        Ok(user)
    }
}
}

Available methods:

#![allow(unused)]
fn main() {
cache.get::<UserDto>("users:1").await?;
cache.set("users:1", &user).await?;
cache.set_with_ttl("users:1", &user, Duration::from_secs(60)).await?;
cache.set_with_optional_ttl("users:1", &user, Some(Duration::from_secs(60))).await?;
cache.delete("users:1").await?;
cache.clear().await?;
}

Values are serialized to serde_json::Value before storage and deserialized on get.

CacheModule registers MemoryCacheStore and Cache. MemoryCacheStore supports maximum entries, maximum serialized value size, and an optional default TTL through MemoryCacheOptions.

#![allow(unused)]
fn main() {
use std::time::Duration;

use caelix::{Cache, MemoryCacheOptions, MemoryCacheStore, Module, ModuleMetadata, provider_dependencies};

pub struct CacheConfigModule;

impl Module for CacheConfigModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider_async_factory::<MemoryCacheStore, _, _>(provider_dependencies![], |_container| async {
                Ok::<_, std::convert::Infallible>(
                    MemoryCacheStore::with_options(MemoryCacheOptions {
                        max_entries: 10_000,
                        max_value_bytes: 2 * 1024 * 1024,
                        default_ttl: Some(Duration::from_secs(300)),
                    }),
                )
            })
            .provider::<Cache>()
    }
}
}

When the cache reaches max_entries, the memory store removes the oldest inserted entries. Expired entries are removed during cache writes and missed on reads.

To use a custom backend, implement CacheStore and register Cache with a factory that constructs Cache::new(Arc<dyn CacheStore>).

#![allow(unused)]
fn main() {
use serde_json::Value;
use std::{sync::Arc, time::Duration};

use caelix::{BoxFuture, Cache, CacheStore, Module, ModuleMetadata, Result};

pub struct RedisCacheStore;

impl CacheStore for RedisCacheStore {
    fn get(&self, key: String) -> BoxFuture<'_, Result<Option<Value>>> {
        Box::pin(async move {
            // load JSON value by key
            Ok(None)
        })
    }

    fn set(&self, key: String, value: Value, ttl: Option<Duration>) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move {
            // write JSON value with optional TTL
            Ok(())
        })
    }

    fn delete(&self, key: String) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move { Ok(()) })
    }

    fn clear(&self) -> BoxFuture<'_, Result<()>> {
        Box::pin(async move { Ok(()) })
    }
}

pub struct RedisCacheModule;

impl Module for RedisCacheModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider_async_factory::<Cache, _, _>(provider_dependencies![], |_container| async move {
                let store: Arc<dyn CacheStore> = Arc::new(RedisCacheStore);
                Ok::<_, std::convert::Infallible>(Cache::new(store))
            })
    }
}
}

Application Runtime

caelix provides Application for production and TestApplication for in-process HTTP tests on both the Actix and Axum backends (see Testing). The Axum adapter is documented in Axum and Tower.

#![allow(unused)]
fn main() {
use caelix::Application;

Application::new::<AppModule>()
    .await
    ?
    .listen("127.0.0.1:8080")
    .await
}

Application::new returns startup failures as caelix::Result<Application>.

#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>().await?;
app.listen("127.0.0.1:8080").await?;
}

Startup builds the container from module metadata, logs controller routes, runs bootstrap hooks, and stores the adapter route registration function.

Body Limits and Upload Storage

The default JSON body limit is 1 MiB. Override it before listen:

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await
    ?
    .body_limit(2 * 1024 * 1024)
    .listen("127.0.0.1:8080")
    .await
}

The same limit applies to multipart controller routes. Oversized JSON or multipart bodies return 413 Payload Too Large; malformed JSON or multipart data returns 400 Bad Request. Configure the isolated directory used to stage multipart file parts with upload_temp_dir:

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await?
    .body_limit(10 * 1024 * 1024)
    .upload_temp_dir("/var/tmp/my-service/uploads")
    .listen("127.0.0.1:8080")
    .await?;
}

See Multipart Uploads for route-level upload limits, file ownership, and typed form binding.

Workers

Application starts the Actix server with one worker by default. Increase it before listen when the process should run multiple Actix workers:

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await
    ?
    .workers(4)
    .listen("127.0.0.1:8080")
    .await
}

Logging

The runtime logs startup, route mapping, listening, and shutdown. Application logging, levels, Actix access formats, buffering, and Axum differences are covered in the dedicated Logging guide.

Shutdown

Application::listen runs provider on_shutdown hooks after the Actix server exits. If binding the socket fails after startup has completed, shutdown hooks are also attempted before returning the bind error.

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await
    ?
    .listen("0.0.0.0:8080")
    .await
}

Shutdown hook errors are converted into std::io::Error after the server stops.

Actix Adapter Behavior

The adapter installs:

  • Shared Arc<Container> application data.
  • Shared request configuration with the Caelix body limit, normalized JSON and multipart errors, typed multipart DTO binding, temporary upload storage, and JSON body parsing for #[body] routes even when the client omits the Content-Type header.
  • Generated controller routes from module metadata.
  • A request logging wrapper, enabled only when access logging is explicitly requested.

Controller route wrappers are generated by #[controller]. Plain routes call the controller directly. Routes that use guards, interceptors, or #[user] build a RequestContext, run guards, run interceptors, call the controller method, convert the result to a Caelix response, then convert it to actix_web::HttpResponse.

Logging

Every container provides Logger. An Arc<Logger> field in an #[injectable] class is resolved specially with the class name as its scope:

#![allow(unused)]
fn main() {
#[caelix::injectable]
struct OrdersService {
    logger: Arc<Logger>,
}

self.logger.info("order accepted");
self.logger.warn("inventory is low");
self.logger.error("payment failed");
self.logger.debug("retry state updated");
}

Logger::new("scope"), Logger::for_type::<T>(), and context() are available for manual construction. CAELIX_LOG selects error, warn, info/log, or debug. If absent or invalid, Caelix reads an applicable caelix, caelix_core, or caelix-core directive from RUST_LOG, then defaults to debug. Framework startup, module/provider initialization, route mapping, readiness, shutdown, and internal server failures use the same logger.

Generated controller routes include correlation metadata on every logged server error:

[2026-07-26T14:32:08.190Z ERROR caelix::error] 500 Internal Server Error: Internal Server Error | source: database unavailable | method=GET path="/orders" request_id="9e285a20-9405-4cc8-8941-0f758d84f524" trace_id="4bf92f3577b34da6a3ce929d0e0e4736" service="orders-api" environment="production"

Caelix accepts X-Request-Id and X-Trace-Id, and understands the trace ID inside a W3C traceparent header. Missing request IDs are generated as UUIDs; invalid correlation headers are ignored. X-Request-Id and X-Trace-Id are included on the response so clients can report them.

Set CAELIX_SERVICE_NAME and CAELIX_ENVIRONMENT to identify the deployment. OTEL_SERVICE_NAME is also accepted for the service name; APP_ENV and ENVIRONMENT are environment fallbacks. Defaults are application and development.

Default startup output is intentionally compact:

[2026-07-26T14:32:08.184Z INFO  caelix::bootstrap] Building application
[2026-07-26T14:32:08.186Z INFO  caelix::routes] 3 routes registered across 1 controller
[2026-07-26T14:32:08.187Z INFO  caelix::server] Listening on http://127.0.0.1:3000 in 12ms

The ready event is written only after the server successfully binds. Individual module, provider, controller, and route events are included by default. Set CAELIX_LOG=info for compact startup output. In-process TestApplication compilation does not emit server startup events.

Actix access logs

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await?
    .logging(Logging::default())
    .listen("127.0.0.1:3000")
    .await?;
}

Logging::default() enables compact method, path, status, and duration logs. Logging::info() enables the detailed Actix-compatible format: peer address, request line/protocol, status, response size, referrer, user agent, and duration. Use .access_log(false) to disable middleware completely.

Explicit .logging(...) wins. If it is omitted, CAELIX_HTTP_LOG is checked, then the legacy CAELIX_ACCESS_LOG; recognized boolean values decide whether compact access logging is enabled. The default fallback is disabled.

Request workers enqueue access entries to a dedicated buffered writer. The queue capacity is 65,536; when full, new entries are dropped instead of blocking requests. Monitor dropped_http_request_logs(). Caelix reports drops to stderr at most once per second. Buffered responses report their byte count; a streaming response whose final size is unknown logs -.

Axum

Axum has the scoped application/framework Logger, but does not expose Actix’s Logging application configuration or install this access-log middleware. Attach a Tower HTTP tracing/access-log layer with Application::layer when Axum request logs are required.

Multipart Uploads

Caelix accepts file uploads alongside typed request DTOs on both supported runtimes. A controller method keeps its normal #[body] DTO and declares file parts independently with #[file] or #[files]. JSON clients and multipart clients can therefore use the same route whenever its file arguments are optional.

Typed fields and one required file

Use #[body] for the non-file fields and #[file] for one upload. Multipart text fields use typed Serde form decoding: strings, numbers, booleans, optional fields, and repeated collection fields are decoded into the DTO before the controller runs.

#![allow(unused)]
fn main() {
use caelix::{Response, Result, UploadedFile, controller, injectable};
use serde::Deserialize;
use serde_json::{Value, json};
use validator::Validate;

#[derive(Deserialize, Validate)]
struct DocumentInput {
    #[validate(length(min = 3))]
    title: String,
    #[validate(range(min = 1))]
    quantity: u32,
    published: bool,
    #[serde(default)]
    labels: Vec<String>,
}

#[injectable]
struct DocumentsController;

#[controller("/documents")]
impl DocumentsController {
    #[post("")]
    async fn create(
        &self,
        #[body] #[validate] input: DocumentInput,
        #[file] document: UploadedFile,
    ) -> Result<Response<Value>> {
        let contents = document.read_bytes().await?;
        Ok(Response::Body(json!({
            "title": input.title,
            "bytes": contents.len(),
        })))
    }
}
}

The file-part name defaults to the Rust parameter name. Rename it when the HTTP contract needs a different name:

#![allow(unused)]
fn main() {
#[file(name = "cover_image")] image: UploadedFile
}

File validation

#[file(...)] and #[files(...)] can validate each staged file before the handler runs. max_size accepts a whole number with B, KB, MB, GB, KiB, MiB, or GiB; decimal suffixes use powers of 1,000 and IEC suffixes use powers of 1,024. content_type is a comma-separated MIME allowlist.

#![allow(unused)]
fn main() {
#[post("/documents")]
async fn create(
    &self,
    #[file(
        name = "document",
        max_size = "10MiB",
        content_type = "application/pdf, image/png",
        validate = validate_document,
    )]
    document: UploadedFile,
) -> Result<Response<Document>> {
    Ok(Response::Body(self.documents.store(document).await?))
}
}

Caelix checks, in order: declared size, declared content type, then the controller validator. Size violations return 413 Payload Too Large; MIME violations and undetectable files return 400 Bad Request. MIME validation is secure by default: Caelix detects the type from the file’s magic bytes and does not trust a client filename or part header.

Set trust_content_type_header = true only when that header is part of your explicit trust boundary. It must accompany content_type; header parameters such as charset=utf-8 are ignored. This mode accepts the client declaration instead of inspecting file bytes, so it is unsafe for untrusted clients.

Validators must be controller methods with this exact shape:

#![allow(unused)]
fn main() {
async fn validate_document(&self, file: &UploadedFile) -> Result<()> {
    self.document_rules.validate(file).await
}
}

For reusable rules, inject a service into the controller and forward to it as above. A validator receives only its attached file; keep cross-field checks in the handler body after every extractor is available. Optional files skip all file checks when absent. Repeated files are checked in request order. If any check fails, Caelix drops the collected files and all remaining staged files, deleting their temporary paths before returning the error response.

#[validate] runs after JSON or multipart DTO decoding. A successful multipart decode that fails validation returns Caelix’s normal 400 Bad Request validation envelope, including its errors map.

Content negotiation

#[body] accepts the following content types:

Route argumentsAccepted request content typesFile argument result
DTO onlyapplication/json, omitted content type, multipart/form-dataNot applicable
DTO + required UploadedFilemultipart/form-dataRequired file must occur exactly once
DTO + Option<UploadedFile>JSON, omitted content type, multipart/form-dataJSON supplies None; multipart may supply one file
DTO + Vec<UploadedFile>multipart/form-dataRepeated file field, or an empty vector when absent
#[multipart] MultipartFormmultipart/form-dataFull form access

Other declared content types return 415 Unsupported Media Type. Invalid boundaries, malformed parts, duplicate values for a single-file extractor, and invalid typed form values return 400 Bad Request.

Optional and repeated files

Optional files allow the endpoint to retain a JSON workflow. They are None for JSON requests and when the named multipart part is not present:

#![allow(unused)]
fn main() {
#[post("/profile")]
async fn update(
    &self,
    #[body] input: UpdateProfile,
    #[file(name = "avatar")] avatar: Option<UploadedFile>,
) -> Result<Response<Profile>> {
    if let Some(avatar) = avatar {
        self.profiles.replace_avatar(avatar).await?;
    }
    Ok(Response::Body(self.profiles.update(input).await?))
}
}

Use Vec<UploadedFile> for repeated parts under one field name:

#![allow(unused)]
fn main() {
#[post("/attachments")]
async fn attach(
    &self,
    #[files(name = "attachment")] attachments: Vec<UploadedFile>,
) -> Result<Response<AttachmentSummary>> {
    // `attachments` is empty when the field is absent.
    Ok(Response::Body(self.files.store_all(attachments).await?))
}
}

Full-form access

Use #[multipart] MultipartForm when the controller needs direct control over repeated text or file fields instead of binding a DTO. It cannot be combined with #[body], #[file], or #[files] on the same route.

#![allow(unused)]
fn main() {
#[post("/import")]
async fn import(&self, #[multipart] mut form: MultipartForm) -> Result<Response<ImportResult>> {
    let mode = form.text("mode").and_then(|values| values.first());
    let files = form.take_files("source");
    Ok(Response::Body(self.importer.run(mode, files).await?))
}
}

text(name) and files(name) borrow the form. take_file(name) consumes one file and returns 400 Bad Request if that field has duplicates; take_files consumes every file with that name.

Uploaded file lifecycle

UploadedFile stages each file in Caelix’s isolated temporary directory. The handle exposes its multipart field name, client filename, optional content type, headers, byte size, and temporary path.

#![allow(unused)]
fn main() {
let bytes = upload.read_bytes().await?;
let destination = upload.persist_to("/srv/documents/report.pdf").await?;
}

These operations must happen in the opposite order when both are required: persist_to consumes the upload handle, so call read_bytes first. Temporary files are removed when their UploadedFile is dropped. persist_to requires the destination directory to exist and uses no-overwrite creation; it returns an error rather than replacing an existing file. Caelix supplies temporary upload handling only—applications choose permanent local, object-storage, or database storage themselves.

Limits and temporary storage

All multipart requests use Application::body_limit, which defaults to 1 MiB. Configure a larger application limit and an application-owned staging directory before listen:

#![allow(unused)]
fn main() {
Application::new::<AppModule>()
    .await?
    .body_limit(10 * 1024 * 1024)
    .upload_temp_dir("/var/tmp/my-service/uploads")
    .listen("127.0.0.1:3000")
    .await?;
}

Apply a stricter multipart limit to one route with #[upload(limit = ...)]:

#![allow(unused)]
fn main() {
#[upload(limit = 512 * 1024)]
#[post("/avatar")]
async fn avatar(&self, #[file] image: UploadedFile) -> Result<Response<Avatar>> {
    // ...
}
}

An overflow returns 413 Payload Too Large and includes the effective limit in the response message. Storage failures are logged with their details but expose only Caelix’s standard internal-server-error response to clients. TestApplication offers the same .body_limit(...) and .upload_temp_dir(...) configuration.

Upload support is opt-in. Enable the uploads feature when an application uses multipart extractor APIs; builds without it omit the multipart dependencies entirely.

Testing with curl

This request exercises typed DTO binding, repeated values, file metadata, and the normal validation path:

curl --fail-with-body \
  -F 'title=Quarterly report' \
  -F 'quantity=42' \
  -F 'published=true' \
  -F 'labels=rust' \
  -F 'labels=uploads' \
  -F 'document=@report.pdf;type=application/pdf' \
  http://127.0.0.1:3000/documents

Use raw multipart payloads through TestApplication::set_payload(...) when an integration test needs malformed-boundary or duplicate-file coverage. The Testing recipe shows the in-process request helpers.

Axum and Tower

Caelix has an Axum runtime adapter for applications that want Axum’s router, extractors, and Tower middleware ecosystem. The framework-level source stays the same: modules, providers, controllers, guards, interceptors, and route attributes are still registered through Caelix metadata.

This page focuses on what the Axum adapter does at the runtime boundary. For the protocol-specific APIs, see WebSockets and Socket.IO support.

Select the Axum backend

Actix is the default backend, so an Axum application disables default features and enables axum explicitly:

[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }

The actix and axum features are mutually exclusive. Most applications only need the caelix dependency and the #[caelix::main] macro. Add direct Axum and Tokio dependencies when your application calls native Axum APIs such as axum::serve or tokio::net::TcpListener:

[dependencies]
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }
axum = "0.8.8"
tokio = { version = "1.47.1", features = ["macros", "net", "rt-multi-thread"] }

Caelix’s generated controller code uses its own hidden Axum re-exports, so controllers do not need to import Axum types just to use #[controller]. Direct Axum dependencies are useful when embedding the returned router or adding native routes and extractors.

Minimal Axum application

Every Axum application starts with a module. The module registration model is the same as on Actix:

#![allow(unused)]
fn main() {
use caelix::{Module, ModuleMetadata};

pub struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
    }
}
}

The Caelix runtime owns the listener and server lifecycle when listen is used:

use caelix::Application;

#[caelix::main]
async fn main() -> std::io::Result<()> {
    Application::new::<AppModule>()
        .await
        .map_err(|error| std::io::Error::other(error.message))?
        .listen("127.0.0.1:3000")
        .await
}

Application::new performs the startup work before binding a TCP listener:

  1. Builds the dependency container from module metadata.
  2. Registers providers, controllers, gateways, and lifecycle handlers.
  3. Validates declarations and runs bootstrap hooks.
  4. Logs the module routes and returns an Axum Application.

If startup fails, the error is returned from Application::new and the server does not start. When listen returns after the server stops, Caelix runs shutdown hooks. If listener binding fails, it also attempts shutdown hooks before returning the bind error.

Configure the application

Application is a builder. Configure it before calling listen or into_router:

#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>()
    .await?
    .body_limit(2 * 1024 * 1024);

app.listen("127.0.0.1:3000").await?;
}

The default HTTP body limit is 1 MiB. It applies to body-consuming Caelix routes including JSON bodies and multipart uploads, and body_limit changes the limit in bytes. Configure upload_temp_dir(path) when multipart file staging must use an application-specific directory. The value is applied through Axum’s DefaultBodyLimit layer when the router is built.

WebSocket messages have their own limit. Configure that separately with websocket_max_message_size; see Axum WebSocket support.

Controllers and Axum extractors

Controller source does not change for Axum. Caelix maps its framework-neutral extractor attributes to Axum extractors internally:

Caelix attributeAxum extractorMeaning
#[param]axum::extract::Path<T>Route parameters such as {id}
#[query]axum::extract::Query<T>Query-string values
#[body]Caelix negotiated request wrapperJSON or typed multipart text fields
#[file]Caelix multipart file bindingOne required or optional UploadedFile
#[files]Caelix multipart file bindingRepeated Vec<UploadedFile> field
#[multipart]Caelix multipart form bindingDirect MultipartForm access
#[user]RequestContext lookupA typed value installed by a guard or interceptor

The controller method receives the inner value (T), not Path<T>, Query<T>, or Json<T>:

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
use caelix::{
    controller, injectable, Module, ModuleMetadata, Response, Result, StatusCode,
};

#[derive(Deserialize)]
struct CreateUser {
    name: String,
}

#[derive(Serialize)]
struct User {
    id: String,
    name: String,
}

#[injectable]
struct UsersController;

#[controller("/users")]
impl UsersController {
    #[get("/{id}")]
    async fn find(&self, #[param] id: String) -> Result<User> {
        Ok(User {
            id,
            name: "Ada".to_owned(),
        })
    }

    #[post("")]
    async fn create(&self, #[body] input: CreateUser) -> Result<Response<User>> {
        Ok(Response::WithStatus(
            StatusCode::CREATED,
            User {
                id: "new-user".to_owned(),
                name: input.name,
            },
        ))
    }
}

struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().controller::<UsersController>()
    }
}
}

The generated Axum handler also receives the shared container through State<Arc<Container>>. Application code normally does not see that state; Caelix resolves the controller and its dependencies before invoking the method.

Request context, guards, and interceptors

When a route uses a guard, interceptor, or #[user], Caelix builds a framework-neutral RequestContext from the Axum request method, path, and headers. The same guard and interceptor implementations can be used on both backends:

#![allow(unused)]
fn main() {
use caelix::{
    BoxFuture, Guard, RequestContext, Result, UnauthorizedException, guard,
};

#[guard]
struct ApiKeyGuard;

impl Guard for ApiKeyGuard {
    fn can_activate<'a>(
        &'a self,
        context: &'a RequestContext,
    ) -> BoxFuture<'a, Result<bool>> {
        Box::pin(async move {
            if context.header("x-api-key") == Some("development-key") {
                Ok(true)
            } else {
                Err(UnauthorizedException::new("invalid API key"))
            }
        })
    }
}
}

Attach it at controller or method level with #[use_guard(ApiKeyGuard)]. Controller-level guards run before method-level guards. Interceptors receive the same RequestContext and can transform the framework-neutral HttpResponse; they do not need to depend on Axum’s response type.

Extractor failures

Path and query extraction happen in Axum before the generated handler body runs. Caelix handles JSON and multipart body extraction through its shared negotiated wrapper, yielding the same 400, 413, and 415 response categories as the Actix adapter. A Caelix exception returned by the controller, guard, interceptor, or provider is converted through Caelix’s response adapter.

See Multipart Uploads for field binding, limits, validation, and file persistence.

Use #[validate] when the value was successfully deserialized but its fields need application-level validation:

#![allow(unused)]
fn main() {
use serde::Deserialize;
use validator::Validate;

#[derive(Deserialize, Validate)]
struct SearchUsers {
    #[validate(length(min = 1))]
    q: String,
}

#[get("/search")]
async fn search(
    &self,
    #[query] #[validate] query: SearchUsers,
) -> Result<Vec<User>> {
    // query.q has already passed validator::Validate::validate.
    Ok(Vec::new())
}
}

Responses in Axum

Controller return values are first converted to Caelix’s framework-neutral HttpResponse, then caelix::to_axum_response converts that response into an Axum Response.

Common controller returns include:

#![allow(unused)]
fn main() {
use caelix::{HttpResponse, Response, Result, StatusCode};

let json = Response::Body(value);                         // 200 JSON
let created = Response::WithStatus(StatusCode::CREATED, value);
let empty = Response::no_content();                       // 204
let text = Response::text(StatusCode::OK, "ready");
let bytes = Response::bytes(StatusCode::OK, data);
}

For streaming endpoints, return Result<HttpResponse> and use Response::stream, Response::sse, or Response::file. The Axum adapter turns buffered bodies into axum::body::Body directly and turns streaming bodies into Axum stream bodies. If a stream yields an error after the response has started, Caelix logs the error and ends the body; it cannot change the already-sent status code.

Native Axum response conversion

When an application adds a native Axum handler, it can still use Caelix’s response helpers and convert the result explicitly:

#![allow(unused)]
fn main() {
use caelix::{HttpResponse, StatusCode};

async fn native_health() -> axum::response::Response {
    caelix::to_axum_response(HttpResponse::text(StatusCode::OK, "ok"))
}
}

to_axum_response applies the Caelix content type and all response headers. This is useful when a native Axum route shares response construction with a Caelix controller or service.

Compose with an Axum router

into_router is the integration boundary. It returns a fully configured Axum router containing Caelix controller routes, WebSocket routes, body limits, and the Caelix JSON fallback for unmatched requests.

Use it when another component owns the listener:

#![allow(unused)]
fn main() {
let app = Application::new::<AppModule>()
    .await?
    .into_router();

let listener = tokio::net::TcpListener::bind("127.0.0.1:3000").await?;
axum::serve(listener, app).await?;
}

You can add native Axum routes after obtaining the router:

#![allow(unused)]
fn main() {
use axum::{Router, routing::get};

let app: Router = Application::new::<AppModule>()
    .await?
    .into_router()
    .route("/native", get(native_health));
}

Keep Caelix routes registered through controllers and modules. Add native routes for Axum-only integrations that do not need Caelix controller metadata. If a native handler needs shared application state, use Axum’s normal State extractor and merge it before serving, taking care to preserve Caelix’s router state requirements.

AxumRequestInfo

AxumRequestInfo is a parts-only extractor exposed by Caelix for native Axum handlers that need request metadata without consuming the body:

#![allow(unused)]
fn main() {
use caelix::AxumRequestInfo;

async fn request_info(info: AxumRequestInfo) -> String {
    format!("{} {}", info.method(), info.path())
}
}

It provides method(), path(), and headers(). Because it implements FromRequestParts, it can be combined with a body-consuming extractor in a native Axum handler. Caelix uses the same request information internally when it builds a RequestContext for guarded, intercepted, or authenticated controller routes.

Tower middleware

Application::layer accepts a compatible Tower layer and applies it to the underlying Axum router:

#![allow(unused)]
fn main() {
use caelix::Application;
use tower_http::{compression::CompressionLayer, trace::TraceLayer};

let app = Application::new::<AppModule>()
    .await?
    .layer(TraceLayer::new_for_http())
    .layer(CompressionLayer::new())
    .into_router();
}

This works with tracing, compression, CORS, request IDs, authentication, and rate-limiting layers from the Tower ecosystem. Add the relevant crate to your application:

[dependencies]
tower-http = { version = "0.6.8", features = ["compression-full", "trace"] }

Layers are attached to the router after Caelix has registered its routes, so they can observe normal controller requests and WebSocket upgrade requests. Message-level WebSocket authorization still belongs in the gateway; Tower middleware sees the HTTP upgrade boundary, not each WebSocket message.

A layer must satisfy the adapter’s service bounds: its service must return an Axum IntoResponse, use an error convertible to Infallible, and expose a Send future. If a third-party layer does not meet those bounds, wrap it in a compatible Tower service or install it after into_router using native Axum composition.

WebSockets and Socket.IO

Axum RFC 6455 gateways are registered with the same #[gateway] and ModuleMetadata::gateway APIs as Actix gateways. The Axum adapter mounts them when into_router or listen builds the application. See the dedicated Axum WebSocket support section for message callbacks, request metadata, close frames, message limits, and browser clients.

Socket.IO is an Axum-only optional feature. Enable it with features = ["socketio"], call with_socket_io::<AppModule>() before listen or into_router, and use the Socket.IO client protocol. See the Socket.IO guide for namespaces, events, acknowledgements, rooms, and client examples.

Testing Axum applications

Axum provides the same in-process TestApplication API as the Actix adapter. It builds the production container and routes without opening a TCP listener:

#![allow(unused)]
fn main() {
use caelix::{StatusCode, TestApplication};

#[caelix::test]
async fn health_route_works() {
    let app = TestApplication::new::<AppModule>().await.unwrap();
    let body = app
        .get("/health")
        .send()
        .await
        .unwrap()
        .assert_status(StatusCode::OK)
        .json::<serde_json::Value>()
        .await;

    assert_eq!(body["status"], "ok");
}
}

The builder supports override_provider, override_provider_factory, and body_limit. Request helpers include get, post, put, patch, and delete, with json, header, set_payload, and send; response helpers include status, assert_status, json, body, and text. Call app.shutdown().await when tests need provider shutdown hooks to run.

For lower-level Axum or Tower integration tests, you can still build the router directly and send requests with ServiceExt::oneshot:

[dev-dependencies]
http-body-util = "0.1.3"
tower = { version = "0.5.3", features = ["util"] }
#![allow(unused)]
fn main() {
use axum::{body::Body, http::Request};
use http_body_util::BodyExt;
use tower::ServiceExt;

#[tokio::test]
async fn health_route_works() {
    let app = caelix::Application::new::<AppModule>()
        .await
        .unwrap()
        .into_router();

    let response = app
        .oneshot(
            Request::builder()
                .uri("/health")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), axum::http::StatusCode::OK);
    let body = response.into_body().collect().await.unwrap().to_bytes();
    assert_eq!(body.as_ref(), br#"{\"status\":\"ok\"}"#);
}
}

For WebSocket behavior, start an ephemeral tokio::net::TcpListener, serve into_router(), and connect with a WebSocket client such as tokio-tungstenite. For Socket.IO behavior, use a real Socket.IO client so the Engine.IO handshake, namespace, acknowledgement, and room behavior are tested together.

Common integration patterns

Put shared state in providers

Prefer injectable services for database pools, repositories, clients, and configuration. Controllers resolve them from the Caelix container regardless of whether Axum or Actix is selected:

#![allow(unused)]
fn main() {
use std::sync::Arc;
use caelix::{injectable, Module, ModuleMetadata};

#[injectable]
struct UsersService {
    repository: Arc<UsersRepository>,
}

#[injectable]
struct UsersRepository;

struct AppModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new()
            .provider::<UsersRepository>()
            .provider::<UsersService>()
            .controller::<UsersController>()
    }
}
}

This keeps Axum-specific router wiring at the application boundary and keeps business logic portable across runtimes.

Use native Axum only at the boundary

Use native Axum routes or layers when an integration requires Axum types. Keep controller methods, providers, guards, and interceptors on Caelix abstractions when the code should remain portable. This gives one module graph and one dependency-injection model while still allowing Axum’s ecosystem at the edge.

Common Tasks

Create A Project

cargo install caelix-cli
caelix new demo-api

Generate A Feature

caelix g module greetings

Add the generated module to your app:

#![allow(unused)]
fn main() {
pub mod greetings;

use greetings::GreetingsModule;

impl Module for AppModule {
    fn register() -> ModuleMetadata {
        ModuleMetadata::new().import::<GreetingsModule>()
    }
}
}

Generate Pieces Separately

caelix g service users
caelix g controller users

If the service exists before the controller is generated, the controller injects it.

Convert Library Errors

#![allow(unused)]
fn main() {
let user = repository
    .find(id)
    .await
    .map_err(InternalServerErrorException::new)?;
}

For 5xx errors, the client response message stays generic.

Validate A Request Body

Derive Validate and mark the extractor:

#![allow(unused)]
fn main() {
#[post("")]
async fn create(&self, #[body] #[validate] input: CreateUser) -> Result<Response<UserDto>> {
    self.users.create(input).await
}
}

Emit Events After Writes

Import EventModule, inject or resolve EventBus in a service, perform the write, then emit a cloned event type.

#![allow(unused)]
fn main() {
ModuleMetadata::new()
    .import::<EventModule>()
    .provider::<UsersService>()
    .provider::<SendWelcomeEmail>()
    .event_handler::<SendWelcomeEmail>();

self.events.emit(UserCreated { id: user.id }).await?;
}

Cache Service Results Explicitly

Import CacheModule, inject Arc<Cache>, and call get, set, set_with_ttl, delete, or clear inside service methods.

Create a response with Response::Body(value).with_cookie(Cookie::new("session", signed)). Clear it with Cookie::removal("session"), matching the original path and domain. See Cookies and Sessions.

Call a Microservice

Inject Arc<MicroserviceClient> and call request::<Payload, Reply> or emit. See Handlers and Client.

Testing

Caelix supports two testing styles: unit tests against the DI container, and full in-process HTTP tests with TestApplication.

Unit tests

Build a Container, register dependencies, and call methods directly:

#![allow(unused)]
fn main() {
#[tokio::test]
async fn service_returns_user() -> caelix::Result<()> {
    let mut container = Container::new();
    container.register::<UsersService>().await?;

    let service = container.resolve::<UsersService>()?;
    let user = service.find(1).await?;

    assert_eq!(user.id, 1);
    Ok(())
}
}

You can also use build_container::<AppModule>() or build_container_with_overrides when you want the full module graph without HTTP.

Integration tests with TestApplication

TestApplication builds the same container and route table as production, then serves requests through the selected runtime’s in-memory service (no TCP listener). The API is the same when the Actix or Axum backend is selected.

#![allow(unused)]
fn main() {
use caelix::{StatusCode, TestApplication};

#[caelix::test]
async fn should_create_user() -> caelix::Result<()> {
    let app = TestApplication::new::<AppModule>().await?;

    let response = app
        .post("/users")
        .json(CreateUserDto {
            name: "Ronnie".into(),
            email: "ronnie@example.com".into(),
        })
        .send()
        .await?;

    response.assert_status(StatusCode::CREATED);
    Ok(())
}
}

#[caelix::test] runs the selected backend runtime, so it is suitable for TestApplication tests with either backend. It expands through Caelix’s hidden runtime re-exports, so a direct Actix or Axum runtime dependency is not needed just to use the test macro.

Request helpers

MethodPurpose
app.get/post/put/patch/delete(path)Start a request
.json(value)JSON body + content type
.header(name, value)Set a header
.set_payload(bytes)Raw body
.send().awaitExecute against the in-process app

Response helpers

MethodPurpose
.status()http::StatusCode
.assert_status(code)Panic on mismatch; returns self for chaining
.json::<T>().awaitDeserialize JSON body
.body().await / .text().awaitRaw bytes / UTF-8 text

Cookies

Send cookies with .header("cookie", "session=hello%20world; theme=dark"). Assert required/optional extraction and errors. For response cookies, inspect each Set-Cookie value separately and assert its security attributes and scope. See Cookies and Sessions.

Provider overrides

Swap a concrete provider (including ones registered in nested imports) before the container is built:

#![allow(unused)]
fn main() {
#[caelix::test]
async fn creates_user_without_database() -> caelix::Result<()> {
    let app = TestApplication::new::<AppModule>()
        .override_provider(UserRepository::in_memory())
        .await?;

    let response = app
        .post("/users")
        .json(CreateUserDto {
            name: "Ronnie".into(),
            email: "ronnie@example.com".into(),
        })
        .send()
        .await?;

    response.assert_status(StatusCode::CREATED);
    // resolve the same type the app injects
    let _repo = app.resolve::<UserRepository>()?;
    Ok(())
}
}

Overrides match by TypeId. The replacement must be the same concrete type T that modules register and inject as Arc<T>. Typical pattern: give the production type a test constructor (for example UserRepository::in_memory()) rather than a separate mock struct type.

You can also use .override_provider_factory(...) for async construction, .body_limit(n) to match Application::body_limit, and .upload_temp_dir(path) when an upload test needs an isolated staging directory.

Multipart tests use the raw payload helper with a boundary header:

#![allow(unused)]
fn main() {
let response = app
    .post("/uploads")
    .header("content-type", "multipart/form-data; boundary=test-boundary")
    .set_payload("--test-boundary\r\nContent-Disposition: form-data; name=\"title\"\r\n\r\nReport\r\n--test-boundary--\r\n")
    .send()
    .await?;
}

An override for a type that never appears in the module tree is a startup error.

Instance overrides use NestJS-style useValue semantics: declared lifecycle hooks (on_module_init, on_bootstrap, on_shutdown) for that type are skipped.

Shutdown

#![allow(unused)]
fn main() {
app.shutdown().await?;
}

Dropping a TestApplication without shutdown skips on_shutdown hooks. Call shutdown when your test cares about cleanup.

Microservice tests

Build the module container and invoke Microservice::definition().handlers() directly for typed handler tests. Use an isolated real broker with MicroserviceRuntime and MicroserviceClient for queue groups, recovery, deadlines, retries, dead letters, and shutdown. See Microservice Operations, Testing, and Interoperability.

CLI-generated projects

caelix new demo-api
cd demo-api
cargo test

Useful checks:

cargo test
cargo test -p caelix-actix
cargo check

Macro Attributes

#[injectable]

Applies to unit structs or named-field structs. Named fields must be Arc<T>.

#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersService;
}
#![allow(unused)]
fn main() {
#[injectable]
pub struct UsersController {
    service: Arc<UsersService>,
}
}

Expansion behavior:

  • Implements caelix::Injectable.
  • Resolves each Arc<T> field with container.resolve::<T>().
  • Uses container.resolve_logger(stringify!(TypeName)) for Arc<Logger>.
  • Leaves lifecycle hooks as default no-ops.

Invalid patterns:

#![allow(unused)]
fn main() {
#[injectable]
pub struct Bad(String); // tuple structs are rejected

#[injectable]
pub enum Bad { A } // non-struct items are rejected

#[injectable]
pub struct Bad {
    value: String, // named fields must be Arc<T>
}
}

#[guard]

Uses the same dependency injection expansion as #[injectable]. The type must implement Guard.

#[controller("/base")]

Applies to an impl block. Route methods may use:

  • #[get("...")]
  • #[post("...")]
  • #[patch("...")]
  • #[put("...")]
  • #[delete("...")]

Controller and method attributes:

  • #[use_guard(Type)]
  • #[use_interceptor(Type)]

Extractor attributes:

  • #[param]
  • #[query]
  • #[body]
  • #[file]
  • #[files]
  • #[multipart]
  • #[user]
  • #[cookie("name")]
  • #[validate]

Bodies and uploads

#[body] accepts application/json, omitted content type, and multipart/form-data. JSON and multipart text fields both decode into the declared type. Add #[validate] to run validator::Validate after either decode path.

#[file] name: UploadedFile requires exactly one named multipart file; use Option<UploadedFile> when it may be absent. #[files] files: Vec<UploadedFile> collects repeated file parts. File fields default to their argument name and accept name = "...", for example #[file(name = "avatar")] avatar: UploadedFile.

#[multipart] form: MultipartForm provides direct full-form access and is intentionally incompatible with #[body], #[file], and #[files] on the same route. Use #[upload(limit = bytes)] on a route to set a stricter multipart body limit than the application limit.

#[file]/#[files] additionally accept max_size, content_type, trust_content_type_header, and an async controller validate method. See Extractors.

Example:

#![allow(unused)]
fn main() {
#[controller("/users")]
#[use_guard(AuthGuard)]
#[use_interceptor(AuditInterceptor)]
impl UsersController {
    #[get("/{id}")]
    pub async fn find(&self, #[param] id: i64) -> Result<UserDto> {
        self.users.find(id).await
    }

    #[post("")]
    pub async fn create(
        &self,
        #[body] #[validate] input: CreateUserDto,
    ) -> Result<Response<UserDto>> {
        let user = self.users.create(input).await?;
        Ok(Response::WithStatus(StatusCode::CREATED, user))
    }
}
}

#[param] and #[query] use the selected runtime’s native path and query extractors. Body and upload routes use Caelix’s shared negotiated request wrapper so the same controller source works on Actix and Axum. #[user] reads RequestContext::get::<T>() and returns 401 Unauthorized if the typed value is missing.

Invalid extractor pattern:

#![allow(unused)]
fn main() {
#[get("/{id}")]
pub async fn find(&self, #[param] Some(id): Option<i64>) -> Result<String> {
    Ok(id.to_string())
}
}

Extractor arguments must be simple identifiers because the macro moves extracted values into the controller method call.

#[gateway("/path")]

Decorates a WebSocket or Socket.IO gateway implementation and supplies the explicit path metadata used by ModuleMetadata::gateway::<Gateway>().

For RFC 6455 sockets, place it on impl WebSocketGateway for Gateway; callback methods use the WebSocketGateway trait. This works with both Actix and Axum.

With the Axum-only socketio feature, place it on an inherent implementation and annotate each async event method with #[on_message("event")]. Such methods accept either payload: T or socket: SocketRef, payload: T and return Result<Reply>; successful replies become Socket.IO acks, while failures also emit "error".

#[cookie("name")]

Controller arguments may use String for a required cookie or Option<String> for an optional cookie. The name must be a non-empty string literal. Extraction reads the generated RequestContext, so the same controller source works with Actix and Axum.

OpenAPI markers

With openapi, controller expansion consumes #[response(...)], #[errors(...)], #[request_header(...)], and #[security(...)]. They declare successful/error responses, header parameters, and security requirements; they do not perform runtime authentication or extraction. Controller extractors, including cookies and uploads, also contribute request metadata.

#[upload(limit = bytes)]

This route marker requires uploads and supplies a stricter multipart limit than the application body limit. It is meaningful only on a controller route.

Microservice macros

#[microservice] decorates an inherent impl. Its methods use exactly one #[message_pattern("subject")] or #[event_pattern("subject")], one bare #[payload], and optionally one bare #[context] MessageContext.

Handlers must be async, non-generic, take &self, use named parameters, and return Result<T>. Payloads require DeserializeOwned + Send; command response types require Serialize + Send; events must return Result<()>. Command subjects reject wildcards. Event subjects accept whole-token * and terminal >. The macro generates Microservice metadata and resolves the handler class from the module container.

Public Traits

Module

#![allow(unused)]
fn main() {
pub trait Module {
    fn register() -> ModuleMetadata;
}
}

Injectable

#![allow(unused)]
fn main() {
pub trait Injectable: Send + Sync + 'static {
    fn create(container: &Container) -> BoxFuture<'_, Result<Self>>
    where
        Self: Sized;

    fn dependencies() -> Vec<ProviderDependency>
    where
        Self: Sized;

    fn on_module_init(&self) -> BoxFuture<'_, Result<()>>;
    fn on_bootstrap(&self) -> BoxFuture<'_, Result<()>>;
    fn on_shutdown(&self) -> BoxFuture<'_, Result<()>>;
}
}

Implement manually for owned state, custom construction, or lifecycle hooks. A manual implementation must declare every provider it resolves from create:

#![allow(unused)]
fn main() {
fn dependencies() -> Vec<ProviderDependency> {
    provider_dependencies![Database, Config]
}
}

Use provider_dependencies![] when it resolves no providers. The compiler requires this method, and Caelix rejects an undeclared container.resolve::<T>() while constructing the provider. #[injectable] supplies the declaration automatically for its Arc<T> fields; Arc<Logger> is excluded because it uses the scoped logger API rather than DI resolution.

Controller

Generated by #[controller] for normal use. Manual implementations register routes into an adapter-specific service config.

#![allow(unused)]
fn main() {
pub trait Controller {
    fn base_path() -> &'static str;
    fn routes() -> &'static [RouteDef] {
        &[]
    }
    fn register_routes(cfg: &mut dyn Any);
}
}

Implement manually only when writing adapter-specific route integration.

Guard

#![allow(unused)]
fn main() {
pub trait Guard: Send + Sync + 'static {
    fn can_activate<'a>(&'a self, ctx: &'a RequestContext) -> BoxFuture<'a, Result<bool>>;
}
}

Interceptor

#![allow(unused)]
fn main() {
pub trait Interceptor: Send + Sync + 'static {
    fn intercept<'a>(
        &'a self,
        ctx: &'a RequestContext,
        next: Next<'a>,
    ) -> BoxFuture<'a, Result<HttpResponse>>;
}
}

EventHandler<E>

#![allow(unused)]
fn main() {
pub trait EventHandler<E>: Send + Sync + 'static {
    fn handle(&self, event: E) -> BoxFuture<'_, Result<()>>;
}
}

Event types must be Clone + Send + Sync + 'static.

RegisterableEventHandler

#![allow(unused)]
fn main() {
pub trait RegisterableEventHandler: Injectable {
    type Event: Clone + Send + Sync + 'static;
}
}

Implement this to use .event_handler::<Handler>(). Use .event_handler_for::<Event, Handler>() when you want to avoid the marker implementation.

CacheStore

Custom cache backends implement get, set, delete, and clear over JSON values.

#![allow(unused)]
fn main() {
pub trait CacheStore: Send + Sync + 'static {
    fn get(&self, key: String) -> BoxFuture<'_, Result<Option<serde_json::Value>>>;
    fn set(
        &self,
        key: String,
        value: serde_json::Value,
        ttl: Option<std::time::Duration>,
    ) -> BoxFuture<'_, Result<()>>;
    fn delete(&self, key: String) -> BoxFuture<'_, Result<()>>;
    fn clear(&self) -> BoxFuture<'_, Result<()>>;
}
}

Implement manually for Redis, Memcached, database-backed caches, or test stores. Caelix does not provide automatic HTTP response caching.

Response conversion

IntoCaelixResponse converts controller return values into HttpResponse. Caelix implements it for its response wrappers and common serializable results. Implement it for an application response wrapper when centralized status, headers, cookies, or body conversion is needed.

Microservices

Microservice: Injectable is generated by #[microservice]; its definition returns provider and handler metadata. Do not implement MessageHandlerDef plumbing manually in normal applications.

Gateways

Gateway: Injectable supplies registration metadata and is normally generated by #[gateway]. WebSocketGateway: Injectable is the public RFC 6455 callback contract. Custom runtime transports may implement WebSocketTransport. RegisteredWebSocketGateway is a framework composition marker and should not be implemented directly. With socketio, SocketIoGateway is generated for annotated gateways.

Documentation and framework internals

OpenApiError is the public extension point for custom documented error types. Controller, Microservice, Gateway, RegisteredWebSocketGateway, and RegisterableEventHandler are primarily macro/registration metadata traits. Application authors normally implement behavior traits (Module, Injectable, Guard, Interceptor, EventHandler, CacheStore, WebSocketGateway, WebSocketTransport, IntoCaelixResponse, and custom OpenApiError) and let macros produce framework metadata.

Error Response Shape

HTTP exceptions serialize as JSON:

{
  "status": 404,
  "error": "Not Found",
  "message": "user not found"
}

Validation or structured client errors can include errors:

{
  "status": 422,
  "error": "Unprocessable Entity",
  "message": "invalid request",
  "errors": {
    "email": ["must be an email"]
  }
}

For 5xx statuses, Caelix hides the internal message from the response body:

{
  "status": 500,
  "error": "Internal Server Error",
  "message": "Internal Server Error"
}

Registration And Lifecycle Order

Module registration follows the metadata graph. Each module is discovered once; repeated imports share one provider graph and module-import cycles are startup errors.

  1. The complete module graph, exports, duplicates, and dependency metadata are validated.
  2. Providers are constructed in dependency order and initialized once.
  3. Event handlers are registered after their provider exists.
  4. on_bootstrap runs in dependency order.

on_module_init runs when an injectable provider is registered. on_shutdown runs once in reverse successful startup order. If startup fails, Caelix shuts down providers already initialized and preserves the original error. Shutdown continues after individual hook failures and returns the first such error.

Async factory providers are construction-only and use no-op lifecycle callbacks.

Controllers participate in dependency injection because registering a controller creates a provider definition for that controller type.

Visibility

Providers are visible to their declaring module, to direct importers only when explicitly exported, and through explicit exports of reachable global modules. An import does not expose a module’s private providers. Re-exporting is allowed only from a direct import.

Failures

Startup can fail for:

  • Missing providers declared in module metadata.
  • Missing event handler providers.
  • Missing EventModule import before resolving Arc<EventBus> or registering event handlers.
  • A dependency that is private, unexported, or declared by a module that was not imported.
  • A handwritten provider or factory resolving a type absent from its declared dependencies.
  • Duplicate production provider, factory, controller, or gateway registrations.
  • Async factory errors.
  • Lifecycle hook errors.
  • Dependency resolution failures during provider construction.

Application::new::<AppModule>() returns startup errors as caelix::Result<Application>, so callers can choose whether to propagate, map, or unwrap them.

Feature Flags

caelix defaults to actix. Disable defaults when selecting Axum or building a broker-only process.

FeatureEnables and exportsCompatibility
actixActix Application, TestApplication, runtime macros, Logging, to_actix_responsemutually exclusive with axum
axumAxum Application, TestApplication, runtime macros, AxumRouterBuilder, to_axum_responsemutually exclusive with actix
socketioSocket.IO types and Application::with_socket_io; selects axumunavailable with Actix
sqlxCompatibility feature; SQLx errors work with ? without enabling itcombines with either runtime
validator#[validate] and validator re-exportcombines with either runtime
openapiOpenAPI metadata/config, Swagger UI, controller markersrequires an HTTP runtime to serve docs
uploadsUploadedFile, MultipartForm, upload extractors/configurationcombines with either runtime
microservices-natsmicroservice macros, application/runtime/client, NATS options, Tokio runtime macrosno HTTP runtime required
microservices-redissame shared microservice API plus RedisTransportOptionsno HTTP runtime required; Redis 6.2+
# Default Actix
caelix = "0.0.32"

# Axum
caelix = { version = "0.0.32", default-features = false, features = ["axum"] }

# Actix with request facilities
caelix = { version = "0.0.32", features = ["uploads", "validator", "openapi"] }

# Broker-only process with both transports
caelix = { version = "0.0.32", default-features = false, features = [
  "microservices-nats",
  "microservices-redis",
] }

actix and axum must never be enabled together. socketio selects Axum, so disable defaults. NATS and Redis may be enabled separately or together and may also coexist with one HTTP runtime in a combined process. Generated macro code uses facade-only hidden exports; applications should depend on caelix, not adapter or macro crates directly.

Design Decisions

Caelix keeps framework-neutral concepts separate from adapter-specific HTTP server behavior internally, while the public caelix package exports the application-facing API.

Interceptors are framework-neutral. They receive RequestContext, Next, and the already converted HttpResponse, so they can inspect or transform responses without depending on a handler’s original return type.

HttpResponse body is either buffered bytes or a streaming Stream of Bytes chunks (ResponseBody). This is a breaking change from body: Vec<u8> (acceptable on early 0.0.x; migrate with body_bytes() / as_buffered_mut()). Optional headers live on HttpResponse.headers as owned (String, String) pairs (dynamic values allowed; not a full HeaderMap API). SSE and file downloads are thin helpers on the same primitive — SSE is data-frame framing plus cache/proxy headers, not the full SSE protocol. Adapters (today Actix) branch on ResponseBody at the boundary — buffered uses .body(...), streaming uses .streaming(...). Streaming handlers return Result<HttpResponse> via the existing identity IntoCaelixResponse impl; no separate controller attribute is required. Interceptors may rewrite buffered bodies; streaming bodies stay opaque after the handler returns.

EventBus::emit runs registered handlers first; only on full success does it publish to subscribe streams. Broadcast channels are created by subscribe, not by emit.

Middleware remains adapter-specific. The Actix layer can continue to use native Actix middleware without adding a framework-neutral middleware abstraction before another backend exists.

Lifecycle hooks live on Injectable. Normal providers use .provider::<T>(); async factory providers keep construction-only behavior.

Events are opt-in through EventModule, which registers EventBus. Event handlers must be providers before being registered as handlers.

Cache support is explicit service-level caching through Cache, CacheStore, MemoryCacheStore, and CacheModule.

Cookies are explicit response data

Request cookies are parsed into RequestContext, while response cookies live in a dedicated ordered collection on HttpResponse. This keeps controllers runtime-neutral and preserves multiple Set-Cookie headers without changing the intentionally simple generic response-header collection. Caelix does not track cookie mutations or provide an application session store.