Axum Dependency Injection

Dependency Injection (DI) is a pattern where a component declares what it needs (its dependencies) and a framework or caller supplies them, rather than the component constructing them itself. In Axum, handler functions declare their dependencies as typed parameters; Axum resolves and provides them from the request and application state.

Axum turns function signatures into dependency declarations. The Rust type system — traits, tuple impls, newtypes (single-field tuple structs like struct TenantId(Uuid) used to give a distinct type to a value, preventing it from being confused with another Uuid), and destructuring — does the heavy lifting. This note explains the syntax and type mechanics that make it work.

Extractors: Traits, Not Magic

An extractor is any type that implements FromRequestParts<S> (for headers, state, extensions) or FromRequest<S> (for the body). When Axum sees a type in a handler signature, it calls the trait impl to produce the value from the incoming request.

// Simplified from axum's source
pub trait FromRequestParts<S>: Sized {
    type Rejection: IntoResponse;
 
    async fn from_request_parts(
        parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection>;
}

S is the state type — whatever you pass to .with_state(). Every extractor is generic over S, which is how State<T> knows what type to pull out.

The key insight

Axum handlers aren’t special. They’re regular async functions. Axum’s Handler trait is implemented for functions whose parameter types all implement FromRequestParts (or FromRequest for the last one). The compiler enforces this — if you put a non-extractor type in the signature, the trait bound fails and you get a compile error.

How Handlers Become Routes: Tuple Impls

Axum implements Handler for function types via macro-generated tuple impls. Simplified:

// Axum generates impls like this for tuples of 1..16 extractors
impl<F, Fut, S, Res, T1, T2, T3> Handler<(T1, T2, T3), S> for F
where
    F: FnOnce(T1, T2, T3) -> Fut + Clone + Send + 'static,
    Fut: Future<Output = Res> + Send,
    Res: IntoResponse,
    T1: FromRequestParts<S> + Send,
    T2: FromRequestParts<S> + Send,
    T3: FromRequest<S> + Send,    // last param can consume the body
    S: Send + Sync + 'static,
{
    async fn call(self, req: Request, state: S) -> Response {
        let (mut parts, body) = req.into_parts();
        // Each extractor is called in order
        let t1 = T1::from_request_parts(&mut parts, &state).await?;
        let t2 = T2::from_request_parts(&mut parts, &state).await?;
        let req = Request::from_parts(parts, body);
        let t3 = T3::from_request(req, &state).await?;
        self(t1, t2, t3).await.into_response()
    }
}

This is why the body extractor must come lastfrom_request consumes the Request, while from_request_parts borrows &mut Parts. The compiler won’t stop you from putting a body extractor in position 1, but Axum’s macro only generates the FromRequest call for the final parameter. Non-final params must implement FromRequestParts.

The "wrong number of arguments" error

If you get the trait Handler<_, _> is not implemented for fn(...), it usually means one of your parameter types doesn’t implement FromRequestParts<S> for the right S. The error is on Handler, not on FromRequestParts, which makes it confusing. Check that your state type matches.

Newtype Extractors and Destructuring

State<T> and Extension<T> are newtypes — single-field tuple structs:

// From axum's source
pub struct State<S>(pub S);
pub struct Extension<T>(pub T);

Because the inner field is pub, Rust’s pattern matching lets you destructure in the function signature:

async fn handler(
    State(pool): State<PgPool>,         // pool: PgPool
    Extension(ctx): Extension<TenantCtx>, // ctx: TenantCtx
) -> impl IntoResponse { ... }

This is standard Rust destructuring — the same syntax you’d use in a let or match:

let State(pool) = some_state;        // destructure a State<PgPool>
let Extension(ctx) = some_extension;  // destructure an Extension<TenantCtx>

The syntax State(pool): State<PgPool> has two parts:

  • Left of : — a pattern: State(pool) destructures the newtype, binding the inner value to pool
  • Right of : — a type annotation: State<PgPool> tells the compiler (and Axum) which extractor to use

You could also write it without destructuring:

async fn handler(state: State<PgPool>) -> impl IntoResponse {
    let pool = state.0;  // access the inner field manually
    // ...
}

Both are equivalent. The destructured form is idiomatic because you almost never need the wrapper — you need the inner value.

Why it looks weird

State(pool): State<PgPool> looks like you’re calling State() as a constructor, but you’re not. The left side is a pattern, not an expression. Rust reuses the struct/enum name for both construction (State(x) in expression position) and destructuring (State(x) in pattern position). Same syntax, opposite direction of data flow.

FromRequestParts Under the Hood

Here’s how State<T> actually extracts the value:

impl<S> FromRequestParts<S> for State<S>
where
    S: Clone + Send + Sync + 'static,
{
    type Rejection = Infallible; // can't fail -- state is always present
 
    async fn from_request_parts(
        _parts: &mut Parts,
        state: &S,
    ) -> Result<Self, Self::Rejection> {
        Ok(State(state.clone()))
    }
}

It ignores the request entirely — just clones the state. This is why S: Clone is required. The Rejection = Infallible means extraction can never fail, which makes sense because the state was provided at router construction time.

Compare with Extension<T>:

impl<T, S> FromRequestParts<S> for Extension<T>
where
    T: Clone + Send + Sync + 'static,
    S: Send + Sync + 'static,
{
    type Rejection = ExtensionRejection; // CAN fail at runtime
 
    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        parts.extensions
            .get::<T>()               // TypeId-based lookup in the type-map
            .cloned()
            .map(Extension)
            .ok_or(/* MissingExtension error */)
    }
}

This one ignores the state and looks in the request’s extensions type-map instead. If the middleware didn’t insert T, you get a runtime error (500). The type-map uses TypeId internally — TypeId is Rust’s std::any::TypeId, a globally unique identifier for a concrete type computed at compile time and usable at runtime for dynamic dispatch — it’s HashMap<TypeId, Box<dyn Any>> under the hood, where dyn Any is Rust’s type-erasure trait that lets values of any type be stored together and later downcast back to their concrete type.

State = compile-time guarantee. Extension = runtime gamble.

State extraction is infallible because the router builder requires .with_state(S) — the code won’t compile without it. Extension has no such guard. If you forget the middleware that inserts it, the handler compiles fine but panics at runtime.

Compile-Time State Checking

When you write:

Router::new()
    .route("/graphql", post(handler))
    .with_state(AppState { pool, schema })

The .with_state(S) call transforms Router<S> into Router<()> (a router with no outstanding state requirement). If you forget .with_state(), the router remains Router<AppState>, and you can’t nest it into another Router<()> or serve it — the types don’t unify.

The handler’s extractor State<AppState> has the bound FromRequestParts<AppState>. The router is generic over S = AppState. When you call .with_state(app_state), Axum stores the value and erases S to (). If the state type doesn’t match what the handlers expect, the compiler catches it:

// This won't compile -- handler expects State<AppState> but router has State<OtherState>
Router::new()
    .route("/graphql", post(handler))  // handler: FromRequestParts<AppState>
    .with_state(OtherState { ... })    // S = OtherState -- type mismatch

Writing a Custom Extractor

Any type can become an extractor by implementing the trait:

struct TenantId(Uuid);
 
impl<S> FromRequestParts<S> for TenantId
where
    S: Send + Sync + 'static,
{
    type Rejection = (StatusCode, &'static str);
 
    async fn from_request_parts(
        parts: &mut Parts,
        _state: &S,
    ) -> Result<Self, Self::Rejection> {
        parts.headers
            .get("X-Tenant-Id")
            .and_then(|v| v.to_str().ok())
            .and_then(|s| Uuid::parse_str(s).ok())
            .map(TenantId)
            .ok_or((StatusCode::BAD_REQUEST, "missing or invalid X-Tenant-Id"))
    }
}
 
// Now usable directly in handler signatures:
async fn handler(TenantId(tid): TenantId) -> impl IntoResponse {
    format!("tenant: {tid}")
}

The Rejection type must implement IntoResponse. Axum provides (StatusCode, &str) as a convenience impl that returns the status code with the string as the body.

The Two-Phase DI Pattern

Not all dependencies are known at startup. In multi-tenant apps this splits naturally:

PhaseMechanismExampleFailure mode
StartupState<T> via .with_state()DB pool, GraphQL schema, cachesCompile error if missing
Per-requestExtension<T> via middlewareTenant context, user identityRuntime 500 if middleware missing

Startup: shared, immutable, long-lived. Set once when the router is built:

let state = AppState { pool: pool.clone(), schema: gql_schema };
Router::new()
    .route("/graphql", post(graphql_handler))
    .with_state(state)

Per-request: created in middleware after auth resolves the tenant:

pub async fn tenant_middleware(
    State(resolver): State<Arc<dyn TenantResolver>>,
    mut req: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let ctx = resolver.resolve(req.headers()).await
        .map_err(|_| StatusCode::UNAUTHORIZED)?;
    req.extensions_mut().insert(ctx);  // insert into the type-map
    Ok(next.run(req).await)
}

The middleware function itself uses extractors — State(resolver) destructures the resolver from router state. Middleware functions are handlers too; they just also receive Request and Next.

Extraction Order and Body Consumption

Axum extracts parameters left to right. All parameters except the last must implement FromRequestParts (borrows request metadata). The last parameter may implement FromRequest (consumes the body):

async fn handler(
    State(s): State<AppState>,           // 1st: FromRequestParts -- borrows &mut Parts
    Extension(ctx): Extension<TenantCtx>, // 2nd: FromRequestParts -- borrows &mut Parts
    Json(body): Json<CreateRequest>,      // 3rd (last): FromRequest -- consumes body
) -> impl IntoResponse { ... }

If you put Json anywhere except last, the code compiles but Axum’s Handler impl won’t match (because non-final params must be FromRequestParts, and Json only implements FromRequest). You get:

the trait `Handler<_, _>` is not implemented for `fn(Json<...>, State<...>) -> ...`

Three Type-Maps That Look the Same But Aren’t

When combining Axum with async-graphql (a Rust library for building GraphQL servers with async/await support), you encounter three separate TypeId-keyed maps that all use the same .insert()/.get() pattern but live on different structs. Confusing them is the #1 source of “I inserted it but the lookup returns None.”

1. http::Extensions — Axum’s request type-map

The http crate defines Extensions as a HashMap<TypeId, Box<dyn Any>>. Every http::Request carries one. Axum middleware writes to it, Axum extractors read from it:

// Middleware inserts:
req.extensions_mut().insert(TenantContext { tenant_id, user_id });
 
// Handler extracts (via FromRequestParts):
Extension(ctx): Extension<TenantContext>
 
// Under the hood, Extension's FromRequestParts does:
parts.extensions.get::<TenantContext>().cloned()

Scope: one HTTP request. Dies when the response is sent.

2. async_graphql::Request::data() — per-GraphQL-request type-map

async-graphql’s Request has its own Data field (also TypeId-keyed). You insert into it in the handler, after extracting what you need from Axum:

async fn graphql_handler(
    Extension(ctx): Extension<TenantContext>,  // from Axum's type-map
    State(state): State<AppState>,
    req: GraphQLRequest,
) -> GraphQLResponse {
    let request = req.into_inner()
        // .data() inserts into async-graphql's type-map, NOT Axum's
        .data(Box::new(PgAreaRepo::new(state.pool.clone(), ctx.tenant_id))
            as Box<dyn AreaRepository>);
    state.schema.execute(request).await.into()
}

The .data() method takes self and returns Self (builder pattern). Each call inserts one value keyed by its TypeId. Scope: one GraphQL execution.

3. Schema::build().data() — schema-level type-map

When building the schema at startup, .data() inserts into a shared type-map that every request can read:

Schema::build(QueryRoot, MutationRoot, EmptySubscription)
    .data(playlist_gateway)    // available to all requests
    .data(storage_gateway)     // available to all requests
    .finish()

Scope: lifetime of the schema (effectively 'static).

How resolvers read from them

Inside a resolver, ctx.data::<T>() searches both the per-request and schema-level maps (per-request takes priority). The turbofish ::<T> is required because data() is generic — it needs to know which TypeId to look up:

async fn resolve_areas(&self, ctx: &Context<'_>) -> Result<Vec<Area>> {
    // Turbofish tells the compiler: look up TypeId::of::<Box<dyn AreaRepository>>()
    let repo = ctx.data::<Box<dyn AreaRepository>>()?;
    repo.list_all().await
}

ctx.data::<T>() returns Result<&T, Error>. If T wasn’t inserted via .data() at either level, you get a runtime error: "Data T does not exist." This is the async-graphql equivalent of Axum’s Extension missing — runtime, not compile-time.

The TypeId must match exactly

If the handler inserts Box<PgAreaRepo> but the resolver asks for Box<dyn AreaRepository>, the lookup fails — different concrete types have different TypeIds. The explicit coercion as Box<dyn AreaRepository> in the handler is not optional. It ensures the TypeId stored matches what the resolver requests.

Summary table

Type-mapStructInserted byRead byScope
http::Extensionshttp::RequestAxum middlewareAxum extractors (Extension<T>)One HTTP request
Request::data()async_graphql::RequestAxum handlerGraphQL resolvers (ctx.data::<T>())One GraphQL execution
Schema::data()async_graphql::SchemaStartup builderGraphQL resolvers (ctx.data::<T>())Lifetime of schema

There is no .data() on Axum requests

.data() on requests is an Actix-web concept. In Axum, per-request injection goes through extensions. If you see .data() in Axum code, it’s always on an async-graphql type, not an Axum type.

Common Pitfalls

Middleware ordering: Extension<T> is runtime-checked. If the middleware isn’t in the chain for a route, you get a 500. Scope middleware with .route_layer():

Router::new()
    .route("/graphql", post(graphql_handler))
    .route_layer(middleware::from_fn_with_state(resolver, tenant_middleware))
    .with_state(state)

State requires Clone: State<T> clones T on every extraction. If T is large, wrap in Arc:

// Bad -- deep-copies AppState on every request
.with_state(AppState { pool, schema, big_config })
 
// Good -- clones an Arc (cheap refcount bump)
.with_state(Arc::new(AppState { pool, schema, big_config }))
// Handler: State(state): State<Arc<AppState>>

Note: PgPool and SqlitePool are already Arc-wrapped internally, so cloning them is cheap even without an outer Arc.

Mismatched state type: If the handler expects State<AppState> but the router’s S is something else, you get a confusing trait error on Handler, not on State. Read the error carefully — it’s telling you the state types don’t unify.

Extension vs State confusion: Both compile, but Extension looks in the request type-map (parts.extensions) while State looks at the router’s stored state. Mixing them up compiles but fails at runtime.

See also

  • Async — Rust async/await fundamentals
  • Tokio Runtime — the async runtime Axum is built on