Skip to content

Rust Design Patterns — Builder, Newtype, RAII & More

DodaTech Updated 2026-06-21 9 min read

In this tutorial, you'll learn about Rust Design Patterns. We cover key concepts, practical examples, and best practices.

Rust design patterns leverage the language's unique features — ownership, traits, and zero-cost abstractions — to create safe, ergonomic, and high-performance code that would be unsafe or verbose in other languages.

What You'll Learn

In this tutorial, you'll learn idiomatic Rust design patterns: the Builder pattern for configuration, Newtype for type safety, RAII for resource management, Strategy with traits, Observer with channels, and other patterns that leverage Rust's ownership model.

Why It Matters

Design patterns solve recurring problems. In Rust, the ownership and borrowing system makes some classical GoF patterns obsolete (or require different implementations) while enabling new patterns impossible in garbage-collected languages. Mastering Rust patterns is essential for writing production-quality systems code.

Real-World Use

The clap crate uses the Builder pattern for CLI argument parsing. The serde library uses the Visitor pattern. The tokio runtime uses the Reactor pattern. Durga Antivirus Pro uses the Strategy pattern for interchangeable scan backends and RAII for guaranteed resource cleanup.

flowchart TD
    subgraph "Creational"
        B[Builder] -->|fluent API| Config[Configured Object]
        NT[Newtype] -->|type safety| Wrapper[Wrapper]
    end
    subgraph "Structural"
        RAII[RAII] -->|Drop| Guarantee[Resource Released]
        C[Compose] -->|traits| Behavior[Mixed Behavior]
    end
    subgraph "Behavioral"
        S[Strategy] -->|trait objects| Algo[Algorithm Swapped]
        O[Observer] -->|channels| Event[Event Propagation]
    end
â„šī¸ Info

Prerequisites: Traits & Generics, Smart Pointers, and Error Handling.

Builder Pattern

The Builder pattern constructs complex objects with a fluent API, avoiding constructors with many parameters.

#[derive(Debug)]
struct ScanConfig {
    paths: Vec<String>,
    recursive: bool,
    max_file_size: u64,
    threat_db_path: String,
    threads: u32,
}

struct ScanConfigBuilder {
    paths: Vec<String>,
    recursive: bool,
    max_file_size: u64,
    threat_db_path: String,
    threads: u32,
}

impl ScanConfigBuilder {
    fn new() -> Self {
        ScanConfigBuilder {
            paths: vec![],
            recursive: false,
            max_file_size: 10_000_000,
            threat_db_path: "/etc/durga/sigs.db".into(),
            threads: 4,
        }
    }

    fn add_path(mut self, path: &str) -> Self {
        self.paths.push(path.to_string());
        self
    }

    fn recursive(mut self, val: bool) -> Self {
        self.recursive = val;
        self
    }

    fn max_file_size(mut self, size: u64) -> Self {
        self.max_file_size = size;
        self
    }

    fn build(self) -> Result<ScanConfig, &'static str> {
        if self.paths.is_empty() {
            return Err("At least one path is required");
        }
        Ok(ScanConfig {
            paths: self.paths,
            recursive: self.recursive,
            max_file_size: self.max_file_size,
            threat_db_path: self.threat_db_path,
            threads: self.threads,
        })
    }
}

fn main() {
    let config = ScanConfigBuilder::new()
        .add_path("/home")
        .add_path("/var")
        .recursive(true)
        .max_file_size(100_000_000)
        .threads(8)
        .build()
        .expect("Invalid config");

    println!("Scan config: {:?}", config);
}

Expected output:

Scan config: ScanConfig { paths: ["/home", "/var"], recursive: true, max_file_size: 100000000, threat_db_path: "/etc/durga/sigs.db", threads: 8 }

Newtype Pattern

The Newtype pattern wraps an existing type in a new struct for type safety and additional behavior.

struct UserId(u64);
struct FileDescriptor(i32);

struct SecurityContext {
    user: UserId,
    fd: FileDescriptor,
    permissions: u32,
}

impl UserId {
    fn new(id: u64) -> Self {
        UserId(id)
    }

    fn is_admin(&self) -> bool {
        self.0 == 0
    }
}

fn audit_access(user: &UserId, fd: &FileDescriptor) {
    println!("User {} accessed FD {}", user.0, fd.0);
}

fn main() {
    let admin = UserId::new(0);
    let regular = UserId::new(1001);
    let log_fd = FileDescriptor(3);

    println!("Admin? {}", admin.is_admin());
    println!("Regular admin? {}", regular.is_admin());

    audit_access(&admin, &log_fd);

    // Cannot accidentally swap arguments:
    // audit_access(&log_fd, &admin); // TYPE ERROR: wrong types
}

Expected output:

Admin? true
Regular admin? false
User 0 accessed FD 3

RAII Pattern

RAII ties resource lifetime to scope. Rust's Drop trait makes this idiomatic and guaranteed.

struct LogTransaction {
    transaction_id: String,
}

impl LogTransaction {
    fn begin(name: &str) -> Self {
        println!("BEGIN TRANSACTION: {}", name);
        LogTransaction { transaction_id: name.to_string() }
    }

    fn commit(mut self) {
        println!("COMMIT: {}", self.transaction_id);
    }
}

impl Drop for LogTransaction {
    fn drop(&mut self) {
        // Auto-rollback if not committed
        println!("ROLLBACK: {} (scope ended)", self.transaction_id);
    }
}

fn process_with_transaction(success: bool) {
    let tx = LogTransaction::begin("db_update");
    // Do work...
    if success {
        tx.commit(); // Commit consumes self, no drop runs
    } else {
        // tx goes out of scope, auto-rollback
    }
}

fn main() {
    println!("=== Successful transaction ===");
    process_with_transaction(true);
    println!("\n=== Failed transaction ===");
    process_with_transaction(false);
}

Expected output:

=== Successful transaction ===
BEGIN TRANSACTION: db_update
COMMIT: db_update

=== Failed transaction ===
BEGIN TRANSACTION: db_update
ROLLBACK: db_update (scope ended)

Strategy Pattern

The Strategy pattern uses trait objects to swap algorithms at runtime.

trait CompressionStrategy {
    fn compress(&self, data: &[u8]) -> Vec<u8>;
    fn name(&self) -> &str;
}

struct GzipCompression;
struct NoCompression;

impl CompressionStrategy for GzipCompression {
    fn compress(&self, data: &[u8]) -> Vec<u8> {
        // Simplified compression simulation
        let mut result = Vec::with_capacity(data.len() / 2);
        // In reality, use flate2 crate
        for chunk in data.chunks(2) {
            result.push(chunk[0]);
        }
        result
    }

    fn name(&self) -> &str { "gzip" }
}

impl CompressionStrategy for NoCompression {
    fn compress(&self, data: &[u8]) -> Vec<u8> {
        data.to_vec()
    }

    fn name(&self) -> &str { "none" }
}

struct Compressor {
    strategy: Box<dyn CompressionStrategy>,
}

impl Compressor {
    fn new(strategy: Box<dyn CompressionStrategy>) -> Self {
        Compressor { strategy }
    }

    fn compress(&self, data: &[u8]) -> Vec<u8> {
        println!("Using strategy: {}", self.strategy.name());
        self.strategy.compress(data)
    }
}

fn main() {
    let data = b"Hello, World! This is test data for compression.";

    let gzip = Compressor::new(Box::new(GzipCompression));
    let result = gzip.compress(data);
    println!("Gzip: {} bytes -> {} bytes", data.len(), result.len());

    let none = Compressor::new(Box::new(NoCompression));
    let result = none.compress(data);
    println!("None: {} bytes -> {} bytes", data.len(), result.len());
}

Expected output:

Using strategy: gzip
Gzip: 46 bytes -> 23 bytes
Using strategy: none
None: 46 bytes -> 46 bytes

Observer Pattern with Channels

Rust channels implement the Observer pattern with type safety and thread safety.

use std::sync::mpsc;

struct EventBus {
    subscribers: Vec<mpsc::Sender<String>>,
}

impl EventBus {
    fn new() -> Self {
        EventBus { subscribers: vec![] }
    }

    fn subscribe(&mut self) -> mpsc::Receiver<String> {
        let (tx, rx) = mpsc::channel();
        self.subscribers.push(tx);
        rx
    }

    fn publish(&self, event: String) {
        for subscriber in &self.subscribers {
            subscriber.send(event.clone()).ok();
        }
    }
}

fn main() {
    let mut bus = EventBus::new();
    let rx1 = bus.subscribe();
    let rx2 = bus.subscribe();

    bus.publish("File system change detected".into());
    bus.publish("Scan completed".into());

    for rx in &[&rx1, &rx2] {
        println!("Subscriber received: {:?}", rx.recv().ok());
        println!("Subscriber received: {:?}", rx.recv().ok());
    }
}

Expected output:

Subscriber received: Some("File system change detected")
Subscriber received: Some("Scan completed")
Subscriber received: Some("File system change detected")
Subscriber received: Some("Scan completed")

Common Mistakes

1. Overusing Box Instead of Generics

Dynamic dispatch has overhead. Prefer generics (impl Trait) when the concrete type is known at compile time. Use trait objects only when runtime flexibility is required.

2. Not Implementing Drop for Resource Holders

Types that hold file handles, sockets, or locks must implement Drop. Relying on default cleanup may leak resources.

3. Builder Panic Instead of Returning Result

Builders should validate in build() and return Result. Panicking in a builder is poor API design.

4. Newtype Overuse for Simple Wrappers

Creating newtypes for every primitive adds boilerplate with little benefit. Use newtypes when the type has specific invariants or you need type safety to prevent parameter confusion.

5. Tight Coupling in Observer Pattern

Channels decouple publishers from subscribers. Avoid making subscribers aware of each other or the publisher's internal state.

Practice Questions

1. When should you use the Builder pattern? When constructing objects with many optional parameters, or when validation is required before creating the object. Builder provides a fluent API and separates construction from representation.

2. What problem does the Newtype pattern solve? It prevents mixing up values of the same underlying type (e.g., UserId vs FileDescriptor both being u64/i32). It also allows implementing external traits on external types.

3. How does Rust make RAII safer than C++? Rust's ownership system ensures Drop is called exactly once. There is no risk of forgetting to call delete or double-freeing. The compiler verifies resource lifetimes.

4. When would you use Box vs generics for Strategy? Use Box<dyn Trait> when the strategy is chosen at runtime (user selection, config file). Use generics when the strategy is known at compile time (monomorphization gives better performance).

5. Challenge: Implement the Command pattern for a file system scanner that supports operations: Scan, Quarantine, Delete, Report. Each command should be undoable.

Mini Project: Configurable Scan Pipeline

trait ScanStep {
    fn process(&self, data: &[u8]) -> Vec<u8>;
    fn name(&self) -> &str;
}

struct DecryptStep {
    key: u8,
}

impl ScanStep for DecryptStep {
    fn process(&self, data: &[u8]) -> Vec<u8> {
        data.iter().map(|&b| b ^ self.key).collect()
    }
    fn name(&self) -> &str { "decrypt" }
}

struct SignatureCheckStep {
    signature: Vec<u8>,
}

impl ScanStep for SignatureCheckStep {
    fn process(&self, data: &[u8]) -> Vec<u8> {
        if data.windows(self.signature.len()).any(|w| w == self.signature.as_slice()) {
            println!("  Signature match found!");
        }
        data.to_vec()
    }
    fn name(&self) -> &str { "signature_check" }
}

struct ScanPipeline {
    steps: Vec<Box<dyn ScanStep>>,
}

impl ScanPipeline {
    fn new() -> Self {
        ScanPipeline { steps: vec![] }
    }

    fn add_step(mut self, step: Box<dyn ScanStep>) -> Self {
        self.steps.push(step);
        self
    }

    fn execute(&self, data: &[u8]) {
        println!("Running pipeline with {} steps", self.steps.len());
        let mut current = data.to_vec();
        for step in &self.steps {
            println!("  Step: {}", step.name());
            current = step.process(&current);
        }
    }
}

fn main() {
    let pipeline = ScanPipeline::new()
        .add_step(Box::new(DecryptStep { key: 0xAB }))
        .add_step(Box::new(SignatureCheckStep { signature: vec![0x01, 0x02] }));

    let data = vec![0xAA, 0xAB, 0x01, 0x02, 0x03];
    pipeline.execute(&data);
}

FAQ

Are GoF design patterns applicable in Rust?

Many GoF patterns apply but with Rust-specific implementations. Singleton is replaced by lazy_static! or OnceCell. Observer uses channels. Strategy uses traits. Some patterns (like Visitor) are less common due to pattern matching.

What is the Recommended way to implement Builder?

Create a separate builder struct with optional fields (Option), implement fluent setter methods returning Self, and a build() method that validates and returns Result<T, E>.

How do I choose between enum dispatch and trait objects?

Use enums when the set of variants is fixed and known. Use trait objects when the set is open (any crate can implement the trait). Enums are faster (match), trait objects are more extensible.

Traits & Generics
Cargo Workspaces
Smart Pointers

What's Next

Review the Rust Systems Programming Overview to reinforce foundational concepts, or explore Performance Optimization for making your patterns run faster.

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro