Why Axum & Tonic
Type-safe APIs in Rust: HTTP and gRPC, unified
The Problem
APIs are the interface to your system. Every bug in your API layer—incorrect response types, missing error handling, malformed data—becomes a bug for every consumer.
Traditional API frameworks hide the truth.
Express, Flask, FastAPI—they make the happy path easy and the error path invisible. You return JSON, and TypeScript consumers assume types that may not match reality. Runtime validation patches over the gap, but the gap remains.
We needed API frameworks that:
- Make the API contract explicit and enforced at compile time
- Handle both HTTP (REST) and gRPC from the same codebase
- Share middleware, authentication, and error handling across protocols
- Integrate with the async Rust ecosystem (Tokio)
Current Options
| Option | Pros | Cons |
|---|---|---|
| Actix WebFast, battle-tested Rust web framework. |
|
|
| Axum + TonicHTTP and gRPC on unified Tower middleware. |
|
|
| WarpFilter-based composable web framework. |
|
|
Future Outlook
Tower is becoming the standard middleware layer for async Rust networking.
HTTP and gRPC are converging.
Modern systems need both: HTTP for public APIs and web clients, gRPC for internal services and streaming. Running separate stacks means duplicate authentication, logging, rate limiting.
Axum and Tonic share Tower middleware. Write authentication once, use it everywhere. The same tracing, rate limiting, and error handling works across protocols.
Type-safe extractors are the future.
Axum's extractor pattern—declaring what you need in handler signatures—makes invalid states unrepresentable. If a handler compiles, the request was valid.
Our Decision
✓Why we chose this
- Unified middlewareTower middleware works for both HTTP and gRPC. One auth layer, one logging setup.
- Type-safe extractorsHandlers declare their inputs; extraction is automatic and type-checked.
- Native asyncBuilt on Tokio. No adapters, no runtime conflicts.
- Ergonomic routingRouter composition is intuitive. Nested routes, fallbacks, and layers work as expected.
×Trade-offs we accept
- Tower learning curveUnderstanding Service, Layer, and the tower ecosystem takes time.
- Error handling complexityUnified error types across extractors and handlers require design thought.
- Ecosystem maturityFewer off-the-shelf middlewares than Express or Rails.
Motivation
We use both HTTP and gRPC. HTTP for public APIs consumed by web and mobile clients. gRPC for internal services where schema enforcement and streaming matter.
Running separate stacks was duplicative. Two authentication implementations. Two logging setups. Two ways to handle errors.
Axum and Tonic unified this. Same Tower middleware handles auth for both. Same tracing captures spans for HTTP and gRPC requests. Same error types work everywhere.
The Tower ecosystem means we can compose behaviors: rate limiting + auth + logging + compression, all as layers that apply to any service.
Recommendation
Use Axum for HTTP APIs. The extractor pattern makes handlers clean and type-safe.
Use Tonic for gRPC services, especially for:
- Internal service-to-service communication
- Streaming (bidirectional if needed)
- Strong schema enforcement with protobuf
Share Tower middleware between them:
- Authentication (JWT validation, API keys)
- Logging and tracing (OpenTelemetry integration)
- Rate limiting
- Compression
Start simple. Add middleware as needs arise. The layered architecture makes incremental enhancement easy.
Examples
use axum::{
extract::{Path, State, Json},
routing::{get, post},
Router,
};
use tower_http::trace::TraceLayer;
// Type-safe extractors: if it compiles, the request was valid
async fn get_user(
State(db): State<Database>,
Path(user_id): Path<UserId>,
) -> Result<Json<User>, ApiError> {
let user = db.get_user(user_id).await?;
Ok(Json(user))
}
async fn create_user(
State(db): State<Database>,
Json(request): Json<CreateUserRequest>,
) -> Result<Json<User>, ApiError> {
let user = db.create_user(request).await?;
Ok(Json(user))
}
pub fn router(db: Database) -> Router {
Router::new()
.route("/users/:id", get(get_user))
.route("/users", post(create_user))
.layer(TraceLayer::new_for_http())
.with_state(db)
}Axum's extractors (State, Path, Json) are type-checked at compile time. The handler signature declares what it needs; Axum provides it or returns an error.