# Production-oriented REST API with Axum

> A checked Axum 0.8 service skeleton with typed state, JSON handlers, HTTP error responses, in-process tests, structured tracing, and graceful shutdown.

HTML: https://rustprint.com/blueprints/axum-rest-api/

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.

## What this blueprint includes

- 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

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

**Router with typed application state**

```rust
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.

## Make the HTTP contract visible

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

**JSON handler with state and a typed result**

```rust
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

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

**Application errors converted with IntoResponse**

```rust
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.

## Test the router without opening a socket

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

**In-process health route test**

```rust
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.

## Keep process startup small

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

**Bind, serve, and attach graceful shutdown**

```rust
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](/recipes/graceful-shutdown-tokio/index.md) explains the signal handling and its limits.

## Logging is not an afterthought

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](/recipes/structured-tracing/index.md) for the checked subscriber setup and the distinction between logs, spans, and exported telemetry.

## 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:

```text
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.

## Production boundary checklist

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.

## Sources

- [axum serve](https://docs.rs/axum/0.8.9/axum/fn.serve.html)
- [axum State extractor](https://docs.rs/axum/0.8.9/axum/extract/struct.State.html)
- [axum IntoResponse](https://docs.rs/axum/0.8.9/axum/response/trait.IntoResponse.html)
- [axum Json](https://docs.rs/axum/0.8.9/axum/struct.Json.html)

## Related RustPrint guides

- [Graceful shutdown with Tokio and Axum](/recipes/graceful-shutdown-tokio/index.md): Handle Ctrl+C and SIGTERM, trigger Axum graceful shutdown, and understand what still needs explicit cancellation in background tasks.
- [Structured tracing for a Rust service](/recipes/structured-tracing/index.md): Initialize tracing-subscriber with runtime filtering and newline-delimited JSON output, then keep logs, spans, and exported telemetry conceptually separate.
- [How RustPrint verifies code and technical claims](/methodology/index.md): What SOURCE-BACKED and CI-CHECKED mean, what RustPrint actually validates, and which claims the verification system does not make.
