Skip to Content
Course content
Click on the "Edit" button in the top corner of the screen to edit your slide content.

At some point in every project, you'll find yourself writing the same three lines of code at the start of every single handler. Maybe you're checking an API key, logging the request time, or extracting a trace ID from a header. When I first started with Axum, my instinct was to just build a helper function and call it at the top of every handler. It feels straightforward, and it's easy to trace with "Go to Definition" in your IDE.

The manual wrapper trap

Imagine we want to assign a unique Request ID to every incoming call so we can track logs across different services. The naive way is to write a helper and call it manually. It looks something like this:

async fn handle_user_profile(req: Request) -> Response {
    let req_id = generate_request_id(&req); 
    info!("Processing request {}", req_id);
    
    // Actual business logic here...
    Response::new("User Profile")
}

This works for two or three routes. But as your API grows to fifty routes, this becomes a nightmare. You'll inevitably forget to add the call to a new endpoint, and suddenly your logs have gaps. Worse, if you decide to change how Request IDs are generated or stored, you're performing a find-and-replace across your entire codebase. You're essentially treating your cross-cutting concerns as business logic, which they aren't.

Leveraging the middleware stack

The better way is to move this logic into the middleware layer. Axum is built on top of Tower, which treats every request as a series of layers—like an onion. Instead of calling a helper inside the handler, we wrap the handler in a function that executes before the handler even knows a request has arrived.

The most ergonomic way to do this in Axum is using axum::middleware::from_fn. This allows us to write a standard async function that takes a Request and a Next object. The Next object is the magic part; it represents the rest of the chain. When you call next.run(req).await, you're handing the request off to the next piece of middleware or the final handler.

async fn track_request_id(
    mut request: Request,
    next: Next,
) -> Response {
    let req_id = Uuid::new_v4().to_string();
    
    // We can inject the ID into request extensions so handlers can access it
    request.extensions_mut().insert(RequestId(req_id.clone()));
    
    info!("Started request {}", req_id);
    
    let response = next.run(request).await;
    
    info!("Finished request {}", req_id);
    response
}

Where the trade-offs live

You might wonder why we don't just do this everywhere. There is a cost to this abstraction. When you use middleware, you're moving away from explicit code paths. A developer looking at a handler function can no longer see every single thing that happens to a request just by reading that function. The "magic" happens in the router configuration.

However, the trade-off is almost always worth it for infrastructure concerns. By applying the middleware in the router, you ensure consistency:

let app = Router::new()
    .route("/profile", get(handle_user_profile))
    .route("/settings", get(handle_settings))
    .layer(middleware::from_fn(track_request_id));

Now, every route defined above that .layer call automatically gets a Request ID. I prefer this approach because it separates how the server behaves (logging, auth, rate limiting) from what the server does (fetching a user profile). It keeps your handlers lean and focused on business logic, which makes them significantly easier to test in isolation.




📋 Practical Task

Build an API Key Validation Guard

Your task is to implement a middleware function that protects a set of routes. Instead of checking for an API key inside every handler, you will create a middleware layer that validates a request header.

Requirements:

  • Create a middleware function using axum::middleware::from_fn.
  • The middleware must check for a header named X-API-Key.
  • If the header is missing or the value is not "secret-123", the middleware should return a StatusCode::UNAUTHORIZED immediately, without calling next.run(req).await.
  • Apply this middleware to a group of "admin" routes (e.g., /admin/dashboard and /admin/settings), but ensure that a public route (e.g., /) remains accessible without the key.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.