Skip to content

Production-oriented REST API with Axum

You need an HTTP API, not a framework demo. This blueprint keeps the application small enough to understand while making the boundaries explicit: router construction, shared state, request and response types, HTTP errors, tests, process signals, and logs are separate concerns.

The repository example is compiled, tested, linted, and formatted in CI on stable Rust. It currently pins Axum 0.8.9, Tokio 1.53.1, Serde 1.0.229, tracing 0.1.44, and tracing-subscriber 0.3.23.

  • an Axum router returned from a normal function
  • typed application state
  • JSON request and response bodies
  • a small application error type converted into HTTP responses
  • a health endpoint
  • in-process route tests through Tower’s service interface
  • JSON tracing output with runtime filtering
  • Ctrl+C and Unix SIGTERM shutdown handling
  • Axum graceful server shutdown

It deliberately does not claim to solve authentication, authorization, persistence, migrations, rate limiting, TLS termination, CORS policy, secrets management, or deployment. Those are application and infrastructure decisions, not decorations to add to a five-route sample.

Keep router construction separate from process startup

Section titled “Keep router construction separate from process startup”

The application router is a value. Build it in a function and pass its dependencies in. Do not bury route construction inside main().

EXAMPLE: CI-CHECKEDTEST ASSERTED · examples/axum-backend/src/lib.rs#blueprint-axum-router
pub fn app(state: AppState) -> Router {
    Router::new()
        .route("/health", get(health))
        .route("/users", post(create_user))
        .route("/users/{id}", get(get_user))
        .with_state(state)
}

This shape matters for two reasons. First, handlers receive the same typed AppState through Axum’s State extractor. Second, tests can construct the exact same router without binding a TCP port.

Axum documents State as global state for the router. Request-derived values such as authenticated identity belong in request extensions or extractors instead.

A handler should make it clear what comes from the request and what can come back.

EXAMPLE: CI-CHECKEDTEST ASSERTED · examples/axum-backend/src/lib.rs#blueprint-axum-handler
async fn create_user(
    State(state): State<AppState>,
    Json(input): Json<CreateUser>,
) -> Result<(StatusCode, Json<User>), ApiError> {
    let name = input.name.trim();
    if name.is_empty() {
        return Err(ApiError::InvalidName);
    }

    let user = User {
        id: state.inner.next_id.fetch_add(1, Ordering::Relaxed) + 1,
        name: name.to_owned(),
    };

    state
        .inner
        .users
        .write()
        .await
        .insert(user.id, user.clone());
    Ok((StatusCode::CREATED, Json(user)))
}

The Json extractor consumes the request body, so it belongs after extractors that only use request parts. Axum rejects malformed or incompatible JSON before the handler runs.

The example trims the supplied name, rejects an empty value, creates a record, and returns 201 Created. The in-memory store exists only to make the state and test flow real. Replace it with a repository or database layer when persistence enters the design.

Convert application errors at the HTTP boundary

Section titled “Convert application errors at the HTTP boundary”

Do not scatter status-code tuples through every handler. Give the application error a single HTTP representation.

EXAMPLE: CI-CHECKEDTEST ASSERTED · examples/axum-backend/src/lib.rs#blueprint-axum-error
impl IntoResponse for ApiError {
    fn into_response(self) -> Response {
        let (status, message) = match self {
            Self::InvalidName => (StatusCode::UNPROCESSABLE_ENTITY, "name must not be empty"),
            Self::NotFound => (StatusCode::NOT_FOUND, "user not found"),
        };

        (status, Json(ErrorBody { error: message })).into_response()
    }
}

Axum handlers may return Result<T, E> when both T and E can become responses. In this example, ApiError owns the mapping from an application failure to a status code and JSON body.

That does not mean every internal error should be exposed to a client. Database errors, stack traces, credentials, and implementation details should normally be logged internally and mapped to an intentionally boring public response.

Axum routers implement Tower’s service abstraction. That lets a test send a request directly into the router with oneshot().

EXAMPLE: CI-CHECKEDTEST ASSERTED · examples/axum-backend/src/lib.rs#blueprint-axum-test
let response = app(AppState::default())
    .oneshot(
        Request::builder()
            .uri("/health")
            .body(Body::empty())
            .unwrap(),
    )
    .await
    .unwrap();

assert_eq!(response.status(), StatusCode::OK);
assert_eq!(decode_json(response).await, json!({ "status": "ok" }));

This is fast and deterministic. Use it for route behavior, status codes, headers, extractor behavior, and response bodies.

It is not a substitute for every integration test. If your application depends on a database, message broker, reverse proxy behavior, or TLS, test those boundaries separately with the real dependency.

The binary should assemble process-level infrastructure and then run the router.

EXAMPLE: CI-CHECKEDCOMPILED · examples/axum-backend/src/main.rs#blueprint-axum-serve
let listener = TcpListener::bind("0.0.0.0:3000").await?;
tracing::info!(address = %listener.local_addr()?, "server listening");

axum::serve(listener, app(AppState::default()))
    .with_graceful_shutdown(shutdown_signal())
    .await?;

axum::serve intentionally exposes a simple server API. If you need lower-level HTTP server tuning, the Axum documentation recommends moving to Hyper or hyper-util rather than forcing configuration into axum::serve.

The shutdown future is kept outside the router because OS signals are process concerns. The dedicated Tokio graceful shutdown recipe explains the signal handling and its limits.

The example initializes structured JSON tracing before the listener starts. That means startup, shutdown, and request instrumentation can all share the same subscriber.

See structured tracing for Rust services for the checked subscriber setup and the distinction between logs, spans, and exported telemetry.

Suggested project split when the service grows

Section titled “Suggested project split when the service grows”

Do not create ten layers on day one. Split only when a boundary becomes real.

A practical next step is:

src/
main.rs process startup, listener, signals
lib.rs app() and shared public application types
routes/
users.rs
health.rs
error.rs HTTP-facing application errors
state.rs shared application dependencies

When persistence appears, add a repository or storage module. When authentication appears, add an authentication boundary. Do not call a folder services until you can explain what belongs there and what does not.

Before calling a real deployment production-ready, decide these separately:

Boundary Question to answer
persistence What survives a process restart, and how are migrations applied?
secrets Where do credentials come from and how are they rotated?
shutdown What happens to background tasks, queues, and open database work?
timeouts Which outbound and inbound operations may run forever?
observability Which events, metrics, and traces let you diagnose a failure?
proxy/TLS Which layer owns HTTPS, client IPs, and request size limits?
abuse controls Where do rate limits and payload limits live?
auth Which component proves identity and which component authorizes actions?

The checked sample gives you a clean HTTP application seam. The rest should be added because your service needs it, not because an architecture diagram had empty boxes.