The Two Rusts: Why Application Code Feels Nothing Like the Tutorials
Source: lobsters
Every language has a gap between the version described in introductory posts and the version people actually write at work. Rust has an unusually wide one, and most of the frustration in the community comes from that gap being invisible.
The learning materials, conference talks, and blog posts that define Rust’s public reputation are almost entirely about library code: allocator-generic collections, zero-copy parsers, crates designed to accept borrowed slices so callers can avoid allocation. That work has legitimate constraints. A crate that forces callers to allocate unnecessarily is a bad crate. The borrow checker annotations, lifetime parameters, and &str-over-String conventions exist for real reasons when your code ships as a dependency.
Application code has none of those constraints. Your binary is the final consumer. Nobody will wrap it in another crate. Nobody needs your internal fn parse_config to accept &'a str with a named lifetime so they can store a reference alongside the source buffer. You can own everything and the compiler will still prevent every class of memory safety bug that makes C programs difficult.
This is the argument the original article by hamy puts clearly: Rust has two modes, and the one that’s painful to learn is largely unnecessary for application developers.
Owned Types Change the Calculus
The borrow checker exists to prevent use-after-free errors, data races, and iterator invalidation without a garbage collector. It does this by tracking which code owns data and enforcing that borrows don’t outlive the owner. In library code, you often want to lend data to callers rather than copy it, which requires annotating how long the borrow lives.
In application code, you can sidestep the entire annotation system by owning your data:
// Library style — correct for libraries
fn first_word<'a>(s: &'a str) -> &'a str {
s.split_whitespace().next().unwrap_or("")
}
// Application style — no annotations, same safety guarantees
fn first_word(s: String) -> String {
s.split_whitespace().next().unwrap_or("").to_string()
}
The owned version allocates an extra String. In a tight loop over millions of records that matters. In a Discord bot that parses a command argument, it doesn’t. The borrow checker still guarantees no use-after-free; you’ve just traded a nanosecond of allocation for eliminating an entire category of compilation friction.
This isn’t a hack or a compromise. Cloning is a first-class operation in Rust. The language designers anticipated application code that clones freely. The cost model is well-defined: String::clone copies bytes, Vec::clone copies elements, and anything you derive Clone on copies fields. There’s no hidden indirection.
Arc and Mutex for Shared State
The second source of lifetime friction in tutorials is shared mutable state across threads or async tasks. The borrow checker, correctly, prevents two threads from mutating data simultaneously without synchronization. Library code often solves this with lifetime parameters that prove exclusive access at compile time. Application code can reach for Arc<Mutex<T>> instead:
use std::sync::{Arc, Mutex};
use std::collections::HashMap;
let cache: Arc<Mutex<HashMap<String, String>>> =
Arc::new(Mutex::new(HashMap::new()));
let cache_clone = Arc::clone(&cache);
tokio::spawn(async move {
let mut map = cache_clone.lock().unwrap();
map.insert("key".to_string(), "value".to_string());
});
The data-race freedom guarantee is fully preserved. Arc provides reference-counted shared ownership; Mutex provides runtime-checked exclusive access. The compiler still prevents you from accessing the data without holding the lock. The difference from low-level Rust is that the enforcement happens through runtime locking rather than compile-time lifetime reasoning. For a Discord bot’s config store or an HTTP server’s connection registry, this is the right trade.
Arc::clone costs roughly 10-15ns from an atomic increment. For a cache lookup on a cached config value, this is noise.
Error Handling Without the Boilerplate
Standard Rust error handling requires defining error enums, implementing Display, implementing From<OtherError> for each error type you want to convert. For library code this is right: callers need stable, matchable error variants. For application code, anyhow eliminates the entire machinery:
use anyhow::{Context, Result};
async fn load_config(path: &str) -> Result<Config> {
let content = tokio::fs::read_to_string(path)
.await
.with_context(|| format!("failed to read {path}"))?;
let config: Config = toml::from_str(&content)
.context("config file contains invalid TOML")?;
Ok(config)
}
The ? operator propagates errors up the call stack. with_context attaches human-readable messages. anyhow::Result erases the error type entirely, which is fine when the caller is your own main function printing to stderr rather than a downstream crate matching on variants. The error chain you get in output is more useful than what most custom error enums produce anyway.
What Changed in 2018
A significant share of Rust’s reputation was established by writing from the 2015 edition and earlier. Non-Lexical Lifetimes, introduced in the 2018 edition, replaced the original lexical borrow scope rules with dataflow-based analysis. Under the original rules, a borrow lasted until the end of the enclosing block, even if the borrow was used much earlier. NLL tracks actual usage, which means a large class of programs that required explicit annotation or restructuring simply compile without change under modern Rust.
The Polonius project on nightly extends this further with a Datalog-based rewrite of the borrow checker. Patterns that currently require lifetime annotations are expected to compile under Polonius without them. The language is still moving in the direction of reducing annotation burden.
Developers learning from pre-2019 tutorials, StackOverflow answers, or blog posts are working with a meaningfully harder version of the language than current stable Rust.
The Ecosystem Meets You Where You Are
The crate ecosystem around application Rust has matured in a way that reinforces the high-level style. clap v4 generates CLI argument parsers from plain Rust structs via derive macros; missing required fields are compile errors, not panics at argument-parsing time. serde handles serialization and deserialization for essentially any format with #[derive(Serialize, Deserialize)]. sqlx type-checks queries against your actual schema at compile time without an ORM.
These crates were designed with application ergonomics as the primary concern. They compose well together. The productivity multiplier from building with them is measurable: maintaining a project that depends on five of these crates involves writing only domain logic; all infrastructure decisions are already made.
What Actually Remains Hard
Honesty requires noting what high-level application style doesn’t fix.
Async Rust is rough at the edges regardless of how you’re using it. Async functions in traits only stabilized in Rust 1.75 in December 2023. Async Drop still doesn’t exist on stable. Dynamic dispatch in async contexts requires Pin<Box<dyn Future + Send>> machinery that looks nothing like the synchronous equivalent. Tokio’s docs are good, but Send bound errors in async code require understanding the executor model to debug effectively.
Compile times are universally painful and don’t improve with a higher-level coding style. A mid-sized Rust project with clean build times in the 45-90 second range is typical. The mold linker reduces link times 2-5x on Linux; the experimental Cranelift backend shows 30-50% faster debug builds. Neither closes the gap to Go’s near-instantaneous build times.
The organizational cost of Rust adoption is real even in the high-level style. Code review requires reviewers who can reason about ownership semantics, even if the code doesn’t use explicit lifetime annotations. The reviewer pool is smaller than for Go or Python. Ramping up new contributors takes longer.
Compared to Go
The natural comparison for application-level Rust is Go, not C++. Go’s concurrency model (goroutines, channels, garbage collector) is genuinely excellent and the build tooling is fast. Where Go costs you is the type system: nil pointers, no sum types, if err != nil repeated throughout every function, no exhaustive pattern matching.
High-level Rust offers Option<T> instead of nullable pointers, Result<T, E> instead of (value, error) tuple conventions, match with exhaustiveness checking instead of switch statements, and traits with real static dispatch instead of interface boxing. These benefits exist completely independently of the borrow checker. You get them whether you’re writing a kernel module or a Discord bot.
For teams that already know Go well and want Go’s simplicity, Lisette is an emerging experiment in Rust syntax on a Go runtime, essentially trading Rust’s memory model for Rust’s type system. It’s early, but the concept illustrates what developers actually want from each language.
For most application programmers, the evidence from the 2025 State of Rust Survey suggests the language has matured past the point where the borrow checker is the dominant friction. The pain has moved to async, compile times, and organizational adoption. Those are real problems, but they’re problems of a working ecosystem, not a broken one. The high-level style described in hamy’s article isn’t a workaround; it’s how most Rust application code is written today.