{"id":226,"date":"2026-05-13T05:04:55","date_gmt":"2026-05-13T05:04:55","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/axum-rust-patterns-that-actually-stuck\/"},"modified":"2026-05-13T05:04:55","modified_gmt":"2026-05-13T05:04:55","slug":"axum-rust-patterns-that-actually-stuck","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/axum-rust-patterns-that-actually-stuck\/","title":{"rendered":"Axum in Rust: The Patterns That Actually Stuck for Me"},"content":{"rendered":"<p>Confession: I migrated a small service from Actix Web to Axum about a year ago because I lost a borrow-checker fight that, in retrospect, was entirely my fault. I want to say the migration was strategic. It wasn&rsquo;t. It was a tantrum.<\/p>\n<p>Twelve months later I&rsquo;m still on Axum, and I have opinions worth writing down.<\/p>\n<p>This isn&rsquo;t an introduction. The <a href=\"https:\/\/docs.rs\/axum\/latest\/axum\/\" rel=\"nofollow noopener\" target=\"_blank\">Axum docs<\/a> are fine; you can read them yourself. This is the post I wish I&rsquo;d had a year ago: the small set of patterns I keep reaching for and the ones I tried and quietly dropped. If you want the broader picture of how Axum stacks up against Actix Web and Rocket, I covered that in <a href=\"https:\/\/abrarqasim.com\/blog\/rust-web-frameworks-2026-axum-actix-rocket\" rel=\"noopener\">my Rust web frameworks comparison<\/a>. This one goes deeper into a single framework.<\/p>\n<h2 id=\"the-mental-model-that-finally-clicked\">The mental model that finally clicked<\/h2>\n<p>For about a month I treated Axum like a slightly weird Express. Routes and handlers. Done. That worked, but I kept getting confused whenever the type errors got loud.<\/p>\n<p>What finally clicked was this: Axum is a thin layer over <a href=\"https:\/\/docs.rs\/tower\/latest\/tower\/\" rel=\"nofollow noopener\" target=\"_blank\">Tower<\/a>. Every handler is a <code>Service<\/code>, and middleware is a <code>Layer<\/code> that wraps it. Routing is composition all the way down. Once I stopped thinking &ldquo;web framework&rdquo; and started thinking &ldquo;Tower with sugar&rdquo;, everything got easier. The compiler error messages even started looking like they were trying to help me, which was a first.<\/p>\n<p>The practical version: when something feels weird, ask whether the same pattern would feel weird if you wrote it as a plain async function returning a <code>Response<\/code>. Usually yes. The pain is structural, not Axum&rsquo;s fault.<\/p>\n<h2 id=\"extractors-i-actually-use\">Extractors I actually use<\/h2>\n<p>Extractors are Axum&rsquo;s best idea. You declare what your handler needs, and the framework hands it to you. I use about four of them daily and ignore the rest.<\/p>\n<pre><code class=\"language-rust\">use axum::{\n    extract::{Path, Query, State, Json},\n    response::IntoResponse,\n};\nuse serde::Deserialize;\nuse sqlx::PgPool;\n\n#[derive(Deserialize)]\nstruct ListParams {\n    q: Option&lt;String&gt;,\n    limit: Option&lt;i64&gt;,\n}\n\nasync fn get_user(\n    State(db): State&lt;PgPool&gt;,\n    Path(user_id): Path&lt;i64&gt;,\n) -&gt; Result&lt;Json&lt;User&gt;, AppError&gt; {\n    let user = sqlx::query_as!(\n        User,\n        &quot;SELECT id, email FROM users WHERE id = $1&quot;,\n        user_id\n    )\n    .fetch_one(&amp;db)\n    .await?;\n    Ok(Json(user))\n}\n\nasync fn list_users(\n    State(db): State&lt;PgPool&gt;,\n    Query(params): Query&lt;ListParams&gt;,\n) -&gt; Result&lt;Json&lt;Vec&lt;User&gt;&gt;, AppError&gt; {\n    \/\/ ...\n    unimplemented!()\n}\n<\/code><\/pre>\n<p>The two I most often see misused: <code>Query&lt;T&gt;<\/code> where <code>T<\/code> has a field that&rsquo;s actually optional but isn&rsquo;t <code>Option&lt;...&gt;<\/code>, and <code>Json&lt;T&gt;<\/code> where the struct doesn&rsquo;t <code>Deserialize<\/code> cleanly because someone forgot <code>#[serde(rename = \"...\")]<\/code>. Both produce error responses that look like Axum is broken when really <code>serde<\/code> is.<\/p>\n<p>For anything custom (auth tokens, request IDs, tenant context), I write a <code>FromRequestParts<\/code> impl rather than reading headers in every handler. Once you&rsquo;ve written one, the next ten take five minutes each.<\/p>\n<h2 id=\"state-and-middleware-stop-fighting-tower\">State and middleware: stop fighting Tower<\/h2>\n<p>The <code>State<\/code> extractor is fine for the database pool and a config struct. For everything else, layers.<\/p>\n<p>This is the layered setup I actually run in production:<\/p>\n<pre><code class=\"language-rust\">use axum::{Router, routing::get};\nuse tower_http::{\n    trace::TraceLayer,\n    timeout::TimeoutLayer,\n    compression::CompressionLayer,\n    cors::CorsLayer,\n};\nuse std::time::Duration;\n\nlet app = Router::new()\n    .route(&quot;\/healthz&quot;, get(healthz))\n    .route(&quot;\/users\/:id&quot;, get(get_user))\n    .route(&quot;\/users&quot;, get(list_users))\n    .with_state(db_pool)\n    .layer(TraceLayer::new_for_http())\n    .layer(TimeoutLayer::new(Duration::from_secs(10)))\n    .layer(CompressionLayer::new())\n    .layer(CorsLayer::permissive());\n<\/code><\/pre>\n<p>Order matters. Layers wrap the router from the bottom up, which means <code>TraceLayer<\/code> runs first on the way in and last on the way out. I got this wrong for two weeks: I had a timeout outside my trace layer and spent embarrassingly long wondering why timed-out requests had no spans.<\/p>\n<p>The other thing that took me too long to internalize: route-specific middleware lives on the route, not the global app.<\/p>\n<pre><code class=\"language-rust\">let app = Router::new()\n    .route(\n        &quot;\/admin\/users&quot;,\n        get(admin_list_users)\n            .layer(axum::middleware::from_fn(require_admin)),\n    )\n    .route(&quot;\/users\/:id&quot;, get(get_user))\n    .with_state(db_pool);\n<\/code><\/pre>\n<p>If you find yourself reaching for global middleware plus a &ldquo;skip this path&rdquo; predicate, that&rsquo;s the universe telling you to put the layer on the route instead.<\/p>\n<h2 id=\"error-handling-that-doesnt-make-you-cry\">Error handling that doesn&rsquo;t make you cry<\/h2>\n<p>This was where I burned the most time. I wanted one error type, one <code>?<\/code> in every handler, and meaningful HTTP responses. It took me three rewrites to land on the pattern below.<\/p>\n<pre><code class=\"language-rust\">use axum::{\n    http::StatusCode,\n    response::{IntoResponse, Response},\n    Json,\n};\nuse serde_json::json;\nuse thiserror::Error;\n\n#[derive(Debug, Error)]\npub enum AppError {\n    #[error(&quot;not found&quot;)]\n    NotFound,\n    #[error(&quot;database error: {0}&quot;)]\n    Database(#[from] sqlx::Error),\n    #[error(&quot;validation: {0}&quot;)]\n    Validation(String),\n    #[error(&quot;internal&quot;)]\n    Internal(#[source] anyhow::Error),\n}\n\nimpl IntoResponse for AppError {\n    fn into_response(self) -&gt; Response {\n        let (status, code) = match &amp;self {\n            AppError::NotFound =&gt; (StatusCode::NOT_FOUND, &quot;not_found&quot;),\n            AppError::Validation(_) =&gt; (StatusCode::BAD_REQUEST, &quot;validation&quot;),\n            AppError::Database(sqlx::Error::RowNotFound) =&gt; {\n                (StatusCode::NOT_FOUND, &quot;not_found&quot;)\n            }\n            AppError::Database(_) | AppError::Internal(_) =&gt; {\n                (StatusCode::INTERNAL_SERVER_ERROR, &quot;internal&quot;)\n            }\n        };\n\n        if status.is_server_error() {\n            tracing::error!(error = ?self, &quot;request failed&quot;);\n        }\n\n        let body = Json(json!({\n            &quot;error&quot;: { &quot;code&quot;: code, &quot;message&quot;: self.to_string() }\n        }));\n        (status, body).into_response()\n    }\n}\n<\/code><\/pre>\n<p>The trick I missed for too long: pattern-match on the variant inside the <code>From&lt;sqlx::Error&gt;<\/code> path so that <code>RowNotFound<\/code> becomes a clean 404 instead of a 500. My on-call past self would like a word.<\/p>\n<h2 id=\"observability-without-ceremony\">Observability without ceremony<\/h2>\n<p>The <code>tracing<\/code> crate plus <code>tower_http::trace::TraceLayer<\/code> does about ninety percent of what I need. I add one custom span per request to carry the request ID and user ID, and the rest writes itself.<\/p>\n<pre><code class=\"language-rust\">use tower_http::trace::{DefaultMakeSpan, TraceLayer};\nuse tracing::Span;\n\nlet trace = TraceLayer::new_for_http()\n    .make_span_with(DefaultMakeSpan::new().include_headers(false))\n    .on_request(|req: &amp;axum::http::Request&lt;_&gt;, _span: &amp;Span| {\n        tracing::info!(\n            method = %req.method(),\n            path = %req.uri().path(),\n            &quot;request&quot;\n        );\n    });\n<\/code><\/pre>\n<p>If you ship to anything OTLP-aware, <code>tracing-opentelemetry<\/code> plus <code>opentelemetry-otlp<\/code> gets you working spans in about an hour. I push these to Grafana Tempo from my own services and have stopped reading raw logs for anything other than panics.<\/p>\n<p>I lean on <code>tracing<\/code> enough that I cover the broader Rust observability story in <a href=\"https:\/\/abrarqasim.com\/blog\/rust-sqlx-production-eight-months-of-lessons\" rel=\"noopener\">my sqlx production lessons post<\/a>, because once you have spans, you want them on database calls too.<\/p>\n<h2 id=\"testing-handlers-without-spinning-up-a-server\">Testing handlers without spinning up a server<\/h2>\n<p>This is something I push every Rust dev I work with to adopt early: write your handler tests against the router as a <code>Service<\/code>, not via a live <code>TcpListener<\/code>. Axum makes this almost suspiciously easy, and once you have it set up the tests run faster than <code>cargo check<\/code> does on a cold cache.<\/p>\n<pre><code class=\"language-rust\">use axum::{body::Body, http::{Request, StatusCode}, Router};\nuse tower::ServiceExt;\n\n#[tokio::test]\nasync fn returns_404_for_missing_user() {\n    let app: Router = build_app(test_pool()).await;\n\n    let response = app\n        .oneshot(\n            Request::builder()\n                .uri(&quot;\/users\/999999&quot;)\n                .body(Body::empty())\n                .unwrap(),\n        )\n        .await\n        .unwrap();\n\n    assert_eq!(response.status(), StatusCode::NOT_FOUND);\n}\n<\/code><\/pre>\n<p><code>tower::ServiceExt::oneshot<\/code> takes a request and pushes it through the whole router, layers and all. You get to test the real middleware ordering and the real error mapping in one shot. The only thing I usually mock out is anything that calls a third party network service. The database stays real, just pointed at an ephemeral Postgres in CI.<\/p>\n<p>A pattern that saved me time once I started doing it: write a tiny helper that builds the test app from a config struct, then write one test per error variant. If your <code>AppError<\/code> has <code>NotFound<\/code>, <code>Validation<\/code>, <code>Forbidden<\/code>, and <code>Internal<\/code>, you want a test that confirms each one returns the right status code and the right JSON shape. They are three-line tests. They catch a surprising number of regressions when somebody adds a new variant and forgets to map it correctly in <code>IntoResponse<\/code>.<\/p>\n<p>The testing story is the bit of Axum I appreciate most in retrospect. I switched from a framework where I had to spin up an actual HTTP server in tests, with all the port allocation pain that implies, and the difference in feedback speed is the kind of thing that quietly changes how you work.<\/p>\n<h2 id=\"what-i-tried-and-dropped\">What I tried and dropped<\/h2>\n<p>A short list, since not everything stuck.<\/p>\n<p><code>axum-extra::TypedHeader<\/code> for auth. Nice idea, but I wanted my auth extractor to also load the user row, so I went back to a custom <code>FromRequestParts<\/code>. Less elegant, more useful.<\/p>\n<p><code>axum-macros::debug_handler<\/code>. Helpful when you&rsquo;re learning. After a couple of months you stop needing it, and I forget to add it now.<\/p>\n<p>Global error middleware via <code>HandleErrorLayer<\/code>. I tried this twice. The flow control got confusing fast. The <code>IntoResponse<\/code> pattern above is plainer to read.<\/p>\n<p>Putting everything behind <code>Arc&lt;AppState&gt;<\/code>. Tempting. Mostly unnecessary. <code>State&lt;...&gt;<\/code> already handles the cloning correctly for things that are cheap to clone, like a <code>PgPool<\/code> or an HTTP client.<\/p>\n<h2 id=\"what-to-do-this-week\">What to do this week<\/h2>\n<p>Pick one of these, depending on where your service hurts.<\/p>\n<p>If your handlers are returning <code>Result&lt;Json&lt;T&gt;, (StatusCode, String)&gt;<\/code>, write a real error enum with <code>IntoResponse<\/code>. Two hours of work, weeks of cleaner reading.<\/p>\n<p>If you have route-specific auth checks inside handlers, hoist them to a route-level layer. Cleaner code, harder to bypass.<\/p>\n<p>If you don&rsquo;t have <code>TraceLayer<\/code> plus a structured logger wired up, do that today. Even local development gets nicer when every request has a span ID.<\/p>\n<p>I keep notes on patterns like these in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">my Rust and infrastructure work<\/a>, which is mostly small services that have to stay up. Axum has made keeping them up considerably less stressful than the last framework I tried, and I&rsquo;ll take that.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>The Axum patterns I actually use in production Rust services: extractors, Tower layer ordering, an error enum that works, and pragmatic tracing.<\/p>\n","protected":false},"author":2,"featured_media":225,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"The Axum patterns I actually use in production Rust services: extractors, Tower layer ordering, an error enum that works, and pragmatic tracing.","rank_math_focus_keyword":"axum rust","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[147,142],"tags":[264,117,49,64,263,262,39],"class_list":["post-226","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-backend","category-rust","tag-async-rust","tag-axum","tag-backend","tag-rust","tag-rust-web-framework","tag-tower","tag-web-development"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/226","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=226"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/226\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/225"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=226"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=226"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=226"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}