Why housing_rs picked Axum over Actix
tower ecosystem vs actor model, sqlx compile-time checks vs Diesel ORM — practical Rust framework selection notes for a mid-size project.
The first technical call on housing_rs was "which Rust web framework". I PoC'd Actix-web, Axum, and Rocket. Axum won. Here are the trade-offs.
What each smells like
Actix-web: actor-model based, perennial benchmark winner. But:
- Actor model is a learning curve for new team members
- Docs lag behind axum
- Past unsafe controversy hurt community confidence
Rocket: elegant API, macro-driven. But:
- async / await landed late (stable in 0.5+)
- middleware ecosystem is weaker than tower's
- heavy macros are unfriendly to IDE tooling
Axum: from the tokio family, built on tower. Pros:
- reusable tower middleware
- elegant type-driven extractor design
- dense docs, fast-growing community
The deciding factor was the tower ecosystem.
tower: Rust's web middleware standard
tower abstracts a Service trait — Request -> Future<Response> — that all middleware speaks. housing_rs uses:
use axum::Router;
use tower_http::{
compression::CompressionLayer,
cors::CorsLayer,
trace::TraceLayer,
timeout::TimeoutLayer,
};
use std::time::Duration;
let app = Router::new()
.route("/api/housing/estimate", post(estimate_handler))
.with_state(state)
// Order: trace → timeout → compression → cors
// Trace first so it covers the entire request lifetime
.layer(TraceLayer::new_for_http())
.layer(TimeoutLayer::new(Duration::from_secs(10)))
.layer(CompressionLayer::new())
.layer(cors);In Actix you'd hunt for an actix-equivalent crate per piece (some exist, some you write yourself), and APIs differ.
sqlx vs Diesel: why sqlx
ORM choices:
- Diesel: mature, type-safe, but macro-heavy, async is third-party
- SeaORM: active-record style, async native, but heavier abstraction
- sqlx: raw SQL + compile-time query check, async native
Picked sqlx because compile-time check is a maintenance superpower for mid-size projects:
use sqlx::PgPool;
pub async fn nearby_pois(
pool: &PgPool,
lat: f64,
lng: f64,
radius_m: f64,
) -> sqlx::Result<Vec<Poi>> {
// sqlx::query_as! validates the SQL against the live DB at cargo check!
// Forget to update a model after a schema change → compile fails.
sqlx::query_as!(
Poi,
r#"
SELECT category, name, ST_Distance(geom, ST_GeogFromText($1)) as distance_m, weight
FROM poi_points
WHERE ST_DWithin(geom, ST_GeogFromText($1), $2)
ORDER BY distance_m
"#,
format!("POINT({lng} {lat})"),
radius_m,
)
.fetch_all(pool)
.await
}Diesel's table! macro requires you to hand-write schema; after migrations you regen with diesel migration, which is easy to miss. With sqlx, SQL is the source of truth. Schema changed? Run cargo sqlx prepare to refresh the cache.
Note: sqlx macros need a reachable DATABASE_URL to compile. In CI, run cargo sqlx prepare first to generate the .sqlx/ cache, then set SQLX_OFFLINE=true.
PostGIS coordinate woes in Rust
sqlx doesn't natively support geography(Point, 4326). housing_rs workaround:
// Cast geom to text in SQL, receive as String in Rust
sqlx::query_as!(
PoiRaw,
r#"
SELECT
id,
category,
name,
ST_AsText(geom::geometry) as "geom_wkt!",
weight
FROM poi_points
"#
)
.fetch_all(pool)
.awaitOr pull in sqlx-postgres-types for proper geography support. housing's traffic is small enough that String parsing overhead is negligible.
A year later
After a year on Axum + sqlx in housing_rs, three reflections:
- tower middleware saved real time: log / cors / trace / rate limit — never had to write any
- sqlx compile-time checks catch bugs precisely: a schema refactor has never blown up SQL in prod
- Axum docs keep maturing: post-0.7 release, Discord questions usually get answered same-day
Same call again, given the chance.
When it's the wrong choice
- Ultra-high-throughput / low-latency trading: Actix-web still wins at the nanosecond level
- Solo prototype: what Express / Fastify ships in 30 minutes takes Rust 2 days
- Team without Rust experience: learning cost is real — assess ROI first
For something like housing_rs (mid-size, long-term, multi-collaborator backend), Axum is the sweet spot.