· 7 min read ·

Owned Everything: Writing Productive Rust Without the Lifetime Wars

Source: lobsters

There’s a version of Rust that gets written in tutorials and another that gets written in production. The tutorial version carefully manages &str versus String, threads borrowed slices through function signatures, and treats every .clone() call as a small architectural failure. The production version reaches for owned types everywhere, wraps shared state in Arc<Mutex<T>>, and clones without hesitation when the alternative is several days fighting lifetime annotations.

A recent piece on hamy.xyz makes the case that this second style is not a compromise but a legitimate strategy: you capture roughly 80% of Rust’s practical value with 20% of the friction. From building Discord bots in Rust and doing occasional systems work, that framing holds up, with some caveats about where it breaks down.

The Two Separable Things Rust Offers

Before getting into patterns, it helps to be clear about what Rust actually provides. There are two separable things going on in the language: a type system with sum types, exhaustive matching, Option<T>, Result<T, E>, and trait-based polymorphism; and a memory model enforced at compile time through the borrow checker and lifetime annotations.

The type system layer is available in essentially every style of Rust you write. The borrow checker layer is where friction lives. High-level Rust is the style that fully engages the type system while deliberately minimizing borrow checker surface area.

You still get enum with data, match expressions the compiler enforces as exhaustive, Option<T> instead of nullable values, and Result<T, E> with ? propagation for clean error handling. What you largely avoid is borrowing references across function boundaries, storing references in structs, and writing explicit lifetime annotations.

That’s a real trade; the borrow checker exists because it catches real bugs: use-after-free, iterator invalidation, data races. For many application-level programs, though, those are not the bugs you’re hitting. When the primary source of errors is business logic rather than memory management, fighting the borrow checker for theoretical purity carries a cost that doesn’t always justify itself.

The Owned-Type Patterns

The core of high-level Rust is a set of type choices that consistently sidestep lifetime annotations.

Use String instead of &str in struct fields and return types. The moment you store a &str in a struct, you need a lifetime annotation on the struct, and that annotation propagates to every function that creates, stores, or passes the struct. String is heap-allocated and owned; it lives as long as the struct lives, goes wherever you put it, and requires zero annotation.

// Lifetime annotation required, and it propagates everywhere:
struct User<'a> {
    name: &'a str,
    email: &'a str,
}

// No annotation required:
struct User {
    name: String,
    email: String,
}

Use Vec<T> in return types rather than &[T]. A function returning &[T] is borrowing from something, and the caller must reason about what that something is and how long it lives. A function returning Vec<T> transfers ownership cleanly; the caller owns the data and there is nothing left to reason about. For function arguments, &str and &[T] remain appropriate because you are only reading data that someone else owns; the rule of thumb is: if data enters a function and stays there or gets returned, prefer owned types.

This pattern extends naturally to HashMap. Using String keys instead of &str eliminates the need to track which string backing store a map’s keys borrow from. The cost is a heap allocation per key on insertion, which is generally the right default for application-level code.

// &str keys require careful lifetime management:
fn build_index<'a>(items: &'a [Item]) -> HashMap<&'a str, &'a Item> { ... }

// String keys require nothing beyond ownership:
fn build_index(items: Vec<Item>) -> HashMap<String, Item> { ... }

Shared State with Arc

The other pillar of high-level Rust is Arc<T> for shared ownership. In a Discord bot, the typical pattern is a state struct holding database connections, configuration, and caches, passed to every event handler. In Go, you’d use a struct with pointer semantics. In Rust, you use Arc<BotState>:

#[derive(Clone)]
struct BotState {
    db: Arc<Database>,
    config: Arc<Config>,
    cache: Arc<Mutex<HashMap<String, CachedUser>>>,
}

async fn handle_message(state: Arc<BotState>, msg: Message) -> Result<()> {
    let content = msg.content.clone();
    let author = msg.author.name.clone();

    let user = state.db.get_user(&author).await?;

    let mut cache = state.cache.lock().await;
    cache.insert(author, CachedUser::from(user));

    Ok(())
}

Arc::clone() does not copy the underlying data. It increments an atomic reference count, which costs roughly 1-5 nanoseconds on modern hardware. The Database and Config objects live in heap memory; every handler gets a cheap pointer to them.

This resembles garbage-collected semantics, and in a narrow sense it is: reference counting is a GC strategy. The distinction from a tracing GC is that deallocation in Arc is deterministic. Data is freed immediately when the last reference is dropped, not eventually when a collector decides to run. That matters for resources like file handles, database connections, and locks, which need prompt cleanup. In Go’s model you’d use defer to force cleanup at scope exit; Rust’s Drop trait handles it automatically at the right moment, without explicit annotation at every call site.

The Real Cost of .clone()

The concern with liberal .clone() calls is that they accumulate into meaningful overhead. In practice, this depends entirely on what you’re cloning and what your program spends its time doing.

String::clone() is a heap allocation proportional to the string length. For typical Discord message content, that’s somewhere between 10 and a few hundred bytes. On Linux with the default allocator, small allocations under several hundred bytes are served from per-thread arenas; the actual cost is in the range of 10-50 nanoseconds per call. For large buffers or high-frequency allocations in a tight loop, this accumulates; for event-driven handlers processing one message at a time, it doesn’t register.

For a Discord bot, the network round-trip to Discord’s API runs 50-200 milliseconds. A database query adds another 1-10 milliseconds. The cost of cloning a message struct is measured in nanoseconds. The allocation cost lives several orders of magnitude below the actual bottleneck, and no amount of borrow-checker discipline changes the fact that the work is I/O-bound.

The calculation changes for different workloads. The Embassy embedded async framework operates at the opposite extreme: no heap at all, with tasks statically allocated at compile time. That’s the right approach for a microcontroller with 64KB of RAM processing real-time sensor data. It is not the right approach for a Discord bot running on a server with gigabytes of available memory, and treating those two contexts as equivalent leads to unnecessarily complex code.

Where This Breaks Down

High-level Rust is appropriate for most application code and breaks down in specific, predictable places.

Zero-copy parsing is the clearest case. Reading large files and cloning their contents into owned Strings for processing is fine for occasional reads; for a hot path processing thousands of records per second, borrowing from the source buffer with lifetimes is worth the annotation cost. Libraries like nom and winnow are built around borrowed slices precisely because the alternative is too expensive at scale. This is the domain where borrow checker discipline pays measurable dividends.

Cyclic data structures are another hard case. Arc<T> cannot collect cycles. If Arc<A> holds an Arc<B> that holds a reference back to A, both objects leak until the process exits. The fix is Weak<T> for back-references, which requires understanding the ownership graph precisely, the kind of reasoning high-level Rust defers. For most application code, restructuring to avoid cycles is the better path anyway, but it’s worth knowing the limitation exists.

High-level Rust also gives up some temporal correctness guarantees. A Vec<T> cloned before mutation is safe, but cloning once and then accidentally holding an outdated copy is not something the compiler will catch. The borrow checker would reject this with references; owned types do not provide the same guarantee. This is a genuine reduction in what the compiler can verify, not just a performance trade.

The Rust annual survey consistently names lifetime annotations and borrow checker friction as the top pain points. Many experienced Rust developers write owned-type-heavy code because they’ve learned to save that complexity for when it pays, not because they’re avoiding a feature they haven’t mastered. The choice is deliberate.

The Practical Case

For web services, CLI tools, Discord bots, and general-purpose backend work, high-level Rust is a reasonable default position. The type system benefits are fully available. Performance is substantially better than garbage-collected languages for compute-intensive work, and allocation overhead is irrelevant for I/O-bound workloads. The code is readable without deep knowledge of lifetime mechanics, which matters when you’re reading it six months later or when someone else needs to change it.

The deeper borrow checker discipline, zero-copy APIs, and explicit lifetime annotations remain available whenever profiling says you need them. Starting in high-level Rust and optimizing specific hot paths as they appear is a cleaner workflow than writing maximally borrowed code throughout and accumulating complexity where it was never needed.

The article’s 80/20 framing is a useful heuristic. The 80% of value consists of: memory safety, no null pointer dereferences, forced error handling with Result, exhaustive pattern matching, deterministic resource cleanup, and solid async support. The 20% of pain you’re absorbing is mostly allocation overhead that the profiler never points to. The remaining 20% of value, squeezed from the last 80% of effort, is real and sometimes necessary. Knowing which 20% you actually need for your specific problem is the whole game.

Was this interesting?