1. Mental Model Shift {#mental-model-shift}
Before you write a single line of Rust, it is worth pausing to understand why the two ecosystems look so different on the surface even though they are solving identical problems. Go and Gin were designed around simplicity and developer velocity. The framework makes strong choices for you: a mutable context object flows through every handler, middleware is a slice of functions called in order, and errors are handled imperatively with early returns. This model is easy to teach, easy to read, and maps naturally to how most engineers think about HTTP request processing as a linear pipeline.
Rust and Axum take a different philosophical stance. Axum is built on top of Tower, which is a general-purpose abstraction for networked services. Rather than giving you a mutable context object and a magic Next() function, it gives you composable types. Middleware is not a function in a list — it is a value that wraps another value. A handler is not a special interface — it is any function whose arguments and return type satisfy certain traits. The result is a system where the compiler can verify the correctness of your middleware stack at build time, not at runtime.
This distinction matters enormously when you are debugging. In Gin, a misconfigured middleware chain fails at request time, often with a nil pointer panic or a silent wrong response. In Axum, a misconfigured middleware stack usually fails at compile time with a trait bound error. The error messages are famously cryptic at first, but once you understand the underlying model, they become precise and actionable.
Internalise this comparison table before continuing. Every section of this guide is an expansion of one row in it.
| Concept | Go + Gin | Rust + Axum |
|---|---|---|
| Handler signature | func(c *gin.Context) |
async fn(/* extractors */) -> impl IntoResponse |
| Middleware |
gin.HandlerFunc in a chain |
tower::Layer wrapping a Service
|
| Router |
gin.Engine (mutable, method calls) |
axum::Router (immutable builder, returns new value) |
| Concurrency | goroutines + net/http thread pool |
tokio async tasks |
| Work queue |
chan + select
|
tokio::sync::mpsc + select! macro |
| Context passing |
*gin.Context via Set/Get
|
typed extractors + axum::Extension
|
| Error type |
error interface, returned or aborted |
impl IntoResponse or custom error enum |
The single most important conceptual shift is this: in Gin, middleware is imperative — you call c.Next() and execution literally jumps forward in a slice of function pointers, then returns. In Axum, middleware is declarative and compositional — you wrap a Service in a Layer, and the runtime calls the outer layer first, which internally calls the inner service as a future. There is no c.Next(). The equivalent is next.run(req).await, which is not magic — it is simply calling an async function that happens to be the rest of your handler stack.
2. Project Setup {#project-setup}
Go + Gin Setup
Setting up a Gin project is straightforward because Go modules and the go get command handle everything in a few commands. Gin itself is a single dependency with no required configuration.
mkdir go-server && cd go-server
go mod init example.com/server
go get github.com/gin-gonic/gin
Rust + Axum Setup
Rust setup requires more deliberate dependency management. The most important thing to understand is that Axum, Tower, Hyper, and tower-http form a tightly coupled version family. Axum 0.7 was a major breaking change from Axum 0.6 — it migrated from Hyper 0.14 to Hyper 1.0. If you mix versions from different generations, you will get extremely confusing trait bound errors that do not mention the real cause. Always pin your versions explicitly and check the Axum changelog before upgrading anything in this dependency group.
The features flags in Cargo.toml are also important. Tokio's full feature enables the entire runtime, including timers, I/O, signal handling, and the multi-threaded scheduler. For production you may want to be more selective, but for learning, full is the right starting point. The tower-http crate provides production-quality middleware implementations — tracing, CORS, timeouts, compression, request ID injection — that would take significant effort to write yourself.
cargo new rust-server && cd rust-server
Your Cargo.toml should look exactly like this. Do not use * for versions in this dependency group.
[package]
name = "rust-server"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = "0.7"
tokio = { version = "1", features = ["full"] }
tower = { version = "0.4", features = ["full"] }
tower-http = { version = "0.5", features = ["trace", "cors", "timeout", "request-id"] }
hyper = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
thiserror = "1"
http = "1"
⚠️ Gotcha #1 — Version coupling is strict in this ecosystem.
tower-httpversion must align with the Hyper version that Axum targets. Axum 0.7 uses Hyper 1.x and tower-http 0.5.x. If you pull in a crate that depends on Hyper 0.14 and try to use it alongside Axum 0.7, the compiler will tell you that trait implementations conflict or are missing — not that your Hyper versions are incompatible. Before adding any HTTP-adjacent crate to an Axum 0.7 project, check whether it has a version that supports Hyper 1.x.
3. Hello World: Server Bootstrapping {#hello-world}
Go + Gin: What Default() Actually Gives You
When most Go developers start a Gin server, they reach for gin.Default() without thinking much about it. It is worth understanding precisely what that function does, because the Rust equivalent requires you to set each piece up explicitly. gin.Default() creates an Engine, which implements Go's standard http.Handler interface, and it pre-registers two middleware functions: Logger and Recovery. Logger writes request details to stdout after every response. Recovery catches any panics that occur during handler execution and converts them to 500 responses, preventing your server from crashing. Both are registered globally, meaning they apply to every route.
The gin.Engine is a mutable struct. You call methods on it to add routes and middleware, and those mutations happen in place. When you call r.Run(), Gin simply delegates to net/http.ListenAndServe, passing itself as the handler. Go's net/http package handles all connection lifecycle management, using goroutines per connection from a managed pool.
// main.go
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
// gin.Default() attaches Logger and Recovery middleware automatically.
// Use gin.New() if you want no default middleware at all.
r := gin.Default()
r.GET("/ping", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "pong"})
})
// Blocks forever. Listens on :8080.
log.Fatal(r.Run(":8080"))
}
Rust + Axum: The Same Server, Explicitly Assembled
In Axum, you build the same result by assembling the pieces that gin.Default() would have given you for free. The tracing subscriber replaces Gin's Logger. Axum does not have a built-in panic recovery middleware, but Tower's ServiceBuilder and tower-http's TraceLayer cover the observability side, and you can add catch-unwind behaviour if needed. Most Rust async code avoids panics entirely by using Result types, which is the idiomatic approach.
The Router in Axum is an immutable builder. Every call to .route(), .layer(), or .merge() returns a new Router value. This is different from Gin's mutable engine and has an important consequence: you compose your router in a single expression tree, and then hand the fully-built value to axum::serve. This makes the router's shape visible at a glance and prevents accidental mutation after the fact.
The #[tokio::main] macro transforms your main function into one that starts the Tokio async runtime and runs the annotated async block on it. This is the entry point for all async code in your binary. Everything downstream of main runs inside the runtime's executor.
// src/main.rs
use axum::{routing::get, Router};
use std::net::SocketAddr;
use tokio::net::TcpListener;
use tracing::info;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() {
// This replaces gin's built-in Logger middleware.
// RUST_LOG=info cargo run controls what gets printed.
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Router is an immutable builder — each method returns a new Router.
let app = Router::new()
.route("/ping", get(ping_handler));
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
// TcpListener is the Tokio async equivalent of net.Listen in Go.
let listener = TcpListener::bind(addr).await.unwrap();
info!("listening on {}", addr);
// axum::serve replaced axum::Server::bind in Axum 0.7.
// This blocks until the server shuts down.
axum::serve(listener, app).await.unwrap();
}
async fn ping_handler() -> axum::Json<serde_json::Value> {
axum::Json(serde_json::json!({ "message": "pong" }))
}
Notice that ping_handler takes no arguments and returns a typed JSON value. Axum converts this function into a tower::Service automatically through its Handler trait implementation. The handler does not need to know anything about the HTTP layer — it just receives whatever it declares it needs (via extractors) and returns something that can be turned into a response (via IntoResponse). This separation of concerns is more complete than in Gin, where every handler always receives the full *gin.Context even if it only uses one field.
⚠️ Gotcha #2 —
axum::Serverwas removed in Axum 0.7. A large number of tutorials, blog posts, and Stack Overflow answers target Axum 0.6 and use theaxum::Server::bind(&addr).serve(app.into_make_service())pattern. This code does not compile against Axum 0.7. The correct Axum 0.7 API isTcpListener::bind(addr).awaitfollowed byaxum::serve(listener, app).await. If your code does not compile and the error mentionsaxum::Server, you are reading outdated documentation.
4. Routing {#routing}
Go + Gin: Groups, Parameters, and the Mutable Chain
Gin's routing API is designed to read like a configuration file. You call methods on the engine or on a RouterGroup to register routes, and those registrations happen imperatively in order. Route groups share a URL prefix and optionally share middleware. The r.Group() call returns a *RouterGroup, and you call the same HTTP-verb methods on it. Middleware registered with group.Use() only applies to routes registered on that group and its children.
Query parameters are not part of the route definition in Gin — they are pulled from the context at handler time using c.Query() or c.DefaultQuery(). Path parameters are declared with a colon prefix in the route string and retrieved with c.Param(). Both APIs use strings with no compile-time type checking, which means a typo in the parameter name is a runtime bug, not a compile error.
// main.go
package main
import (
"net/http"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
// Simple routes with HTTP verb methods
r.GET("/users", listUsers)
r.POST("/users", createUser)
// Path parameters use colon syntax in the route string
r.GET("/users/:id", getUser)
// Query parameters are handled inside the handler, not in the route
// GET /search?q=foo&page=2
r.GET("/search", searchHandler)
// Route groups share a URL prefix and optionally share middleware.
// The braces are a Go scoping convention, not required syntax.
api := r.Group("/api/v1")
{
api.GET("/products", listProducts)
api.GET("/products/:id", getProduct)
// Nested group — admin middleware only applies to these routes
admin := api.Group("/admin")
admin.Use(adminAuthMiddleware())
{
admin.DELETE("/products/:id", deleteProduct)
}
}
r.Run(":8080")
}
func listUsers(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"users": []string{}})
}
func getUser(c *gin.Context) {
// c.Param() retrieves the path parameter by name — string only
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"id": id})
}
func searchHandler(c *gin.Context) {
q := c.Query("q") // returns "" if missing
page := c.DefaultQuery("page", "1") // returns "1" if missing
c.JSON(http.StatusOK, gin.H{"q": q, "page": page})
}
func createUser(c *gin.Context) { c.Status(201) }
func listProducts(c *gin.Context) { c.Status(200) }
func getProduct(c *gin.Context) { c.Status(200) }
func deleteProduct(c *gin.Context) { c.Status(204) }
func adminAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { c.Next() }
}
Rust + Axum: Typed Extractors Replace the Context Object
Axum's routing model eliminates the context object entirely. Instead of a single *gin.Context that carries everything, each handler declares exactly what it needs as typed function parameters. These typed parameters are called extractors, and Axum's framework automatically runs the extraction logic and passes the result to your function. If extraction fails — for example, a required query parameter is missing, or the JSON body cannot be deserialized — Axum returns an appropriate error response before your handler is even called.
Route groups are replaced by the Router::nest() method, which mounts a sub-router under a prefix. Middleware is applied to a sub-router using .layer(), which scopes the middleware to only the routes in that sub-router. This is a more explicit and composable model than Gin's group-level Use().
The structural difference to emphasise here is that Router is a value, not a reference. You cannot pass a Router to a function and mutate it — you return a new Router from each function call. This means your routing configuration is naturally expressed as a tree of pure functions returning values, which makes it easy to test, compose, and reason about.
// src/routes/mod.rs
use axum::{
Router,
routing::{delete, get, post},
extract::{Path, Query, State},
Json,
http::StatusCode,
};
use serde::Deserialize;
use crate::AppState;
// This function builds and returns the complete router for the application.
// It is called once at startup and the result is handed to axum::serve.
// There is no global mutable engine — just a value built from composition.
pub fn build_router(state: AppState) -> Router {
// Public routes that require no authentication
let public_routes = Router::new()
.route("/users", get(list_users).post(create_user))
.route("/users/:id", get(get_user))
.route("/search", get(search_handler));
// Admin routes — middleware will be applied to this sub-router
// in the middleware section below. Shown here for structure only.
let admin_routes = Router::new()
.route("/products/:id", delete(delete_product));
// API v1 group — equivalent to r.Group("/api/v1") in Gin.
// The admin sub-router is nested under /api/v1/admin.
let api_v1 = Router::new()
.route("/products", get(list_products))
.route("/products/:id", get(get_product))
.nest("/admin", admin_routes);
// Assemble the top-level router.
// State is attached at the root and automatically flows to all
// nested routers and handlers that declare State<AppState>.
Router::new()
.merge(public_routes)
.nest("/api/v1", api_v1)
.with_state(state)
}
// Handlers follow below the router definition.
// Each handler only takes the extractors it actually needs.
async fn list_users() -> Json<serde_json::Value> {
Json(serde_json::json!({ "users": [] }))
}
// Path extractor is typed. The compiler knows the parameter is a String.
// A typo in the extractor variable name produces a compile error, not a
// runtime bug as it would with c.Param("typo") in Gin.
async fn get_user(Path(id): Path<String>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "id": id }))
}
async fn create_user() -> StatusCode {
StatusCode::CREATED
}
// Query params are deserialized into a typed struct automatically.
// serde's default attribute replaces DefaultQuery in Gin.
// If a required field (non-Option) is missing, Axum returns 422 automatically.
#[derive(Deserialize)]
struct SearchParams {
q: Option<String>,
#[serde(default = "default_page")]
page: String,
}
fn default_page() -> String {
"1".to_string()
}
async fn search_handler(
Query(params): Query<SearchParams>,
) -> Json<serde_json::Value> {
Json(serde_json::json!({
"q": params.q.unwrap_or_default(),
"page": params.page,
}))
}
async fn list_products() -> StatusCode { StatusCode::OK }
async fn get_product(Path(_id): Path<String>) -> StatusCode { StatusCode::OK }
async fn delete_product(Path(_id): Path<String>) -> StatusCode { StatusCode::NO_CONTENT }
⚠️ Gotcha #3 — Extractor ordering in handler function signatures is enforced by the compiler and easy to get wrong. Axum processes extractors in the order they appear. Because reading the request body consumes the byte stream, the body extractor —
Json<T>,Bytes,String, orForm<T>— must always be the last parameter. Any extractor that appears after the body extractor will fail to compile with a confusing error message about missing trait implementations. The safe ordering is:State→Path→Query→HeaderMap→Extension→ body extractor last.
5. Middleware: The Core Concept {#middleware}
This is the most critical section of this guide. If you read only one section carefully, let it be this one. The difference between how Gin and Axum implement middleware is not just syntactic — it reflects fundamentally different architectural philosophies, and getting the Axum model wrong produces bugs that are difficult to trace.
How Gin Middleware Works: The Handler Chain
Gin stores middleware as a flat slice of HandlerFunc values on the engine and on each route group. When a request arrives and matches a route, Gin builds a merged slice of all applicable middleware functions followed by the route's own handler function. It then executes this slice sequentially, advancing an index pointer. Calling c.Next() increments that pointer and executes the next function in the slice. When the last function returns, control unwinds back through every c.Next() call site, executing any code that appears after c.Next() in each middleware in reverse registration order.
Calling c.Abort() or c.AbortWithStatusJSON() sets a flag that prevents the index from advancing further. Any middleware that checks c.IsAborted() or any code after a c.Next() call will still execute in the aborting middleware, but no further functions in the chain will be invoked.
This model is intuitive but has a subtle implication: every middleware in the chain shares the same mutable *gin.Context. Values are passed between middleware and handlers via untyped string keys in a map (c.Set and c.Get). A typo in a key name is a runtime bug. A type assertion failure from c.Get is a runtime panic. These are not problems you can catch at compile time.
// Gin middleware is a HandlerFunc. c.Next() advances the handler chain.
// Handlers are stored as []HandlerFunc and executed in order (FIFO).
// Code before c.Next() runs on the way IN.
// Code after c.Next() runs on the way OUT, after the response is written.
func TimingMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
// Save anything you need before the downstream handlers run
method := c.Request.Method
uri := c.Request.RequestURI
c.Next() // Execution jumps forward. This line blocks until
// all remaining handlers complete and return.
// This code runs after the response has been written
duration := time.Since(start)
log.Printf("[%s] %s took %v", method, uri, duration)
}
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := c.GetHeader("Authorization")
if token == "" {
// AbortWithStatusJSON writes the response AND prevents
// c.Next() from advancing. The return prevents any code
// after this block from running in this middleware function.
c.AbortWithStatusJSON(401, gin.H{"error": "unauthorized"})
return
}
// Passing data to downstream handlers via untyped map.
// The key is a string — a typo here is a runtime bug.
c.Set("user_id", "user-123")
c.Next()
}
}
func main() {
r := gin.New() // gin.New() gives you no default middleware
// Middleware is registered globally in FIFO order.
// TimingMiddleware runs first on the way in, last on the way out.
r.Use(TimingMiddleware())
r.Use(AuthMiddleware())
r.GET("/protected", func(c *gin.Context) {
// Type assertion from interface{} — can panic if middleware
// didn't set this key or set a different type.
userID, _ := c.Get("user_id")
c.JSON(200, gin.H{"user": userID})
})
}
Gin's execution model visualised:
Request arrives
│
▼
[TimingMiddleware — code before c.Next()] ← records start time
│
▼
[AuthMiddleware — code before c.Next()] ← validates token, sets user_id
│
▼
[Route Handler — c.JSON(200, ...)] ← reads user_id, writes response
│
▼
[AuthMiddleware — code after c.Next()] ← nothing here in our example
│
▼
[TimingMiddleware — code after c.Next()] ← logs duration
│
▼
Response sent to client
How Tower/Axum Middleware Works: Composed Services
Tower middleware is built on two fundamental traits that you need to understand even if you never implement them manually. Service<Request> represents anything that can process a request and produce a response asynchronously. Layer<S> represents a factory that wraps a Service in another Service — it transforms an inner service into an outer service that adds some behaviour.
When you call .layer(MyLayer) on an Axum Router, you are telling Axum: take the current service, and wrap it with MyLayer. The outer service produced by the layer receives requests first, does its pre-processing work, then calls the inner service. When the inner service returns a response, the outer service can do post-processing before returning the response upstream. This is mechanically identical to Gin's before/after c.Next() pattern, but expressed as function composition rather than an index pointer in a slice.
// The two traits you must understand to reason about Axum middleware:
pub trait Service<Request> {
type Response;
type Error;
type Future: Future<Output = Result<Self::Response, Self::Error>>;
// Called before each request to check if the service can accept work.
// This is Tower's backpressure mechanism — no equivalent in Gin.
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>>;
// Called with the request. Returns a Future that resolves to the response.
// This is where your middleware logic lives.
fn call(&mut self, req: Request) -> Self::Future;
}
pub trait Layer<S> {
type Service;
// Wraps the inner Service S and returns a new Service.
// This is called once at startup, not per-request.
fn layer(&self, inner: S) -> Self::Service;
}
// A Layer wraps a Service to produce a new Service.
// Layers compose: the outermost layer runs first on request in,
// and last on response out — identical to Gin's before/after Next() pattern.
// The key difference is that this composition happens at compile time,
// not at request time, and the compiler verifies the types are compatible.
Writing a Timing Middleware Using from_fn
The axum::middleware::from_fn function is the idiomatic way to write middleware for most production use cases. It takes an async function with a specific signature and wraps it in a Tower Layer so you can use it with .layer(). This avoids the significant boilerplate of implementing Service and Layer manually.
The next: Next parameter is the functional equivalent of c.Next() in Gin. Calling next.run(req).await invokes the rest of the middleware stack and the route handler, then returns their combined response. Everything before next.run() is pre-processing (equivalent to code before c.Next() in Gin). Everything after is post-processing (equivalent to code after c.Next()).
// src/middleware/timing.rs
//
// axum::middleware::from_fn wraps this function as a Tower middleware Layer.
// The function signature must match exactly: (Request, Next) -> Response.
// The async keyword is required because middleware is always async in Axum —
// the runtime needs to be able to yield control while waiting for downstream
// handlers to complete.
use axum::{
body::Body,
extract::Request,
middleware::Next,
response::Response,
};
use std::time::Instant;
use tracing::info;
pub async fn timing_middleware(
req: Request,
next: Next,
) -> Response {
// Everything here runs BEFORE the downstream handlers.
// Equivalent to code before c.Next() in Gin.
// We extract what we need from the request before moving it into next.run(),
// because the Request value is consumed when passed to next.run().
let start = Instant::now();
let method = req.method().clone();
let uri = req.uri().clone();
// This is the equivalent of c.Next() in Gin.
// It awaits the completion of all remaining middleware and handlers.
// The request is moved in and the response is returned.
let response = next.run(req).await;
// Everything here runs AFTER the downstream handlers complete.
// Equivalent to code after c.Next() in Gin.
// The response has been produced but not yet sent to the client.
// You can inspect or modify it here.
let duration = start.elapsed();
info!(
method = %method,
uri = %uri,
status = response.status().as_u16(),
duration_ms = duration.as_millis(),
"request completed"
);
response
}
Writing an Auth Middleware That Can Short-Circuit
The early-return pattern replaces c.AbortWithStatusJSON(). Because middleware is just an async function that returns a Response, you can return any response at any point. Simply returning before calling next.run(req) is equivalent to c.Abort() — the downstream middleware and handler are never called.
The type-safe value passing mechanism is request extensions. Rather than storing values in a map[string]interface{} as Gin does with c.Set(), Axum uses a typed map in the request's extension storage. Each type can have exactly one entry. You insert a value with req.extensions_mut().insert(MyValue { ... }), and you retrieve it in a handler with the Extension<MyValue> extractor. The compiler knows the type — no interface, no string key, no type assertion.
// src/middleware/auth.rs
//
// This middleware checks for an Authorization header and either
// short-circuits with a 401 response or injects the authenticated user
// into the request extensions for downstream handlers to retrieve.
// It is the direct equivalent of Gin's AbortWithStatusJSON + c.Set() pattern.
use axum::{
extract::Request,
middleware::Next,
response::{IntoResponse, Response},
Json,
http::StatusCode,
};
use serde_json::json;
// This is the typed value we pass from middleware to handler.
// In Gin you would store this as c.Set("user", map[string]interface{}{...}).
// In Axum it is a real struct with a real type, verified by the compiler.
// Handlers that declare Extension<AuthenticatedUser> will receive exactly this.
#[derive(Clone)]
pub struct AuthenticatedUser {
pub id: String,
pub token: String,
}
pub async fn auth_middleware(
mut req: Request,
next: Next,
) -> Response {
let token = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match token {
// No token or empty string — return early, just like c.AbortWithStatusJSON.
// next.run() is never called. Downstream handlers are never invoked.
None | Some(ref t) if t.is_empty() => {
return (
StatusCode::UNAUTHORIZED,
Json(json!({ "error": "unauthorized" })),
)
.into_response();
}
Some(token) => {
// Insert the typed user value into the request's extension map.
// This is the equivalent of c.Set("user_id", "user-123").
// The type AuthenticatedUser is the key — only one can exist per request.
req.extensions_mut().insert(AuthenticatedUser {
id: "user-123".to_string(),
token,
});
}
}
// Call the remaining middleware and handler.
// Equivalent to c.Next() after a successful auth check.
next.run(req).await
}
Extracting Middleware-Injected Data in a Handler
Retrieving values that middleware injected is done through the Extension extractor. The type parameter tells Axum which extension to retrieve. If the extension is not present — because the middleware that sets it was not applied to this route, or because it returned early — Axum returns a 500 error. To handle the optional case gracefully, use Option<Extension<T>>.
// src/handlers/protected.rs
//
// The Extension extractor retrieves the typed value that auth_middleware
// inserted into the request extensions. If AuthenticatedUser is not in
// the extensions, Axum returns 500 automatically. This prevents handlers
// from running in a partially-authenticated state, which is a class of bug
// that is easy to introduce with Gin's untyped c.Get() pattern.
use axum::{Extension, Json};
use crate::middleware::auth::AuthenticatedUser;
pub async fn protected_handler(
// This extractor fails with 500 if auth middleware did not inject the value.
// Use Option<Extension<AuthenticatedUser>> if the field is optional.
Extension(user): Extension<AuthenticatedUser>,
) -> Json<serde_json::Value> {
axum::Json(serde_json::json!({ "user": user.id }))
}
Applying Middleware: Global vs Route-Scoped
One of Axum's most powerful features is precise middleware scoping. In Gin, scoping middleware to a group of routes requires creating a RouterGroup and calling group.Use(). In Axum, you apply middleware to a sub-router with .layer(), and it affects only the routes in that router. The top-level .layer() applies globally. This allows you to have a public health check route that bypasses authentication while all other routes require it, without any special-casing in the auth middleware itself.
// src/main.rs (routing and middleware wiring section)
//
// This shows how to apply middleware at different scopes.
// Global middleware wraps the entire app.
// Scoped middleware wraps only a sub-router.
use axum::middleware;
use tower_http::trace::TraceLayer;
use crate::middleware::{auth::auth_middleware, timing::timing_middleware};
fn build_app(state: AppState) -> Router {
// These routes require authentication.
// The auth middleware is applied only to this sub-router.
let protected = Router::new()
.route("/protected", get(protected_handler))
.layer(middleware::from_fn(auth_middleware));
Router::new()
// This public route is NOT wrapped by auth middleware.
.route("/ping", get(ping_handler))
.merge(protected)
// tower_http's TraceLayer produces structured HTTP request/response logs.
// This is the closest equivalent to gin's built-in Logger middleware.
.layer(TraceLayer::new_for_http())
// Our custom timing middleware wraps everything including TraceLayer.
.layer(middleware::from_fn(timing_middleware))
.with_state(state)
}
⚠️ Gotcha #4 — Layer ordering in Axum is the opposite of what Go developers expect, and this is the most common source of middleware ordering bugs. In Gin,
r.Use(A); r.Use(B)means A runs first on every request. In Axum,.layer(A).layer(B)means B runs first. Each new.layer()call wraps the existing service on the outside. The last.layer()call produces the outermost service, and the outermost service handles the request first. Think of it as wrapping a present: the last paper you apply is the outermost layer and the first thing the recipient encounters.
// This is the execution order for:
// Router::new()
// .layer(LoggingLayer) ← added first, runs SECOND on request in
// .layer(AuthLayer) ← added last, runs FIRST on request in
//
// Request path: AuthLayer → LoggingLayer → Handler
// Response path: Handler → LoggingLayer → AuthLayer
//
// To match Gin's r.Use(Auth); r.Use(Logging) ordering:
// Router::new()
// .layer(LoggingLayer) ← Gin's second Use() → Axum's first layer()
// .layer(AuthLayer) ← Gin's first Use() → Axum's second layer()
6. The FIFO Channel Scheduler {#fifo-channel-scheduler}
Many production services need to offload work from the HTTP request path to a background worker. Common examples include sending emails, writing audit logs, calling slow third-party APIs, or processing uploaded files. In Go, this pattern is typically implemented with a buffered channel and a goroutine running a select loop. The channel provides FIFO ordering as a language-level guarantee: messages are delivered to the receiver in the order they were sent, and select allows the loop to simultaneously wait for work and for a shutdown signal.
Rust provides the same pattern through Tokio's mpsc channel and the select! macro. The mechanics are nearly identical in intent but differ in important ways that reflect Rust's ownership model and async execution model. Understanding these differences upfront will save you significant debugging time.
The key semantic difference between Go's select and Tokio's select! is fairness. Go's select with multiple ready cases picks one uniformly at random. Tokio's select! also polls branches in a pseudo-random order by default, but it provides a biased; directive that makes it poll branches in declaration order. This is useful when you want shutdown signals to take priority over queued work, which is the correct behaviour for a graceful shutdown.
Go Pattern: Channel + Select Loop
In Go, the scheduler is typically a struct that owns both the work channel and a stop signal. The Submit method is called from HTTP handlers — it is synchronous from the caller's perspective (it blocks if the channel buffer is full). The Run method is started in a goroutine at startup and loops forever until the stop signal fires.
// worker/scheduler.go
package worker
import (
"context"
"log"
)
type Job struct {
ID string
Payload any
}
type Scheduler struct {
queue chan Job
stop chan struct{}
}
func NewScheduler(bufferSize int) *Scheduler {
return &Scheduler{
queue: make(chan Job, bufferSize),
stop: make(chan struct{}),
}
}
// Submit enqueues a job. Called from HTTP handlers.
// If the buffer is full, this call blocks until space is available.
// For non-blocking submission, use a select with a default case.
func (s *Scheduler) Submit(job Job) {
s.queue <- job
}
// Run is the FIFO worker loop. Start this in a goroutine.
// Jobs are processed in arrival order because Go channels are FIFO.
// The select waits for whichever case is ready: a new job or a stop signal.
func (s *Scheduler) Run(ctx context.Context) {
for {
select {
case job := <-s.queue:
// Processing happens synchronously in the loop,
// which guarantees strict FIFO order for this single worker.
processJob(job)
case <-s.stop:
log.Println("scheduler shutting down")
return
case <-ctx.Done():
log.Println("context cancelled, scheduler stopping")
return
}
}
}
func (s *Scheduler) Stop() {
close(s.stop)
}
func processJob(job Job) {
log.Printf("processing job %s", job.ID)
}
Rust Pattern: tokio::sync::mpsc + select!
The Tokio version of this pattern replaces goroutines with async tasks and channels with Tokio's async-aware channel types. The most important distinction is that Tokio's mpsc::Sender::send() is an async fn — it must be awaited. This is because in an async runtime, "waiting for buffer space" is not a thread block, it is a yield point that allows other tasks to run. The Rust equivalent of Go's blocking channel send is sender.send(job).await.
The worker loop is spawned as a Tokio task using tokio::spawn. Like go func(), this creates a concurrent unit of execution. Unlike a goroutine, a Tokio task is not a thread — it is a future that the runtime polls on its thread pool. Multiple tasks can run on the same OS thread. This means that blocking inside a task (using std::thread::sleep, std::sync::Mutex::lock under contention, or synchronous file I/O) can stall the entire runtime thread and degrade the performance of every other task on that thread.
// src/worker/scheduler.rs
//
// This module implements the FIFO work queue using Tokio's mpsc channel.
// The Sender half is cloned and distributed to HTTP handlers.
// The Receiver half is owned by the background worker task.
// FIFO is guaranteed: mpsc channels deliver messages in send order
// to a single receiver, just like a Go buffered channel.
use tokio::sync::{mpsc, oneshot};
use tokio::select;
use tracing::{info, warn};
#[derive(Debug)]
pub struct Job {
pub id: String,
pub payload: serde_json::Value,
// The oneshot channel here implements the Go "done channel" pattern.
// If the HTTP handler wants to wait for a result, it sends a Sender here.
// The worker sends the result back through it when the job is complete.
// If the handler does not need a result (fire-and-forget), this is None.
pub reply: Option<oneshot::Sender<JobResult>>,
}
#[derive(Debug)]
pub struct JobResult {
pub success: bool,
pub message: String,
}
// Scheduler owns only the Sender. The Receiver is returned separately
// so it can be passed to the background worker task without sharing ownership.
// This pattern prevents the common mistake of trying to receive from multiple
// places, which the type system enforces: Receiver is not Clone.
pub struct Scheduler {
pub sender: mpsc::Sender<Job>,
}
impl Scheduler {
pub fn new(buffer_size: usize) -> (Self, mpsc::Receiver<Job>) {
let (sender, receiver) = mpsc::channel(buffer_size);
(Self { sender }, receiver)
}
}
// The worker loop runs as a Tokio task, not an OS thread.
// It is the direct equivalent of Go's goroutine + for { select {} } pattern.
// The biased; directive in select! makes shutdown take priority over pending jobs,
// which is the correct behaviour for graceful shutdown.
// Without biased;, select! randomly chooses among ready branches, which could
// process jobs indefinitely even after a shutdown signal is received.
pub async fn run_scheduler(
mut receiver: mpsc::Receiver<Job>,
mut shutdown: tokio::sync::broadcast::Receiver<()>,
) {
info!("scheduler started");
loop {
select! {
// biased; makes select! check branches in declaration order.
// Shutdown is checked first. If both shutdown and a job are ready,
// shutdown always wins. This prevents processing new work after
// a shutdown signal is received.
biased;
_ = shutdown.recv() => {
info!("scheduler received shutdown signal");
break;
}
// recv() returns None when all Sender halves have been dropped,
// which naturally signals that no more work will ever arrive.
// This is equivalent to the channel being closed in Go.
job = receiver.recv() => {
match job {
Some(job) => {
process_job(job).await;
}
None => {
warn!("all senders dropped, scheduler exiting");
break;
}
}
}
}
}
info!("scheduler shut down cleanly");
}
// Individual job processing. Because this is async, it can await I/O,
// database calls, or external API requests without blocking the runtime thread.
// The FIFO order is preserved because run_scheduler processes one job at a time
// sequentially within its loop. If you want parallel processing, spawn each
// process_job call as a separate tokio::spawn task — but that sacrifices strict FIFO.
async fn process_job(job: Job) {
info!(job_id = %job.id, "processing job");
// Simulate async work such as a database write or API call
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
let result = JobResult {
success: true,
message: format!("job {} completed", job.id),
};
// Send the result back to the waiting HTTP handler if it is listening.
// If the handler has already given up and dropped the Receiver,
// the send fails silently. This is correct — we do not want the worker
// to panic just because the client disconnected.
if let Some(reply) = job.reply {
let _ = reply.send(result);
}
}
Wiring the Scheduler into the HTTP Server
The scheduler is created in main before the server starts. The Sender half is stored in AppState and distributed to every handler that needs it. The Receiver half is moved into the background task. The broadcast channel provides the shutdown signal — it can have multiple receivers, which means both the scheduler and any other background tasks can all listen for the same shutdown event.
// src/main.rs
//
// Full server bootstrap with scheduler integration.
// This shows the complete startup sequence that Go developers should
// map to their mental model of: init state, start goroutines, start server.
use tokio::sync::broadcast;
use crate::worker::scheduler::{run_scheduler, Scheduler};
use crate::state::AppState;
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
// Broadcast channel for clean shutdown signaling.
// broadcast::Sender can be cloned and distributed to multiple subsystems.
// This is the equivalent of a shared context.WithCancel in Go.
let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
// Create the FIFO work queue.
// Sender goes into AppState. Receiver goes to the background task.
let (scheduler, receiver) = Scheduler::new(100);
// Spawn the scheduler as a background async task.
// This is the equivalent of: go sched.Run(ctx)
tokio::spawn(run_scheduler(receiver, shutdown_rx));
let state = AppState {
job_sender: scheduler.sender.clone(),
};
let app = crate::routes::build_router(state);
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
tracing::info!("listening on {}", addr);
// with_graceful_shutdown allows the server to finish in-flight requests
// before shutting down. The future passed here is awaited, and when it
// resolves, the server stops accepting new connections and waits for
// existing connections to close. This is the equivalent of Go's
// http.Server.Shutdown(ctx) with a done channel.
axum::serve(listener, app)
.with_graceful_shutdown(async move {
tokio::signal::ctrl_c().await.unwrap();
tracing::info!("shutdown signal received");
// Signal all broadcast receivers — the scheduler and any
// other tasks listening on a clone of shutdown_rx.
let _ = shutdown_tx.send(());
})
.await
.unwrap();
}
Submitting Jobs from an HTTP Handler
There are two submission patterns: fire-and-forget (submit and return 202 immediately) and synchronous (submit and wait for a result using a oneshot channel). The latter is the Rust equivalent of Go's done channel pattern — the HTTP handler holds the receiving end of a oneshot channel, the job carries the sending end, and the worker sends through it when complete.
// src/handlers/jobs.rs
//
// Two submission patterns demonstrating fire-and-forget and synchronous variants.
// The synchronous variant uses a oneshot channel, which is the Rust equivalent
// of Go's common pattern: done := make(chan Result, 1); job.done = done; result := <-done
use axum::{extract::State, Json, http::StatusCode};
use tokio::sync::oneshot;
use serde::Deserialize;
use uuid::Uuid;
use crate::{
state::AppState,
worker::scheduler::{Job, JobResult},
};
#[derive(Deserialize)]
pub struct CreateJobRequest {
pub payload: serde_json::Value,
}
// Fire-and-forget: submit the job to the FIFO queue and return 202 immediately.
// The client does not wait for the job to complete.
// If the channel buffer is full, the send fails and we return 503.
// mpsc::Sender::send().await blocks the current task until buffer space opens.
// Use try_send() for the non-blocking equivalent of Go's:
// select { case ch <- job: default: return ErrQueueFull }
pub async fn submit_job(
State(state): State<AppState>,
Json(body): Json<CreateJobRequest>,
) -> StatusCode {
let job = Job {
id: Uuid::new_v4().to_string(),
payload: body.payload,
reply: None,
};
match state.job_sender.send(job).await {
Ok(_) => StatusCode::ACCEPTED,
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
// Synchronous: submit the job and wait for the worker to complete it.
// Uses a oneshot channel so the HTTP handler can await the job result.
// This pattern is useful when the client needs confirmation that the work
// was actually performed, not just that it was queued.
pub async fn submit_job_sync(
State(state): State<AppState>,
Json(body): Json<CreateJobRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
// Create a single-use channel for the reply.
// reply_tx goes into the job. reply_rx stays here to await the result.
let (reply_tx, reply_rx) = oneshot::channel::<JobResult>();
let job = Job {
id: Uuid::new_v4().to_string(),
payload: body.payload,
reply: Some(reply_tx),
};
state.job_sender
.send(job)
.await
.map_err(|_| StatusCode::SERVICE_UNAVAILABLE)?;
// Block this handler task until the worker sends a result.
// The runtime thread is not blocked — other tasks continue to run.
// Equivalent to: result := <-done in Go.
match reply_rx.await {
Ok(result) => Ok(Json(serde_json::json!({
"success": result.success,
"message": result.message,
}))),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
⚠️ Gotcha #5 — You must never block a Tokio async task with synchronous blocking calls. In Go, each goroutine has its own stack and blocking one goroutine is cheap — the runtime creates more. In Tokio, the default multi-threaded scheduler runs tasks on a fixed pool of OS threads (defaulting to one per CPU core). If you call
std::thread::sleep,std::sync::Mutex::lockunder contention, or any synchronous file/network I/O inside an async task, you block that thread and all other tasks scheduled on it stall. Always usetokio::time::sleep,tokio::sync::Mutex, andtokio::fs/tokio::netinside async contexts. For unavoidably blocking operations like calling a blocking C library, usetokio::task::spawn_blockingto run the work on a dedicated blocking thread pool.
7. Request Lifecycle & Extractors {#extractors}
The God Object vs the Typed Contract
Go developers who come to Axum experience the extractor system as both the most unfamiliar and ultimately the most appreciated design choice. In Gin, every handler receives the same *gin.Context regardless of what it actually needs. You call methods on that object to access path parameters, query strings, headers, the body, and the response writer. The context is a mutable god object — it contains everything and can do everything. This is convenient and familiar, but it comes with costs: handlers cannot self-document their dependencies, and nothing prevents a handler from reading the body twice, writing headers after the body, or skipping response finalization.
In Axum, the function signature is the contract. A handler that needs a path parameter, a query parameter, and a JSON body declares all three as typed function arguments. The framework reads that declaration, runs the extraction logic, and either delivers the extracted values to your function or returns an appropriate error response before your function is even called. A handler that only needs the path parameter does not receive anything else — it cannot accidentally read a header that should have been processed in middleware, and it cannot call any method that would be inappropriate at that point in the request lifecycle.
This approach also makes handlers significantly easier to unit test. Instead of constructing a mock *gin.Context with all its internal state, you can call an Axum handler function directly with typed values and assert on its typed return value.
// Gin: every handler gets the full context regardless of what it needs.
// There is no declaration of intent — the reader must inspect the body
// of the function to understand what it actually uses.
func handler(c *gin.Context) {
id := c.Param("id")
page := c.Query("page")
auth := c.GetHeader("Authorization")
var body MyStruct
c.ShouldBindJSON(&body)
c.Header("X-Request-ID", "abc")
c.JSON(200, gin.H{"id": id})
}
// Rust + Axum: the function signature declares exactly what this handler uses.
// You can read the signature and know the complete set of inputs.
// Adding a new dependency is explicit and visible in code review.
use axum::{
extract::{Path, Query, State},
Json,
http::{HeaderMap, StatusCode},
response::IntoResponse,
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct UserQuery {
page: Option<u32>,
}
#[derive(Deserialize)]
struct CreateBody {
name: String,
email: String,
}
#[derive(Serialize)]
struct UserResponse {
id: String,
name: String,
}
// Every parameter here is verified by the compiler.
// State<AppState> requires AppState to be registered with .with_state().
// Path<String> requires a route parameter to be declared in the route string.
// Query<UserQuery> deserializes the query string into the struct.
// HeaderMap gives access to all request headers without parsing overhead.
// Json<CreateBody> reads and deserializes the request body — MUST be last.
async fn full_example_handler(
State(state): State<AppState>,
Path(id): Path<String>,
Query(params): Query<UserQuery>,
headers: HeaderMap,
Json(body): Json<CreateBody>,
) -> Result<Json<UserResponse>, StatusCode> {
let auth = headers
.get("Authorization")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
tracing::info!(id, page = ?params.page, auth, "handling request");
Ok(Json(UserResponse {
id,
name: body.name,
}))
}
// Returning custom response headers requires returning a tuple.
// Axum's IntoResponse is implemented for tuples up to a certain arity.
// (StatusCode, HeaderMap, Json<T>) is the pattern for status + headers + body.
async fn handler_with_headers() -> impl IntoResponse {
let mut headers = HeaderMap::new();
headers.insert(
"X-Request-ID",
"abc-123".parse().unwrap(),
);
(StatusCode::OK, headers, Json(serde_json::json!({"ok": true})))
}
Shared State: From Closures to AppState
In Go, shared state is often passed to handlers via closures — the handler function is defined inside a method on an app struct and closes over the struct's fields. Alternatively, Gin provides a built-in key-value store on the engine. Both approaches use untyped storage under the hood, and the second approach requires passing state through middleware as interface values.
Axum's approach is more explicit. You define a typed state struct, derive Clone on it, pass it to the router with .with_state(), and retrieve it in handlers with the State<AppState> extractor. The clone happens once per request — Axum clones the state to give each handler its own copy. For types that are expensive to clone, wrap them in Arc<T>, which is a reference-counted pointer that makes cloning cheap (it just increments a counter) while sharing the underlying data.
// Go: state via closure or via gin engine key-value store
type App struct {
DB *sql.DB
Cache *redis.Client
Config Config
}
func main() {
app := &App{...}
r := gin.Default()
// Closure captures app — idiomatic for small applications
r.GET("/users", func(c *gin.Context) {
rows, _ := app.DB.Query("SELECT ...")
})
}
// Rust: define a typed state struct with Clone
// src/state.rs
//
// All fields must be Clone because Axum clones AppState for each request.
// Arc<T> is the standard way to share non-Clone or expensive-to-clone data.
// Tokio's PgPool (from sqlx) is already Arc-wrapped internally, so it is
// cheap to clone and safe to share across tasks.
use tokio::sync::mpsc;
use crate::worker::scheduler::Job;
#[derive(Clone)]
pub struct AppState {
// Sender is Clone — cloning creates another handle to the same channel.
pub job_sender: mpsc::Sender<Job>,
pub config: AppConfig,
// Example: pub db: sqlx::PgPool,
// Example: pub cache: Arc<redis::Client>,
}
#[derive(Clone)]
pub struct AppConfig {
pub environment: String,
pub api_key: String,
}
8. Error Handling {#error-handling}
Gin's Imperative Error Model
In Gin, error handling is a series of if err != nil checks followed by early returns with c.AbortWithStatusJSON. The pattern is familiar to every Go developer, but it has a subtle limitation: the error response format is determined at each call site. If ten different handlers return errors, each one decides independently how to format the JSON. Centralising error formatting requires a custom middleware that reads from c.Errors, or a shared helper function that everyone must remember to call.
// Gin: error handling at each call site with AbortWithStatusJSON
func getUser(c *gin.Context) {
id := c.Param("id")
user, err := db.FindUser(id)
if err != nil {
if errors.Is(err, ErrNotFound) {
c.AbortWithStatusJSON(404, gin.H{"error": "not found"})
return
}
c.AbortWithStatusJSON(500, gin.H{"error": "internal error"})
return
}
c.JSON(200, user)
}
Rust + Axum: The IntoResponse Error Type
Axum's error handling model leverages Rust's Result type and the IntoResponse trait to centralise error formatting automatically. You define a custom error enum for your application, implement IntoResponse on it once, and then every handler that returns Result<T, AppError> automatically uses your centralised error formatting logic. There is no AbortWithStatusJSON and no need to remember to call a helper — the type system ensures that every error path produces the same structured response.
The thiserror crate provides derive macros that generate Display and Error implementations from your enum definition, eliminating boilerplate. The #[from] attribute on a variant automatically generates a From implementation, allowing you to use the ? operator to convert lower-level errors into your application error type.
// src/error.rs
//
// Define your application error enum once.
// Implement IntoResponse once.
// Every handler that returns Result<T, AppError> uses this automatically.
// This is the Axum equivalent of a centralised Gin error middleware,
// but enforced by the type system rather than by convention.
use axum::{
response::{IntoResponse, Response},
Json,
http::StatusCode,
};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized")]
Unauthorized,
#[error("bad request: {0}")]
BadRequest(String),
// The #[from] attribute generates From<anyhow::Error> for AppError.
// This lets you use ? on any anyhow::Result inside a handler.
#[error("internal error")]
Internal(#[from] anyhow::Error),
}
// This implementation is called automatically whenever a handler returns
// Err(AppError::SomeVariant). You define the mapping from error variant
// to HTTP status code and response body once, here, and it applies everywhere.
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::Unauthorized => (
StatusCode::UNAUTHORIZED,
"unauthorized".to_string(),
),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Internal(e) => {
// Log the full internal error but do not expose it to the client.
tracing::error!("internal error: {:?}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
"internal server error".to_string(),
)
}
};
let body = Json(serde_json::json!({
"error": message,
"status": status.as_u16(),
}));
(status, body).into_response()
}
}
// Usage in a handler. The return type is the self-documenting contract.
// Ok(T) flows through normally. Err(AppError) triggers into_response() above.
// The ? operator on any Result<T, AppError> propagates errors automatically.
// src/handlers/users.rs
use crate::error::AppError;
pub async fn get_user(
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
if id == "0" {
// This is the equivalent of c.AbortWithStatusJSON(404, ...) in Gin,
// but it composes with the rest of the function via the type system.
return Err(AppError::NotFound(format!("user {} not found", id)));
}
// Imaginary DB call: db.find_user(&id).await?
// The ? converts any DB error into AppError::Internal via From<anyhow::Error>.
Ok(Json(serde_json::json!({ "id": id, "name": "Alice" })))
}
9. Full Working Example {#full-example}
The following is the complete compilable project. Every file shown here is necessary. The structure separates concerns in a way that scales beyond a tutorial into a real application.
rust-server/
├── Cargo.toml
└── src/
├── main.rs
├── state.rs
├── error.rs
├── routes/
│ └── mod.rs
├── handlers/
│ ├── mod.rs
│ ├── health.rs
│ ├── users.rs
│ └── jobs.rs
├── middleware/
│ ├── mod.rs
│ ├── auth.rs
│ └── timing.rs
└── worker/
├── mod.rs
└── scheduler.rs
src/state.rs
use tokio::sync::mpsc;
use crate::worker::scheduler::Job;
#[derive(Clone)]
pub struct AppState {
pub job_sender: mpsc::Sender<Job>,
}
src/error.rs
use axum::{response::{IntoResponse, Response}, Json, http::StatusCode};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
#[error("not found: {0}")]
NotFound(String),
#[error("unauthorized")]
Unauthorized,
#[error("bad request: {0}")]
BadRequest(String),
#[error("internal error")]
Internal(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(m) => (StatusCode::NOT_FOUND, m.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".into()),
AppError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
AppError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}
src/middleware/mod.rs
pub mod auth;
pub mod timing;
src/middleware/timing.rs
use axum::{extract::Request, middleware::Next, response::Response};
use std::time::Instant;
use tracing::info;
pub async fn timing_middleware(req: Request, next: Next) -> Response {
let start = Instant::now();
let method = req.method().clone();
let uri = req.uri().clone();
let response = next.run(req).await;
info!(
method = %method,
uri = %uri,
status = response.status().as_u16(),
duration_ms = start.elapsed().as_millis(),
"request completed"
);
response
}
src/middleware/auth.rs
use axum::{
extract::Request, middleware::Next,
response::{IntoResponse, Response},
Json, http::StatusCode,
};
#[derive(Clone)]
pub struct AuthenticatedUser {
pub id: String,
pub token: String,
}
pub async fn auth_middleware(mut req: Request, next: Next) -> Response {
let token = req.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
match token {
None | Some(ref t) if t.is_empty() => {
return (
StatusCode::UNAUTHORIZED,
Json(serde_json::json!({ "error": "unauthorized" })),
).into_response();
}
Some(token) => {
req.extensions_mut().insert(AuthenticatedUser {
id: "user-123".to_string(),
token,
});
}
}
next.run(req).await
}
src/handlers/mod.rs
pub mod health;
pub mod users;
pub mod jobs;
src/handlers/health.rs
use axum::Json;
pub async fn ping() -> Json<serde_json::Value> {
axum::Json(serde_json::json!({ "message": "pong" }))
}
src/handlers/users.rs
use axum::{extract::Path, Json, http::StatusCode};
use crate::error::AppError;
pub async fn get_user(
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
if id == "0" {
return Err(AppError::NotFound(format!("user {id} not found")));
}
Ok(Json(serde_json::json!({ "id": id, "name": "Alice" })))
}
pub async fn list_users() -> Json<serde_json::Value> {
Json(serde_json::json!({ "users": [] }))
}
src/handlers/jobs.rs
use axum::{extract::State, Json, http::StatusCode};
use tokio::sync::oneshot;
use uuid::Uuid;
use crate::{state::AppState, worker::scheduler::{Job, JobResult}};
use serde::Deserialize;
#[derive(Deserialize)]
pub struct CreateJobRequest {
pub payload: serde_json::Value,
}
pub async fn submit_job(
State(state): State<AppState>,
Json(body): Json<CreateJobRequest>,
) -> StatusCode {
let job = Job {
id: Uuid::new_v4().to_string(),
payload: body.payload,
reply: None,
};
match state.job_sender.send(job).await {
Ok(_) => StatusCode::ACCEPTED,
Err(_) => StatusCode::SERVICE_UNAVAILABLE,
}
}
src/worker/mod.rs
pub mod scheduler;
src/worker/scheduler.rs
use tokio::sync::{mpsc, oneshot};
use tokio::select;
use tracing::{info, warn};
#[derive(Debug)]
pub struct Job {
pub id: String,
pub payload: serde_json::Value,
pub reply: Option<oneshot::Sender<JobResult>>,
}
#[derive(Debug)]
pub struct JobResult {
pub success: bool,
pub message: String,
}
pub struct Scheduler {
pub sender: mpsc::Sender<Job>,
}
impl Scheduler {
pub fn new(buffer_size: usize) -> (Self, mpsc::Receiver<Job>) {
let (sender, receiver) = mpsc::channel(buffer_size);
(Self { sender }, receiver)
}
}
pub async fn run_scheduler(
mut receiver: mpsc::Receiver<Job>,
mut shutdown: tokio::sync::broadcast::Receiver<()>,
) {
info!("scheduler started");
loop {
select! {
biased;
_ = shutdown.recv() => {
info!("scheduler shutting down");
break;
}
job = receiver.recv() => {
match job {
Some(j) => process_job(j).await,
None => { warn!("all senders dropped"); break; }
}
}
}
}
info!("scheduler stopped");
}
async fn process_job(job: Job) {
info!(job_id = %job.id, "processing");
tokio::time::sleep(tokio::time::Duration::from_millis(10)).await;
let result = JobResult { success: true, message: format!("done: {}", job.id) };
if let Some(reply) = job.reply {
let _ = reply.send(result);
}
}
src/routes/mod.rs
use axum::{middleware, routing::{get, post}, Router};
use tower_http::trace::TraceLayer;
use crate::{
handlers::{health, jobs, users},
middleware::{auth::auth_middleware, timing::timing_middleware},
state::AppState,
};
pub fn build_router(state: AppState) -> Router {
let protected = Router::new()
.route("/users", get(users::list_users))
.route("/users/:id", get(users::get_user))
.route("/jobs", post(jobs::submit_job))
.layer(middleware::from_fn(auth_middleware));
Router::new()
.route("/ping", get(health::ping))
.merge(protected)
.layer(middleware::from_fn(timing_middleware))
.layer(TraceLayer::new_for_http())
.with_state(state)
}
src/main.rs
mod error;
mod handlers;
mod middleware;
mod routes;
mod state;
mod worker;
use std::net::SocketAddr;
use tokio::{net::TcpListener, sync::broadcast};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use state::AppState;
use worker::scheduler::Scheduler;
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG")
.unwrap_or_else(|_| "rust_server=debug,tower_http=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let (shutdown_tx, shutdown_rx) = broadcast::channel::<()>(1);
let (scheduler, receiver) = Scheduler::new(100);
tokio::spawn(worker::scheduler::run_scheduler(receiver, shutdown_rx));
let state = AppState {
job_sender: scheduler.sender,
};
let app = routes::build_router(state);
let addr = SocketAddr::from(([0, 0, 0, 0], 8080));
let listener = TcpListener::bind(addr).await.unwrap();
tracing::info!("listening on {}", addr);
axum::serve(listener, app)
.with_graceful_shutdown(async move {
tokio::signal::ctrl_c().await.unwrap();
tracing::info!("shutting down");
let _ = shutdown_tx.send(());
})
.await
.unwrap();
}
10. Gotchas & Common Problems {#gotchas}
The following table consolidates every significant pitfall covered in this guide along with the ones that most commonly appear in Axum codebases written by developers coming from Go. Each row maps a symptom you will encounter to its root cause and a concrete fix.
| # | Problem | Symptom | Fix |
|---|---|---|---|
| 1 | Version mismatches in the Hyper/Axum/tower-http triad | Cryptic trait bound errors mentioning hyper::body::Body
|
Pin all three versions explicitly; check axum changelog before upgrading |
| 2 |
axum::Server removed in 0.7 |
error[E0603]: module 'Server' is private or not found |
Use TcpListener::bind().await and axum::serve()
|
| 3 | Extractor ordering — body extractor not last | Missing trait implementation on handler function | Move Json<T>, Bytes, String, or Form<T> to be the final function parameter |
| 4 | Layer ordering reversed vs Gin | Wrong middleware executes first; auth bypassed | Remember: last .layer() call = outermost = first to run on request |
| 5 | Blocking in async context | Runtime stalls; timeouts under moderate load | Use tokio::sync, tokio::time, tokio::fs; use spawn_blocking for CPU/blocking I/O |
| 6 |
AppState does not implement Clone
|
Compile error when calling .with_state()
|
Derive Clone; wrap non-Clone fields in Arc<T>
|
| 7 | Capturing environment in async middleware closures |
cannot move out of shared reference or lifetime errors |
Clone the Arc outside the closure, clone again inside the async move block |
| 8 |
select! fairness causing starvation |
Shutdown signals ignored under high job load | Use biased; directive so shutdown branch is checked first in every iteration |
Deeper Dive: Gotcha 6 and 7
These two gotchas are closely related and frequently occur together when engineers first try to pass configuration or database connections through middleware. The root cause is that Axum requires AppState to be Clone because it clones the state for each incoming request, and Rust's ownership rules require you to be explicit about how data is shared across async boundaries.
// WRONG: State without Clone will not compile.
// Axum calls state.clone() internally — if Clone is not derived, the compiler
// tells you about a missing trait bound on the with_state() call, which is
// confusing because the error points at with_state, not at your struct.
#[derive(Debug)]
struct AppState {
counter: std::sync::Mutex<u64>, // Mutex is not Clone
}
// RIGHT: Wrap non-Clone data in Arc so cloning is cheap and safe.
// Arc::clone() increments a reference count — it does not copy the data.
// Tokio's Mutex is used instead of std's because it is async-aware.
#[derive(Debug, Clone)]
struct AppState {
counter: std::sync::Arc<tokio::sync::Mutex<u64>>,
}
// WRONG: Capturing state by reference in an async middleware closure.
// Async closures must be 'static — they cannot hold references to
// local variables because the closure may outlive the stack frame.
let config = load_config(); // Config is not Clone or Arc
let mw = move |req: Request, next: Next| async {
println!("{}", config.value); // Error: config may not live long enough
next.run(req).await
};
// RIGHT: Wrap in Arc first, then clone the Arc into the closure.
// The Arc clone is cheap. Each invocation of the closure gets its own
// Arc clone, which points to the same underlying Config allocation.
let config = std::sync::Arc::new(load_config());
let mw = {
let config = config.clone(); // Clone Arc for the outer closure
move |req: Request, next: Next| {
let config = config.clone(); // Clone Arc for each async invocation
async move {
println!("{}", config.value);
next.run(req).await
}
}
};
When to Use from_fn vs Implementing Layer Manually
Most engineers writing application code should use from_fn for the entirety of their middleware needs. The manual Layer + Service implementation path exists for library authors and for cases where you need access to Tower's backpressure mechanism (poll_ready) or where you need to modify the response type in a way that from_fn does not support.
// Use axum::middleware::from_fn when:
// ✓ You need to inspect or modify request headers
// ✓ You need to short-circuit with an early response (auth, rate limiting)
// ✓ You need to time or log requests
// ✓ You need to inject values into request extensions
// ✓ You need to read or modify response headers or status
// ✓ 95% of production application middleware falls here
// Implement tower::Layer + tower::Service manually when:
// ✓ You are writing a reusable library crate (not application code)
// ✓ You need poll_ready() for backpressure-aware behaviour
// ✓ You need to wrap the response Future itself for streaming
// ✓ You need to inspect the inner Service type at layer construction time
11. Cheat Sheet {#cheat-sheet}
This reference table provides the complete vocabulary mapping from Gin to Axum for daily use. Bookmark this section.
```log ┌─────────────────────────────────────┬────────────────────────────────────────────────────┐
│ Go + Gin │ Rust + Axum │
├─────────────────────────────────────┼────────────────────────────────────────────────────┤
│ gin.Default() │ Router::new() + TraceLayer + timing middleware │
│ gin.New() │ Router::new() │
│ r.GET("/path", handler) │ .route("/path", get(handler)) │
│ r.POST("/path", handler) │ .route("/path", post(handler)) │
│ r.Group("/prefix") │ Router::new().nest("/prefix", sub_router) │
│ r.Use(middleware) │ .layer(from_fn(middleware)) │
│ group.Use(middleware) │ sub_router.layer(from_fn(middleware)) │
│ c.Param("id") │ Path(id): Path │
│ c.Query("q") │ Query(p): Query with Option │
│ c.DefaultQuery("page", "1") │ #[serde(default = "fn_name")] on struct field │
│ c.GetHeader("Authorization") │ headers: HeaderMap then headers.get("...") │
│ c.ShouldBindJSON(&v) │ Json(body): Json (must be last) │
│ c.JSON(200, gin.H{}) │ Json(json!({})) or (StatusCode::OK, Json(...)) │
│ c.Status(204) │ StatusCode::NO_CONTENT │
│ c.Header("key", "value") │ Return tuple (StatusCode, HeaderMap, Json) │
│ c.Set("key", val) │ req.extensions_mut().insert(MyTypedValue) │
│ c.Get("key") │ Extension(val): Extension │
│ c.AbortWithStatusJSON(401, ...) │ return (StatusCode::UNAUTHORIZED, Json(...)) │
│ │ .into_response() │
│ c.Next() │ next.run(req).await │
│ make(chan Job, 100) │ mpsc::channel::(100) │
│ go func() { for { select {} } }() │ tokio::spawn(async { loop { select! {} } }) │
│ context.WithCancel │ broadcast::channel + graceful_shutdown │
│ select { case <-ctx.Done(): } │ select! { _ = shutdown.recv() => {} } │
│ select { case ch <- v: default: } │ sender.try_send(v) — returns Err if full │
│ ch <- v (blocking send) │ sender.send(v).await │
│ result := <-doneCh │ reply_rx.await │
│ errors.Is(err, ErrNotFound) │ AppError::NotFound(...) via ? operator │
│ http.StatusOK │ StatusCode::OK │
│ http.StatusCreated │ StatusCode::CREATED │
│ http.StatusNoContent │ StatusCode::NO_CONTENT │
│ http.StatusUnauthorized │ StatusCode::UNAUTHORIZED │
│ http.StatusInternalServerError │ StatusCode::INTERNAL_SERVER_ERROR │
└─────────────────────────────────────┴────────────────────────────────────────────────────┘
---
## Final Thoughts
The transition from Gin to Axum is not a syntax translation exercise. It is a migration from an imperative, runtime-checked programming model to a compositional, compile-time-verified one. Every pattern you relied on in Gin has a precise equivalent in Axum — the handler chain, the shared context, the route groups, the work queue — but the mechanism that implements each pattern is grounded in Rust's type system rather than in runtime conventions.
The three investments that will pay dividends most quickly are these: first, spend time reading the Tower documentation to understand `Service` and `Layer`, even if you only ever use `from_fn`. Comprehending what `from_fn` wraps makes every compiler error in this space legible. Second, embrace the `Result<T, AppError>` return type across all of your handlers and define your `AppError::IntoResponse` implementation early, before you write any domain logic. This single architectural decision eliminates the entire class of inconsistent error response formatting that plagues Gin applications at scale. Third, never fight the borrow checker in async contexts — when you see lifetime errors in middleware or handlers, the answer is almost always an `Arc::clone()` before the async boundary, not an attempt to pass references across it.
Axum's surface area is intentionally smaller than Gin's. It provides the composition primitives and defers the higher-level decisions to you. The crates in the Tower and tower-http ecosystem fill the gap with production-quality implementations of the middleware that Gin provides out of the box. Once the compositional model becomes natural, you will find it more expressive than Gin's imperative model — and the compiler will catch entire classes of configuration bugs that in a Go service would only surface in production under load.
Top comments (0)