Skip to content
ExponaBLOG
RUST PROGRAMMING · DAY 083 / 100

Rust developer roadmap: a practical guide for developers

What to learn, in order.

Expona Admin9 min read

Introduction

We've reached day 83 of our 100-day Rust journey, and by now you've likely encountered enough friction to wonder: what's the right order to learn all this? The Rust developer roadmap: a practical guide for developers exists precisely because Rust's learning curve isn't linear—mastering ownership before traits, or async before error handling, fundamentally changes how well everything else clicks into place. This article maps out the core domains you need to conquer, in the order that makes the most sense for production work.

Foundation: Ownership, Borrowing, and Lifetimes

Everything in Rust rests on three pillars: ownership (who controls memory), borrowing (temporary access), and lifetimes (how long that access lasts). Most developers struggle here initially, so let's be clear: this is non-negotiable. You cannot write meaningful Rust without internalizing these concepts.

Ownership is straightforward: each value has exactly one owner. When that owner goes out of scope, the value is dropped. Here's the simplest example:

rust
fn main() {
    let s = String::from("hello");
    println!("{}", s);
    // s goes out of scope here, memory is freed
}

The hard part comes when you move ownership:

rust
let s1 = String::from("hello");
let s2 = s1;  // ownership moves to s2
// println!("{}", s1);  // error: s1 no longer owns the value
println!("{}", s2);     // ok

Borrowing lets you access a value without taking ownership. Use & for immutable borrows and &mut for mutable borrows:

rust
fn main() {
    let mut s = String::from("hello");
    let len = calculate_length(&s);  // immutable borrow
    println!("'{}' has length {}", s, len);
    
    modify_string(&mut s);  // mutable borrow
    println!("{}", s);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

fn modify_string(s: &mut String) {
    s.push_str(" world");
}

The compiler enforces one rule: you can have either multiple immutable borrows or one mutable borrow at any point in time. This rule prevents data races entirely.

Lifetimes annotate how long a borrow is valid. In most code, Rust infers them. You only annotate when the compiler can't figure it out alone. Here's a real scenario from API handlers:

rust
struct User {
    name: String,
}

// Compiler needs explicit lifetimes here
fn get_name_ref<'a>(user: &'a User) -> &'a str {
    &user.name
}

fn main() {
    let user = User {
        name: "Alice".to_string(),
    };
    let name = get_name_ref(&user);
    println!("{}", name);  // ok, user still alive
}

The annotation <'a> tells the compiler: "the returned reference lives as long as the input reference." This prevents dangling pointers.

Structuring Data: Structs, Enums, Traits, and Generics

Once ownership feels natural, you can model real-world domains cleanly.

Structs bundle related data together. They're straightforward, but the key is designing them with ownership in mind:

rust
struct Order {
    id: u64,
    items: Vec<String>,  // takes ownership of the vec
    total: f64,
}

impl Order {
    fn new(id: u64) -> Self {
        Order {
            id,
            items: Vec::new(),
            total: 0.0,
        }
    }

    fn add_item(&mut self, item: String) {
        self.items.push(item);
    }
}

fn main() {
    let mut order = Order::new(1);
    order.add_item("Widget".to_string());
}

Enums represent choices and are far more powerful than in other languages because they can hold associated data:

rust
enum OrderStatus {
    Pending,
    Processing { started_at: u64 },
    Shipped { tracking_number: String },
    Cancelled(String),  // reason
}

impl OrderStatus {
    fn is_complete(&self) -> bool {
        matches!(self, OrderStatus::Shipped { .. })
    }
}

fn handle_status(status: OrderStatus) {
    match status {
        OrderStatus::Pending => println!("Waiting to process"),
        OrderStatus::Processing { started_at } => println!("Started at {}", started_at),
        OrderStatus::Shipped { tracking_number } => println!("Track: {}", tracking_number),
        OrderStatus::Cancelled(reason) => println!("Cancelled: {}", reason),
    }
}

Traits define shared behavior across types. Think of them as interfaces with implementations. In production, you'll use traits for dependency injection:

rust
trait Database {
    fn get_user(&self, id: u64) -> Option<User>;
}

struct PostgresDb;

impl Database for PostgresDb {
    fn get_user(&self, id: u64) -> Option<User> {
        // real implementation
        None
    }
}

fn fetch_user(db: &dyn Database, id: u64) -> Option<User> {
    db.get_user(id)
}

Generics let you write code that works across types without repetition:

rust
struct Cache<T> {
    data: Vec<T>,
}

impl<T: Clone> Cache<T> {
    fn get(&self, index: usize) -> Option<T> {
        self.data.get(index).cloned()
    }
}

The trait bound T: Clone says "T must implement Clone." This pattern appears constantly in real codebases.

Handling Reality: Error Handling, Iterators, and Testing

Error handling in Rust is explicit via Result<T, E>. There's no hidden null or exception; you handle errors or crash deliberately:

rust
fn parse_order_id(input: &str) -> Result<u64, String> {
    input
        .parse::<u64>()
        .map_err(|_| "Invalid order ID".to_string())
}

fn main() {
    match parse_order_id("123") {
        Ok(id) => println!("Order: {}", id),
        Err(e) => eprintln!("Error: {}", e),
    }
    
    // Or use the ? operator in functions returning Result
    let result = parse_and_process("123")?;
}

fn parse_and_process(input: &str) -> Result<String, String> {
    let id = parse_order_id(input)?;  // ? propagates errors
    Ok(format!("Processing order {}", id))
}

Iterators replace for loops and are central to Rust's expressiveness. They're lazy and composable:

rust
fn main() {
    let numbers = vec![1, 2, 3, 4, 5];
    
    let result: Vec<i32> = numbers
        .iter()
        .filter(|n| n % 2 == 0)
        .map(|n| n * n)
        .collect();
    
    println!("{:?}", result);  // [4, 16]
    
    // Consuming iterators
    let sum: i32 = numbers.iter().sum();
    println!("Sum: {}", sum);
}

Testing is baked into the language. Write tests alongside your code:

rust
pub fn add(a: i32, b: i32) -> i32 {
    a + b
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_add() {
        assert_eq!(add(2, 2), 4);
        assert_ne!(add(2, 2), 5);
    }

    #[test]
    #[should_panic]
    fn test_panic() {
        panic!("This test expects a panic");
    }
}

Run with cargo test. In production systems, you'll also want integration tests in a tests/ directory.

Building Services: Async, Axum, SQLx, and Deployment

Async is where Rust shines for concurrent I/O. Use async fn and await with an executor like Tokio:

rust
use tokio::task;

async fn fetch_data(id: u64) -> String {
    // Simulating async work (database query, HTTP call, etc.)
    tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
    format!("Data for {}", id)
}

#[tokio::main]
async fn main() {
    let handle1 = task::spawn(fetch_data(1));
    let handle2 = task::spawn(fetch_data(2));

    let result1 = handle1.await.unwrap();
    let result2 = handle2.await.unwrap();
    
    println!("{}, {}", result1, result2);
}

Axum is a modern web framework built on Tokio. Here's a minimal handler:

rust
use axum::{
    routing::get,
    Router, Json,
};

async fn get_order(axum::extract::Path(id): axum::extract::Path<u64>) -> Json<String> {
    Json(format!("Order {}", id))
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/orders/:id", get(get_order));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    
    axum::serve(listener, app).await.unwrap();
}

SQLx provides compile-time checked SQL queries with async support:

rust
use sqlx::PgPool;

#[sqlx::FromRow]
struct Order {
    id: i64,
    status: String,
}

async fn fetch_order(pool: &PgPool, id: i64) -> Result<Order, sqlx::Error> {
    sqlx::query_as::<_, Order>("SELECT id, status FROM orders WHERE id = $1")
        .bind(id)
        .fetch_one(pool)
        .await
}

#[tokio::main]
async fn main() {
    let pool = PgPool::connect("postgres://user:pass@localhost/db").await.unwrap();
    let order = fetch_order(&pool, 1).await.unwrap();
    println!("{:?}", order);
}

Deployment starts with a simple Docker image:

rust
FROM rust:latest as builder
WORKDIR /app
COPY . .
RUN cargo build --release

FROM debian:bookworm-slim
COPY --from=builder /app/target/release/myapp /usr/local/bin/
CMD ["myapp"]

Push to any container registry and deploy on Kubernetes, ECS, or a VPS. Rust binaries are statically linked when possible, making deployment straightforward.

Common Mistakes

  • Fighting the borrow checker early on. Don't reach for clone() or Rc<T> as first resorts. Understand why the compiler is rejecting your code; it's teaching you something.
  • Overusing lifetimes. Most code doesn't need explicit lifetime annotations. If you're writing <'a, 'b, 'c>, you've probably got a design problem.
  • Treating Result as Optional. Result is not Option. Use Result when operations can fail; use Option when values may or may not exist.
  • Spawning tasks without understanding backpressure. Async is not free. If you spawn thousands of tasks, you'll exhaust memory. Use semaphores or bounded channels.
  • Skipping tests for async code. Async tests require setup (usually with #[tokio::test]). Don't skip them; they catch subtle bugs.

Key Takeaways

  • Master ownership, borrowing, and lifetimes before moving forward. Everything else builds on this foundation.
  • Structs, enums, traits, and generics let you model domains cleanly and safely. Use them liberally.
  • Explicit error handling via Result, iterators for transformation, and tests for confidence are non-negotiable in production.
  • Async/await with Tokio, Axum for HTTP, and SQLx for databases give you a complete toolkit for services.
  • The roadmap isn't one-way; you'll loop back to deepen understanding as you build real systems. Learn to read compiler errors as guidance, not obstacles.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles