Why Rust for services?
Interpreted languages feel productive at first. They let you ship features quickly, and the feedback loop is tight. But once a codebase matures, that productivity tends to evaporate. Large Ruby or JavaScript services in production become a game of whack-a-mole: fix one edge case and a new one appears somewhere else. No amount of tests or discipline prevents the slow drip of runtime bugs that need patching over months and years.
The problem is that humans are good at happy paths and terrible at edge conditions. A strict compiler and a discerning type system are tools that force you to confront those edges before your code ever runs. The trade-off is clear: spend more time satisfying the compiler during development, spend less time fixing things in production.
That thesis pushes toward the strict end of the language spectrum. Rust is famous for its uncompromising compiler, and it has been a learning curve around ownership, types, and lifetimes. But the payoff has been real: fewer forgotten edge cases, fewer runtime errors, and refactoring that no longer feels like a high-risk operation.
The framework and the ORM
The service runs on actix-web, a web framework built on actix, an actor library. The model resembles what you get in Erlang, but Rust’s type system adds safety: an actor cannot receive a message it cannot handle because that would be a compile-time error, not a runtime surprise.
The framework has done well in the TechEmpower benchmarks, often sitting alongside C++ and Java implementations at the top of the list. Benchmark code is always a little contrived, but the result still says something: actix-web is fast. It is also young — around six months old at the time of writing — yet it ships with HTTP/2, WebSockets, streaming responses, graceful shutdown, HTTPS, cookies, static file serving, and solid testing infrastructure. Documentation is still rough in places, but the author is prolific and the codebase has been bug-free in practice.
On the database side, diesel is the ORM for Postgres. Its author has spent years in the trenches with Active Record, and a lot of that experience shows up in the design. diesel does not pretend all SQL dialects are the same, uses raw SQL for migrations instead of a custom DSL, and avoids global connection management. It bakes in Postgres features like upsert and jsonb in the core library, and its type-safe DSL gets checked at compile time. Misreference a field, insert into the wrong table, or build an impossible join, and the compiler stops you.
time_helpers::log_timed(&log.new(o!("step" => "upsert_episodes")), |_log| {
Ok(diesel::insert_into(schema::episode::table)
.values(ins_episodes)
.on_conflict((schema::episode::podcast_id, schema::episode::guid))
.do_update()
.set((
schema::episode::description.eq(excluded(schema::episode::description)),
schema::episode::explicit.eq(excluded(schema::episode::explicit)),
schema::episode::link_url.eq(excluded(schema::episode::link_url)),
schema::episode::media_type.eq(excluded(schema::episode::media_type)),
schema::episode::media_url.eq(excluded(schema::episode::media_url)),
schema::episode::podcast_id.eq(excluded(schema::episode::podcast_id)),
schema::episode::published_at.eq(excluded(schema::episode::published_at)),
schema::episode::title.eq(excluded(schema::episode::title)),
))
.get_results(self.conn)
.chain_err(|| "Error upserting podcast episodes")?)
})
Complex SQL does not always fit the DSL. For those cases, Rust’s include_str! macro embeds a file’s contents during compilation, and those contents can be passed to diesel for parameter binding and execution. The query lives in its own .sql file, with proper editor highlighting and the full power of SQL semantics. The loss of compile-time SQL verification is a trade, but the gains are worth it.
WITH expired AS (
SELECT id
FROM directory_search
WHERE retrieved_at < NOW() - $1::interval
LIMIT $2
),
deleted_batch AS (
DELETE FROM directory_search
WHERE id IN (
SELECT id
FROM expired
)
RETURNING id
)
SELECT COUNT(*)
FROM deleted_batch;
A pragmatic concurrency model
actix-web runs on tokio, an event loop library that serves as Rust’s solid concurrency foundation. When the HTTP server starts, actix-web spawns a number of workers equal to the number of logical cores, each on its own thread with its own tokio reactor.
Handlers can return content synchronously. This blocks the underlying reactor until it is done, which suits static views from memory or a health check response:
fn index(req: HttpRequest) -> Bytes {
...
}
Alternatively, HTTP handlers can return a boxed future. This chains asynchronous calls together, keeping the reactor free while a file is read from disk or a response comes back from the database:
fn index(req: HttpRequest) -> Box<Future<Item=HttpResponse, Error=Error>> {
...
}
While waiting on a future’s result, the tokio reactor handles other requests.
Synchronous actors for blocking work
Futures are widespread in Rust, but not universal. diesel does not yet support asynchronous operations, so any database work blocks. Running that directly inside an actix-web handler would lock up the thread’s reactor and stop that worker from serving other traffic until the query finished.
The solution is the synchronous actor, an actor that runs its workload on a dedicated OS thread. The SyncArbiter abstraction starts several copies of the actor, each sharing a message queue so work can be distributed to the group via its addr. No one waits on the result synchronously — callers get a future that represents the outcome, and they can do other work as well in the meantime.
// Start 3 `DbExecutor` actors, each with its own database
// connection, and each in its own thread
let addr = SyncArbiter::start(3, || {
DbExecutor(SqliteConnection::establish("test.db").unwrap())
});
In practice, fast workloads like parameter parsing and view rendering remain in the handler. Database operations dispatch a message to a synchronous actor, then free the HTTP worker to serve other traffic. When the future resolves, the worker renders a response and sends it back to the waiting client.
Limits as a feature
Synchronous actors cap parallelism, but that limit works in your favor. Postgres has strict limits on simultaneous connections — larger instances on Heroku or GCP cap out at 500, and a small GCP database can be limited to 25. Coarse connection management schemes tend to slap PgBouncer in front of the problem. With synchronous actors, however, the actor count implies the maximum number of connections, providing exact control over connection usage.
Connections are checked out from an r2d2 pool when work begins and returned when it finishes. An idle service — starting up, shutting down, or steady state — uses no connections. This contrasts with many web frameworks that open a connection at worker startup and hold it for the worker’s lifetime, which roughly doubles connection demand during a graceful restart.
Speed with pragmatism
Synchronous code cannot outrun a purely asynchronous approach, but it is much easier to write. Composing futures properly is time-consuming, and compiler errors from broken compositions are bewildering. Ergonomics count for a lot when core domain logic still needs to ship.
And “slower” here is relative. The synchronous actor model still uses real parallelism, and the honest comparison for most services is against an interpreted world with thread limits and a non-compacting GC. Against those, it is not just faster — it is shedding layers of infrastructure problem-solving. The database will still be the practical bottleneck, and the synchronous actor model delivers about as much parallelism as the database can handle, while keeping maximum throughput for paths that never touch it.
Error handling that reaches the user
Nearly every API in this service returns a Result, and futures carry their own variant of it. Errors are defined with error-chain. Most are internal, but a distinct group is marked as explicitly user-facing:
error_chain!{
errors {
//
// User errors
//
BadRequest(message: String) {
description("Bad request"),
display("Bad request: {}", message),
}
}
}
Whenever a failure needs to surface to the user, it is mapped to one of the user error types:
Params::build(log, &request).map_err(|e|
ErrorKind::BadRequest(e.to_string()).into()
)
Handling those user errors becomes a clean composition: wait on the synchronous actor, attempt to build an HTTP response, then hand any failure to a renderer. Note that then is used here rather than and_then — then receives a Result whether the preceding future succeeded or failed, whereas and_then only chains on success:
let message = server::Message::new(&log, params);
// Send message to synchronous actor
sync_addr
.send(message)
.and_then(move |actor_response| {
// Transform actor response to HTTP response
}
.then(|res: Result<HttpResponse>|
server::transform_user_error(res, render_user_error)
)
.responder()
Errors that are not meant for the user are logged, and actix-web converts them into a 500 Internal server error. A custom renderer for those may follow later. The transform_user_error helper is generic: it accepts a render function, which lets the same logic serve both a JSON API and an HTML-rendering web server.
pub fn transform_user_error<F>(res: Result<HttpResponse>, render: F) -> Result<HttpResponse>
where
F: FnOnce(StatusCode, String) -> Result<HttpResponse>,
{
match res {
Err(e @ Error(ErrorKind::BadRequest(_), _)) => {
// `format!` activates the `Display` traits and shows our error's `display`
// definition
render(StatusCode::BAD_REQUEST, format!("{}", e))
}
r => r,
}
}
Middleware and request state
actix-web supports middleware like most web frameworks. The example below initializes a per-request logger and stores it in the request's extensions — a map of request state that lives for the duration of the request:
pub mod log_initializer {
pub struct Middleware;
pub struct Extension(pub Logger);
impl<S: server::State> actix_web::middleware::Middleware<S> for Middleware {
fn start(&self, req: &mut HttpRequest<S>) -> actix_web::Result<Started> {
let log = req.state().log().clone();
req.extensions().insert(Extension(log));
Ok(Started::Done)
}
fn response(
&self,
_req: &mut HttpRequest<S>,
resp: HttpResponse,
) -> actix_web::Result<Response> {
Ok(Response::Done(resp))
}
}
/// Shorthand for getting a usable `Logger` out of a request.
pub fn log<S: server::State>(req: &mut HttpRequest<S>) -> Logger {
req.extensions().get::<Extension>().unwrap().0.clone()
}
}
A notable design choice is that middleware state is keyed by type, not by string (as in Ruby's Rack). This provides compile-time checks so a key can't be mistyped, and it also controls modularity: removing pub from Extension makes the logger private, and the compiler will reject access from any other module.
Asynchronous middleware
Middleware follows the same async pattern as handlers: returning a future rather than a Result is allowed. That would enable a rate limiter to query Redis without blocking the HTTP worker thread.
Testing the full HTTP stack
actix-web offers several testing methodologies. The approach used here builds a minimal app with TestServerBuilder around a single handler, then fires a request at it. The tests stay small while still exercising an end-to-end slice of the HTTP stack, which makes them quick and thorough:
#[test]
fn test_handler_graphql_get() {
let bootstrap = TestBootstrap::new();
let mut server = bootstrap.server_builder.start(|app| {
app.middleware(middleware::log_initializer::Middleware)
.handler(handler_graphql_get)
});
let req = server
.client(
Method::GET,
format!("/?query={}", test_helpers::url_encode(b"{podcast{id}}")).as_str(),
)
.finish()
.unwrap();
let resp = server.execute(req.send()).unwrap();
assert_eq!(StatusCode::OK, resp.status());
let value = test_helpers::read_body_json(resp);
// The `json!` macro is really cool:
assert_eq!(json!({"data": {"podcast": []}}), value);
}
The final line relies on serde_json's json! macro — the standard Rust JSON library. The inline JSON is raw syntax, not a string: json! validates it at compile time and turns it into a Rust structure. For testing HTTP JSON responses, this is the most elegant approach seen in any language.
Is Rust ready for resilient services?
An equivalent service in Ruby would have taken roughly a tenth of the time. Part of that is Rust's learning curve, but not all of it — the language itself is concise, yet satisfying the compiler is frequently slow and frustrating.
Still, there is a consistent payoff after clearing that final hurdle: the program runs exactly as intended on the first try, reminiscent of Haskell. An interpreted language often works on the 15th attempt, and even then, edge cases are likely still wrong. Rust also enables large refactors — moving a thousand lines at a time and having the program work perfectly afterward is normal. That level of confidence is rare: production-scale interpreted services are typically refactored in tiny, low-risk increments.
Whether a new web service should be written in Rust is not yet a settled question, but it deserves consideration.



