DEV Community

Cover image for Rust Concepts: Serde, Error Handling, Benchmarking & Workspaces (Part 6)
mihir mohapatra
mihir mohapatra

Posted on

Rust Concepts: Serde, Error Handling, Benchmarking & Workspaces (Part 6)

This is Part 6 — the final part of the Core Rust Concepts series.

  • Part 1 — Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
  • Part 2 — Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
  • Part 3 — Macros, Modules, Testing, Unsafe Rust, FFI
  • Part 4 — Threads, Channels, Send/Sync, Mutex, Atomics, clap
  • Part 5 — dyn Trait, Deref, Drop, Custom Iterators, Axum REST API

Table of Contents

  1. Serde — Serialization Deep Dive
  2. Error Handling with thiserror and anyhow
  3. Benchmarking with Criterion
  4. Workspaces & Multi-Crate Projects
  5. Writing a Real-World CLI + Library Crate

30. Serde — Serialization Deep Dive

serde is Rust's de-facto serialization framework. It supports JSON, TOML, YAML, MessagePack, Bincode, and dozens of other formats — all from the same #[derive] annotations.

Setup

[dependencies]
serde       = { version = "1", features = ["derive"] }
serde_json  = "1"
toml        = "0.8"
Enter fullscreen mode Exit fullscreen mode

Basic serialize / deserialize

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u32,
    name: String,
    email: String,
    active: bool,
}

fn main() {
    let user = User {
        id: 1,
        name: String::from("Alice"),
        email: String::from("alice@example.com"),
        active: true,
    };

    // Struct → JSON string
    let json = serde_json::to_string_pretty(&user).unwrap();
    println!("{json}");

    // JSON string → Struct
    let json_str = r#"{"id":2,"name":"Bob","email":"bob@example.com","active":false}"#;
    let parsed: User = serde_json::from_str(json_str).unwrap();
    println!("{:?}", parsed);
}
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "id": 1,
  "name": "Alice",
  "email": "alice@example.com",
  "active": true
}
Enter fullscreen mode Exit fullscreen mode

Field attributes

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct Article {
    #[serde(rename = "article_id")]
    id: u32,

    title: String,

    #[serde(skip_serializing_if = "Option::is_none")]
    subtitle: Option<String>,

    #[serde(default)]
    published: bool,

    #[serde(skip)]
    internal_notes: String, // never included in JSON

    #[serde(rename = "created_at")]
    created: String,
}

fn main() {
    let article = Article {
        id: 42,
        title: String::from("Learning Rust"),
        subtitle: None,             // skipped in output
        published: true,
        internal_notes: String::from("draft"), // skipped
        created: String::from("2026-01-01"),
    };

    println!("{}", serde_json::to_string_pretty(&article).unwrap());
}
Enter fullscreen mode Exit fullscreen mode

Output:

{
  "article_id": 42,
  "title": "Learning Rust",
  "published": true,
  "created_at": "2026-01-01"
}
Enter fullscreen mode Exit fullscreen mode

Enums with serde

use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
enum Event {
    UserCreated { user_id: u32, name: String },
    OrderPlaced { order_id: u32, total: f64 },
    PageViewed(String),
}

fn main() {
    let events = vec![
        Event::UserCreated { user_id: 1, name: String::from("Alice") },
        Event::OrderPlaced { order_id: 99, total: 49.95 },
        Event::PageViewed(String::from("/home")),
    ];

    for e in &events {
        println!("{}", serde_json::to_string(e).unwrap());
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

{"type":"UserCreated","data":{"user_id":1,"name":"Alice"}}
{"type":"OrderPlaced","data":{"order_id":99,"total":49.95}}
{"type":"PageViewed","data":"/home"}
Enter fullscreen mode Exit fullscreen mode

Custom serialize / deserialize

use serde::{Deserialize, Deserializer, Serialize, Serializer};

#[derive(Debug)]
struct Rgb(u8, u8, u8);

impl Serialize for Rgb {
    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
        s.serialize_str(&format!("#{:02X}{:02X}{:02X}", self.0, self.1, self.2))
    }
}

impl<'de> Deserialize<'de> for Rgb {
    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
        let s = String::deserialize(d)?;
        let hex = s.trim_start_matches('#');
        let r = u8::from_str_radix(&hex[0..2], 16).map_err(serde::de::Error::custom)?;
        let g = u8::from_str_radix(&hex[2..4], 16).map_err(serde::de::Error::custom)?;
        let b = u8::from_str_radix(&hex[4..6], 16).map_err(serde::de::Error::custom)?;
        Ok(Rgb(r, g, b))
    }
}

fn main() {
    let color = Rgb(255, 128, 0);
    let json = serde_json::to_string(&color).unwrap();
    println!("{json}"); // "#FF8000"

    let back: Rgb = serde_json::from_str(&json).unwrap();
    println!("{:?}", back); // Rgb(255, 128, 0)
}
Enter fullscreen mode Exit fullscreen mode

Working with dynamic JSON (serde_json::Value)

use serde_json::{json, Value};

fn main() {
    // Build JSON with the json! macro
    let payload = json!({
        "name": "Alice",
        "scores": [98, 87, 95],
        "meta": { "verified": true }
    });

    // Navigate dynamically
    println!("{}", payload["name"]);             // "Alice"
    println!("{}", payload["scores"][0]);         // 98
    println!("{}", payload["meta"]["verified"]);  // true

    // Merge two JSON objects
    let mut base: Value = json!({ "a": 1, "b": 2 });
    let extra: Value    = json!({ "b": 99, "c": 3 });

    if let (Value::Object(base_map), Value::Object(extra_map)) = (&mut base, extra) {
        base_map.extend(extra_map);
    }

    println!("{}", serde_json::to_string_pretty(&base).unwrap());
    // { "a": 1, "b": 99, "c": 3 }
}
Enter fullscreen mode Exit fullscreen mode

31. Error Handling with thiserror and anyhow

Rust's Result<T, E> is powerful but verbose when you have many error types. Two crates solve this cleanly — thiserror for library authors, anyhow for application code.

[dependencies]
thiserror = "1"
anyhow    = "1"
Enter fullscreen mode Exit fullscreen mode

thiserror — define your own error types

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("user not found: id={0}")]
    UserNotFound(u32),

    #[error("database error: {0}")]
    Database(#[from] std::io::Error),

    #[error("invalid email: {email}")]
    InvalidEmail { email: String },

    #[error("permission denied for user {user_id} on resource {resource}")]
    PermissionDenied { user_id: u32, resource: String },
}

fn find_user(id: u32) -> Result<String, AppError> {
    if id == 0 {
        return Err(AppError::UserNotFound(id));
    }
    Ok(format!("user_{id}"))
}

fn validate_email(email: &str) -> Result<(), AppError> {
    if !email.contains('@') {
        return Err(AppError::InvalidEmail { email: email.to_string() });
    }
    Ok(())
}

fn main() {
    match find_user(0) {
        Ok(u)  => println!("found: {u}"),
        Err(e) => println!("error: {e}"), // "error: user not found: id=0"
    }

    if let Err(e) = validate_email("notanemail") {
        println!("{e}"); // "invalid email: notanemail"
    }
}
Enter fullscreen mode Exit fullscreen mode

anyhow — ergonomic error handling in applications

use anyhow::{anyhow, bail, Context, Result};
use std::fs;

fn read_config(path: &str) -> Result<String> {
    let content = fs::read_to_string(path)
        .with_context(|| format!("failed to read config from '{path}'"))?;

    if content.is_empty() {
        bail!("config file '{path}' is empty");
    }

    Ok(content)
}

fn parse_port(s: &str) -> Result<u16> {
    s.parse::<u16>()
        .map_err(|_| anyhow!("'{}' is not a valid port number (0–65535)", s))
}

fn main() -> Result<()> {
    // anyhow::Result as the return type of main — shows a clean error message
    let config = read_config("config.toml")
        .context("startup failed")?;

    println!("config loaded: {} bytes", config.len());

    let port = parse_port("8080")?;
    println!("listening on port {port}");

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

thiserror vs anyhow — when to use which

thiserror anyhow
Best for Libraries Applications / binaries
Error type Typed, structured enum Opaque anyhow::Error
Callers can match on variants
Context chaining Manual .context() built-in
Boilerplate Low Minimal

Combining both

// In your library: typed errors with thiserror
#[derive(Debug, thiserror::Error)]
pub enum DbError {
    #[error("connection failed: {0}")]
    Connection(String),
    #[error("query failed: {0}")]
    Query(String),
}

// In your binary: use anyhow to wrap everything
fn run() -> anyhow::Result<()> {
    let result: Result<(), DbError> = Err(DbError::Connection("timeout".into()));
    result?; // DbError converts into anyhow::Error automatically
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

32. Benchmarking with Criterion

criterion is the standard Rust benchmarking library. It runs each benchmark many times, measures variance, detects regressions, and generates HTML reports.

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name    = "my_benchmark"
harness = false
Enter fullscreen mode Exit fullscreen mode

Basic benchmark

// benches/my_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 0,
        1 => 1,
        n => fibonacci(n - 1) + fibonacci(n - 2),
    }
}

fn fibonacci_iter(n: u64) -> u64 {
    let (mut a, mut b) = (0u64, 1u64);
    for _ in 0..n { (a, b) = (b, a + b); }
    a
}

fn bench_fibonacci(c: &mut Criterion) {
    let mut group = c.benchmark_group("fibonacci");

    group.bench_function("recursive n=20", |b| {
        b.iter(|| fibonacci(black_box(20)))
    });

    group.bench_function("iterative n=20", |b| {
        b.iter(|| fibonacci_iter(black_box(20)))
    });

    group.finish();
}

criterion_group!(benches, bench_fibonacci);
criterion_main!(benches);
Enter fullscreen mode Exit fullscreen mode
cargo bench
Enter fullscreen mode Exit fullscreen mode

Sample output:

fibonacci/recursive n=20  time: [123.45 µs 124.12 µs 124.89 µs]
fibonacci/iterative n=20  time: [  1.23 ns   1.24 ns   1.25 ns]

Performance has improved: iterative is 100000x faster.
Enter fullscreen mode Exit fullscreen mode

Benchmarking with input sizes

use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};

fn sum_vec(v: &[i32]) -> i32 { v.iter().sum() }

fn bench_sum(c: &mut Criterion) {
    let mut group = c.benchmark_group("vec_sum");

    for size in [100, 1_000, 10_000, 100_000] {
        let data: Vec<i32> = (0..size).collect();

        group.bench_with_input(
            BenchmarkId::new("sum", size),
            &data,
            |b, v| b.iter(|| sum_vec(black_box(v))),
        );
    }

    group.finish();
}

criterion_group!(benches, bench_sum);
criterion_main!(benches);
Enter fullscreen mode Exit fullscreen mode

What black_box does

black_box(x) prevents the compiler from optimizing away the computation. Without it, the compiler might realize the result is unused and eliminate the benchmark entirely, giving you falsely fast timings.

// ❌ Compiler may optimize this to nothing
b.iter(|| fibonacci(20));

// ✅ Forces the computation to actually run
b.iter(|| fibonacci(black_box(20)));
Enter fullscreen mode Exit fullscreen mode

33. Workspaces & Multi-Crate Projects

As projects grow, you split them into multiple crates in a Cargo workspace. All crates share one Cargo.lock and one target/ directory — so they share compiled dependencies and build faster.

Workspace layout

my_project/
├── Cargo.toml          ← workspace root
├── crates/
│   ├── core/           ← shared library
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── lib.rs
│   ├── cli/            ← binary using core
│   │   ├── Cargo.toml
│   │   └── src/
│   │       └── main.rs
│   └── api/            ← web server using core
│       ├── Cargo.toml
│       └── src/
│           └── main.rs
Enter fullscreen mode Exit fullscreen mode

Root Cargo.toml

# Cargo.toml (workspace root)
[workspace]
members = [
    "crates/core",
    "crates/cli",
    "crates/api",
]
resolver = "2"

# Shared dependency versions across all crates
[workspace.dependencies]
serde     = { version = "1", features = ["derive"] }
tokio     = { version = "1", features = ["full"] }
anyhow    = "1"
thiserror = "1"
Enter fullscreen mode Exit fullscreen mode

core/Cargo.toml

[package]
name    = "core"
version = "0.1.0"
edition = "2021"

[dependencies]
serde     = { workspace = true }
thiserror = { workspace = true }
Enter fullscreen mode Exit fullscreen mode

cli/Cargo.toml

[package]
name    = "cli"
version = "0.1.0"
edition = "2021"

[dependencies]
core   = { path = "../core" }
anyhow = { workspace = true }
clap   = { version = "4", features = ["derive"] }
Enter fullscreen mode Exit fullscreen mode

Sharing types across crates

// crates/core/src/lib.rs
use serde::{Deserialize, Serialize};
use thiserror::Error;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
    pub id: u32,
    pub name: String,
    pub email: String,
}

#[derive(Debug, Error)]
pub enum CoreError {
    #[error("user not found: {0}")]
    NotFound(u32),
    #[error("invalid input: {0}")]
    InvalidInput(String),
}

pub fn find_user(id: u32) -> Result<User, CoreError> {
    if id == 0 {
        return Err(CoreError::NotFound(id));
    }
    Ok(User { id, name: format!("user_{id}"), email: format!("user{id}@example.com") })
}
Enter fullscreen mode Exit fullscreen mode
// crates/cli/src/main.rs
use clap::Parser;
use core::{find_user, CoreError};

#[derive(Parser)]
struct Cli {
    #[arg(short, long)]
    id: u32,
}

fn main() {
    let cli = Cli::parse();
    match find_user(cli.id) {
        Ok(user)  => println!("Found: {} <{}>", user.name, user.email),
        Err(CoreError::NotFound(id)) => eprintln!("No user with id {id}"),
        Err(e)    => eprintln!("Error: {e}"),
    }
}
Enter fullscreen mode Exit fullscreen mode

Useful workspace commands

# Build all crates
cargo build --workspace

# Test all crates
cargo test --workspace

# Run a specific crate's binary
cargo run -p cli -- --id 42

# Check all without building
cargo check --workspace

# Lint everything
cargo clippy --workspace

# Format everything
cargo fmt --all
Enter fullscreen mode Exit fullscreen mode

34. Writing a Real-World CLI + Library Crate

Putting it all together — a filestat tool that analyzes text files and exposes its logic as a reusable library.

Library: crates/filestat-core/src/lib.rs

use serde::Serialize;
use std::collections::HashMap;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum StatError {
    #[error("could not read file '{path}': {source}")]
    Io { path: String, #[source] source: std::io::Error },
    #[error("file '{0}' is empty")]
    Empty(String),
}

#[derive(Debug, Serialize)]
pub struct FileStats {
    pub path: String,
    pub lines: usize,
    pub words: usize,
    pub chars: usize,
    pub unique_words: usize,
    pub top_words: Vec<(String, usize)>,
}

pub fn analyze(path: &str) -> Result<FileStats, StatError> {
    let content = std::fs::read_to_string(path)
        .map_err(|e| StatError::Io { path: path.to_string(), source: e })?;

    if content.trim().is_empty() {
        return Err(StatError::Empty(path.to_string()));
    }

    let lines = content.lines().count();
    let chars = content.chars().count();

    let words: Vec<&str> = content
        .split_whitespace()
        .collect();

    let word_count = words.len();

    let mut freq: HashMap<String, usize> = HashMap::new();
    for word in &words {
        let clean = word.to_lowercase()
            .trim_matches(|c: char| !c.is_alphanumeric())
            .to_string();
        if !clean.is_empty() {
            *freq.entry(clean).or_insert(0) += 1;
        }
    }

    let unique_words = freq.len();

    let mut top_words: Vec<(String, usize)> = freq.into_iter().collect();
    top_words.sort_by(|a, b| b.1.cmp(&a.1));
    top_words.truncate(5);

    Ok(FileStats {
        path: path.to_string(),
        lines,
        words: word_count,
        chars,
        unique_words,
        top_words,
    })
}

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

    #[test]
    fn test_basic_stats() {
        use std::io::Write;
        let mut f = tempfile::NamedTempFile::new().unwrap();
        writeln!(f, "hello world hello rust world world").unwrap();
        let stats = analyze(f.path().to_str().unwrap()).unwrap();
        assert_eq!(stats.lines, 1);
        assert_eq!(stats.words, 6);
        assert_eq!(stats.unique_words, 3);
        assert_eq!(stats.top_words[0].0, "world");
        assert_eq!(stats.top_words[0].1, 3);
    }
}
Enter fullscreen mode Exit fullscreen mode

CLI: crates/filestat-cli/src/main.rs

use anyhow::{Context, Result};
use clap::{Parser, ValueEnum};
use filestat_core::{analyze, StatError};

#[derive(ValueEnum, Clone, Debug)]
enum OutputFormat {
    Text,
    Json,
}

#[derive(Parser)]
#[command(name = "filestat", about = "Analyze text file statistics")]
struct Cli {
    /// Files to analyze
    files: Vec<String>,

    /// Output format
    #[arg(short, long, value_enum, default_value = "text")]
    format: OutputFormat,

    /// Show top N words (default: 5)
    #[arg(long, default_value_t = 5)]
    top: usize,
}

fn main() -> Result<()> {
    let cli = Cli::parse();

    if cli.files.is_empty() {
        anyhow::bail!("no files provided. Use: filestat <file1> [file2 ...]");
    }

    for path in &cli.files {
        match analyze(path) {
            Ok(stats) => match cli.format {
                OutputFormat::Json => {
                    println!("{}", serde_json::to_string_pretty(&stats)
                        .context("failed to serialize stats")?);
                }
                OutputFormat::Text => {
                    println!("── {} ──", stats.path);
                    println!("  Lines:        {}", stats.lines);
                    println!("  Words:        {}", stats.words);
                    println!("  Characters:   {}", stats.chars);
                    println!("  Unique words: {}", stats.unique_words);
                    println!("  Top {} words:", cli.top);
                    for (word, count) in stats.top_words.iter().take(cli.top) {
                        println!("    {:20} {}", word, count);
                    }
                }
            },
            Err(StatError::Empty(p)) => eprintln!("skipping '{p}': file is empty"),
            Err(e) => eprintln!("error: {e}"),
        }
    }

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Running it:

$ cargo run -p filestat-cli -- README.md --format text
── README.md ──
  Lines:        42
  Words:        381
  Characters:   2104
  Unique words: 198
  Top 5 words:
    the                  24
    rust                 18
    and                  15
    a                    12
    to                   11

$ cargo run -p filestat-cli -- README.md --format json
{
  "path": "README.md",
  "lines": 42,
  "words": 381,
  ...
}
Enter fullscreen mode Exit fullscreen mode

Wrapping Up Part 6

Concept What it gives you
serde Zero-boilerplate serialization to any format
thiserror Typed, ergonomic library error enums
anyhow Context-rich error propagation in apps
criterion Statistical, regression-detecting benchmarks
Workspaces Multi-crate projects with shared dependencies
CLI + Library Separation of logic and interface, reusable core

The Complete Series

Part Topics
Part 1 Ownership, Borrowing, Lifetimes, Traits, Result/Option, Pattern Matching
Part 2 Closures, Iterators, Generics, Enums, Smart Pointers, Async/Await
Part 3 Macros, Modules, Cargo, Testing, Unsafe, FFI
Part 4 Threads, Channels, Send/Sync, Mutex, Atomics, clap
Part 5 dyn Trait, Deref, Drop, Custom Iterators, Axum REST API
Part 6 Serde, thiserror, anyhow, Criterion, Workspaces, CLI + Library

Where to Go Next

You've covered the full breadth of Rust — here's what to explore next:


Thanks for following the entire series! If it helped you, drop a ❤️ on each part and share with someone learning Rust. See you in the next one! 🦀

Top comments (0)