The shape of an axum service that stays maintainable
axum is a thin layer over tower and hyper, and it is deliberately unopinionated about how you
structure an application. That is good and it means the first version you write will be wrong.
Here is the shape I arrived at.
Handlers are functions of their inputs
An axum handler is an ordinary async function whose parameters are extractors — each one pulls something from the request:
async fn create_note(
State(state): State<AppState>,
Path(user_id): Path<Uuid>,
Query(params): Query<ListParams>,
headers: HeaderMap,
Json(payload): Json<CreateNote>,
) -> Result<Json<Note>, AppError> {
let note = state.notes.create(user_id, payload).await?;
Ok(Json(note))
}
The ordering rule that catches everyone: Json must be last. Extractors that consume the body
implement FromRequest rather than FromRequestParts, and only one can be last. The error when you
get this wrong is a trait bound failure about Handler that does not mention body extraction at all.
One error type, converted at the boundary
The single biggest improvement to my first version:
pub enum AppError {
NotFound,
Unauthorized,
Validation(String),
Internal(anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound => (StatusCode::NOT_FOUND, "not found".to_string()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "unauthorized".to_string()),
AppError::Validation(message) => (StatusCode::BAD_REQUEST, message),
AppError::Internal(error) => {
tracing::error!(%error, "internal error");
(StatusCode::INTERNAL_SERVER_ERROR, "internal error".to_string())
}
};
(status, Json(json!({ "error": message }))).into_response()
}
}
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(error: E) -> Self {
AppError::Internal(error.into())
}
}
That From implementation is what makes ? work on any error inside a handler. Everything unhandled
becomes a 500, and — importantly — the detail is logged rather than returned. Leaking a database
error message to a client is a real security problem and the easy default.
State by composition, not by one big struct
My first version had an AppState with eight fields, and every handler took the whole thing.
#[derive(Clone)]
struct AppState {
db: PgPool,
cache: Arc<Cache>,
config: Arc<Config>,
}
FromRef lets a handler extract only the part it needs:
impl FromRef<AppState> for PgPool {
fn from_ref(state: &AppState) -> Self {
state.db.clone()
}
}
async fn list_notes(State(db): State<PgPool>) -> Result<Json<Vec<Note>>, AppError> { … }
The handler’s signature now documents its dependencies, and it is testable with a pool rather than a whole application state.
Tip
State must be Clone, and it is cloned per request. Wrap anything expensive in Arc — cloning
a PgPool is cheap by design because it is an Arc internally, but a config struct with owned
Strings is not.
Middleware is tower layers
let app = Router::new()
.route("/notes", get(list_notes).post(create_note))
.route("/notes/{id}", get(get_note).delete(delete_note))
.layer(
ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(30)))
.layer(CompressionLayer::new())
.layer(CorsLayer::permissive()),
)
.with_state(state);
Layers apply bottom-up in ServiceBuilder, so TraceLayer first means it wraps everything and
sees the final response — which is what you want for logging.
Route-specific middleware goes on a nested router:
let protected = Router::new()
.route("/me", get(current_user))
.layer(middleware::from_fn_with_state(state.clone(), require_auth));
let app = Router::new()
.merge(public_routes)
.nest("/api", protected);
The file layout
src/
main.rs — startup, config, migrations, serve
routes/
mod.rs — the router
notes.rs — handlers only
domain/
notes.rs — business logic, no axum types
error.rs
state.rs
The rule that matters: domain/ must not import axum. Business logic that returns Json<T> or
takes State<_> cannot be tested without a request, and cannot be reused from a CLI or a background
worker. Handlers are a thin translation layer — extract, call domain, wrap the result.
That separation is the thing I got wrong first and the thing that made the second version maintainable.
Graceful shutdown
Easy to skip and it matters in production:
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
async fn shutdown_signal() {
let ctrl_c = async { signal::ctrl_c().await.unwrap() };
let terminate = async {
signal::unix::signal(signal::unix::SignalKind::terminate())
.unwrap().recv().await;
};
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
}
Without it, a deployment kills in-flight requests. With it, the server stops accepting new connections and finishes what it is doing — which is the difference between a rolling deploy nobody notices and one that produces a spike of 502s.