DEV Community

Cover image for Rust Design Patterns: Safer, More Efficient Implementations for Modern Software Architecture
Aarav Joshi
Aarav Joshi

Posted on

Rust Design Patterns: Safer, More Efficient Implementations for Modern Software Architecture

As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!

Rust's design patterns offer a fresh approach to familiar software architecture concepts. The language's unique features create opportunities to implement patterns that are both safer and more efficient than traditional approaches. I'll explore how Rust's ownership model, type system, and zero-cost abstractions influence pattern implementation.

When I first encountered Rust, I was struck by how its constraints led to more thoughtful design decisions. The patterns we'll examine aren't just translations of object-oriented concepts—they're evolved versions that leverage Rust's strengths.

Builder Pattern

The Builder pattern shines in Rust due to the type system's ability to enforce correctness at compile time. Unlike dynamic languages where builders might fail at runtime, Rust can guarantee certain properties.

struct HouseBuilder {
    floors: Option<u32>,
    bedrooms: Option<u32>,
    has_garage: Option<bool>,
}

impl HouseBuilder {
    fn new() -> Self {
        HouseBuilder {
            floors: None,
            bedrooms: None,
            has_garage: None,
        }
    }

    fn floors(mut self, count: u32) -> Self {
        self.floors = Some(count);
        self
    }

    fn bedrooms(mut self, count: u32) -> Self {
        self.bedrooms = Some(count);
        self
    }

    fn garage(mut self, has_garage: bool) -> Self {
        self.has_garage = Some(has_garage);
        self
    }

    fn build(self) -> Result<House, &'static str> {
        let floors = self.floors.ok_or("Floors must be specified")?;
        let bedrooms = self.bedrooms.ok_or("Bedroom count is required")?;
        let has_garage = self.has_garage.unwrap_or(false);

        Ok(House { floors, bedrooms, has_garage })
    }
}

struct House {
    floors: u32,
    bedrooms: u32,
    has_garage: bool,
}
Enter fullscreen mode Exit fullscreen mode

I've found this pattern particularly useful for creating complex configurations where some fields are required and others optional. The Result type provides clear error messages when requirements aren't met.

For more sophisticated builders, we can use the typestate pattern to enforce valid building sequences through the type system:

struct Uninitialized;
struct HasFloors(u32);
struct HasBedrooms(u32, u32);

struct HouseBuilder<State = Uninitialized> {
    state: State,
}

impl HouseBuilder<Uninitialized> {
    fn new() -> Self {
        HouseBuilder { state: Uninitialized }
    }

    fn floors(self, count: u32) -> HouseBuilder<HasFloors> {
        HouseBuilder { state: HasFloors(count) }
    }
}

impl HouseBuilder<HasFloors> {
    fn bedrooms(self, count: u32) -> HouseBuilder<HasBedrooms> {
        let HasFloors(floors) = self.state;
        HouseBuilder { state: HasBedrooms(floors, count) }
    }
}

impl HouseBuilder<HasBedrooms> {
    fn build(self) -> House {
        let HasBedrooms(floors, bedrooms) = self.state;
        House { 
            floors, 
            bedrooms,
            has_garage: false 
        }
    }

    fn garage(self, has_garage: bool) -> Self {
        // Can't modify the existing state directly, so we create a new one
        let HasBedrooms(floors, bedrooms) = self.state;
        HouseBuilder { state: HasBedrooms(floors, bedrooms) }
    }
}
Enter fullscreen mode Exit fullscreen mode

Visitor Pattern

The Visitor pattern enables operations across heterogeneous data structures without modifying them. Rust's traits and pattern matching make this particularly elegant:

trait Shape {
    fn accept<V: Visitor>(&self, visitor: &mut V);
}

struct Circle {
    radius: f64,
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Circle {
    fn accept<V: Visitor>(&self, visitor: &mut V) {
        visitor.visit_circle(self)
    }
}

impl Shape for Rectangle {
    fn accept<V: Visitor>(&self, visitor: &mut V) {
        visitor.visit_rectangle(self)
    }
}

trait Visitor {
    fn visit_circle(&mut self, circle: &Circle);
    fn visit_rectangle(&mut self, rectangle: &Rectangle);
}

struct AreaCalculator {
    total_area: f64,
}

impl Visitor for AreaCalculator {
    fn visit_circle(&mut self, circle: &Circle) {
        self.total_area += std::f64::consts::PI * circle.radius * circle.radius;
    }

    fn visit_rectangle(&mut self, rectangle: &Rectangle) {
        self.total_area += rectangle.width * rectangle.height;
    }
}
Enter fullscreen mode Exit fullscreen mode

In my projects, I've found this pattern especially valuable when working with abstract syntax trees or document object models. It allows clean separation of concerns while maintaining type safety.

Command Pattern

Commands encapsulate actions as objects. In Rust, we can implement this with either trait objects or generics:

trait Command {
    fn execute(&self);
}

struct SaveCommand {
    filename: String,
}

impl Command for SaveCommand {
    fn execute(&self) {
        println!("Saving to {}", self.filename);
    }
}

struct PrintCommand {
    content: String,
}

impl Command for PrintCommand {
    fn execute(&self) {
        println!("{}", self.content);
    }
}

// Using trait objects with dynamic dispatch
struct CommandQueue {
    commands: Vec<Box<dyn Command>>,
}

impl CommandQueue {
    fn new() -> Self {
        CommandQueue { commands: Vec::new() }
    }

    fn add_command(&mut self, command: Box<dyn Command>) {
        self.commands.push(command);
    }

    fn execute_all(&self) {
        for cmd in &self.commands {
            cmd.execute();
        }
    }
}

// Alternative using generics with static dispatch
struct StaticCommand<T: Command> {
    command: T,
}

impl<T: Command> StaticCommand<T> {
    fn new(command: T) -> Self {
        StaticCommand { command }
    }

    fn execute(&self) {
        self.command.execute();
    }
}
Enter fullscreen mode Exit fullscreen mode

I prefer the generic approach when performance is critical, as it enables monomorphization—the compiler generates specialized code for each command type, eliminating virtual dispatch overhead.

Factory Pattern

Factory methods create objects without specifying concrete classes. Rust factories utilize associated types to create objects with zero overhead:

trait DatabaseConnection {
    fn connect(&self) -> bool;
    fn query(&self, query: &str) -> Vec<String>;
}

struct PostgresConnection {
    connection_string: String,
}

impl DatabaseConnection for PostgresConnection {
    fn connect(&self) -> bool {
        println!("Connecting to Postgres with {}", self.connection_string);
        true
    }

    fn query(&self, query: &str) -> Vec<String> {
        println!("Executing query on Postgres: {}", query);
        vec!["result1".to_string(), "result2".to_string()]
    }
}

trait ConnectionFactory {
    type Connection: DatabaseConnection;

    fn create_connection(&self, config: &str) -> Self::Connection;
}

struct PostgresFactory;

impl ConnectionFactory for PostgresFactory {
    type Connection = PostgresConnection;

    fn create_connection(&self, config: &str) -> PostgresConnection {
        PostgresConnection {
            connection_string: config.to_string(),
        }
    }
}

fn run_query<F: ConnectionFactory>(factory: F, config: &str, query: &str) -> Vec<String> {
    let connection = factory.create_connection(config);
    if connection.connect() {
        connection.query(query)
    } else {
        Vec::new()
    }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is particularly powerful when combined with generics, as it allows for compile-time polymorphism rather than runtime dispatch.

Adapter Pattern

The Adapter pattern converts interfaces to work together. Rust's traits make this straightforward:

trait JsonSerializer {
    fn to_json(&self) -> String;
}

trait XmlSerializer {
    fn to_xml(&self) -> String;
}

struct User {
    name: String,
    email: String,
}

impl XmlSerializer for User {
    fn to_xml(&self) -> String {
        format!("<user><name>{}</name><email>{}</email></user>", 
               self.name, self.email)
    }
}

struct XmlToJsonAdapter<T: XmlSerializer> {
    adaptee: T,
}

impl<T: XmlSerializer> JsonSerializer for XmlToJsonAdapter<T> {
    fn to_json(&self) -> String {
        // This would normally use a proper XML parser
        let xml = self.adaptee.to_xml();
        xml.replace("<user>", "{")
           .replace("</user>", "}")
           .replace("<name>", "\"name\": \"")
           .replace("</name>", "\", ")
           .replace("<email>", "\"email\": \"")
           .replace("</email>", "\"")
    }
}
Enter fullscreen mode Exit fullscreen mode

Observer Pattern

Traditional observer implementations often violate Rust's ownership rules. Instead, I use channels to maintain both safety and flexibility:

use std::sync::mpsc::{channel, Sender, Receiver};

enum Event {
    ValueChanged(i32),
    ConnectionLost,
    Shutdown,
}

struct EventSource {
    subscribers: Vec<Sender<Event>>,
    value: i32,
}

impl EventSource {
    fn new() -> Self {
        EventSource {
            subscribers: Vec::new(),
            value: 0,
        }
    }

    fn subscribe(&mut self) -> Receiver<Event> {
        let (sender, receiver) = channel();
        self.subscribers.push(sender);
        receiver
    }

    fn set_value(&mut self, new_value: i32) {
        self.value = new_value;

        // Notify subscribers
        self.subscribers.retain(|subscriber| {
            subscriber.send(Event::ValueChanged(new_value)).is_ok()
        });
    }

    fn shutdown(&mut self) {
        for subscriber in &self.subscribers {
            let _ = subscriber.send(Event::Shutdown);
        }
    }
}

fn observer_example() {
    let mut source = EventSource::new();

    let receiver = source.subscribe();

    std::thread::spawn(move || {
        for event in receiver {
            match event {
                Event::ValueChanged(value) => println!("Value changed to {}", value),
                Event::ConnectionLost => println!("Connection lost!"),
                Event::Shutdown => {
                    println!("Shutting down observer");
                    break;
                }
            }
        }
    });

    source.set_value(42);
    source.shutdown();
}
Enter fullscreen mode Exit fullscreen mode

This approach has several advantages: it works across threads, automatically handles disconnected observers, and maintains Rust's ownership guarantees.

Singleton Pattern

While singletons are sometimes controversial, Rust provides several safe implementations:

use std::sync::{Arc, Mutex, Once};
use std::sync::atomic::{AtomicUsize, Ordering};

struct Configuration {
    max_connections: AtomicUsize,
    database_url: String,
}

impl Configuration {
    fn get_max_connections(&self) -> usize {
        self.max_connections.load(Ordering::Relaxed)
    }

    fn set_max_connections(&self, value: usize) {
        self.max_connections.store(value, Ordering::Relaxed);
    }
}

// Option 1: Once initialization
fn get_configuration() -> &'static Configuration {
    static mut INSTANCE: Option<Configuration> = None;
    static ONCE: Once = Once::new();

    unsafe {
        ONCE.call_once(|| {
            INSTANCE = Some(Configuration {
                max_connections: AtomicUsize::new(10),
                database_url: "postgres://localhost/mydb".to_string(),
            });
        });

        INSTANCE.as_ref().unwrap()
    }
}

// Option 2: Lazy static (requires the lazy_static crate)
// lazy_static! {
//     static ref CONFIGURATION: Mutex<Configuration> = Mutex::new(
//         Configuration {
//             max_connections: AtomicUsize::new(10),
//             database_url: "postgres://localhost/mydb".to_string(),
//         }
//     );
// }
Enter fullscreen mode Exit fullscreen mode

I generally prefer the second approach with lazy_static, as it avoids unsafe code while providing the same functionality.

Strategy Pattern

The Strategy pattern defines a family of algorithms. In Rust, we can implement it using traits and closures:

trait SortStrategy<T> {
    fn sort(&self, data: &mut [T]);
}

struct QuickSort;
struct MergeSort;
struct BubbleSort;

impl<T: Ord> SortStrategy<T> for QuickSort {
    fn sort(&self, data: &mut [T]) {
        data.sort();  // Uses the standard library's sort
    }
}

impl<T: Ord> SortStrategy<T> for MergeSort {
    fn sort(&self, data: &mut [T]) {
        data.sort_by(|a, b| a.cmp(b));  // In real code, would use a merge sort
    }
}

impl<T: Ord> SortStrategy<T> for BubbleSort {
    fn sort(&self, data: &mut [T]) {
        // Simple bubble sort implementation
        let len = data.len();
        for i in 0..len {
            for j in 0..len-i-1 {
                if data[j] > data[j+1] {
                    data.swap(j, j+1);
                }
            }
        }
    }
}

struct Sorter<T, S: SortStrategy<T>> {
    strategy: S,
    _phantom: std::marker::PhantomData<T>,
}

impl<T, S: SortStrategy<T>> Sorter<T, S> {
    fn new(strategy: S) -> Self {
        Sorter { 
            strategy, 
            _phantom: std::marker::PhantomData,
        }
    }

    fn sort(&self, data: &mut [T]) {
        self.strategy.sort(data);
    }
}

// Using closures as strategies
struct ClosureSorter<T, F: Fn(&mut [T])> {
    sort_fn: F,
    _phantom: std::marker::PhantomData<T>,
}

impl<T, F: Fn(&mut [T])> ClosureSorter<T, F> {
    fn new(sort_fn: F) -> Self {
        ClosureSorter {
            sort_fn,
            _phantom: std::marker::PhantomData,
        }
    }

    fn sort(&self, data: &mut [T]) {
        (self.sort_fn)(data);
    }
}
Enter fullscreen mode Exit fullscreen mode

The generic implementation allows compile-time optimization, which I've found crucial for high-performance code paths.

State Pattern

The State pattern allows objects to change behavior when internal state changes. Rust's type system makes states explicit:

trait State {
    fn handle_input(&self) -> Option<Box<dyn State>>;
    fn update(&self);
    fn render(&self);
}

struct StandingState;
struct JumpingState {
    velocity: f32,
}
struct DuckingState;

impl State for StandingState {
    fn handle_input(&self) -> Option<Box<dyn State>> {
        // Simplified: spacebar = jump, down arrow = duck
        let input = get_input();  // Assume this function exists

        if input == "SPACE" {
            Some(Box::new(JumpingState { velocity: 10.0 }))
        } else if input == "DOWN" {
            Some(Box::new(DuckingState))
        } else {
            None
        }
    }

    fn update(&self) {
        // Standing state logic
    }

    fn render(&self) {
        println!("Rendering player in standing state");
    }
}

// Implementations for other states...

struct Player {
    state: Box<dyn State>,
}

impl Player {
    fn new() -> Self {
        Player {
            state: Box::new(StandingState),
        }
    }

    fn update(&mut self) {
        if let Some(new_state) = self.state.handle_input() {
            self.state = new_state;
        }

        self.state.update();
        self.state.render();
    }
}

// Simulated input function
fn get_input() -> &'static str {
    "NONE"
}
Enter fullscreen mode Exit fullscreen mode

I've often used a variant of this pattern with enum-based states for improved performance:

enum PlayerState {
    Standing,
    Jumping { velocity: f32, elapsed_time: f32 },
    Ducking { duration: f32 },
}

struct EnumPlayer {
    state: PlayerState,
}

impl EnumPlayer {
    fn new() -> Self {
        EnumPlayer { state: PlayerState::Standing }
    }

    fn update(&mut self, dt: f32) {
        // Handle transitions
        self.state = match self.state {
            PlayerState::Standing => {
                if is_space_pressed() {
                    PlayerState::Jumping { velocity: 10.0, elapsed_time: 0.0 }
                } else if is_down_pressed() {
                    PlayerState::Ducking { duration: 0.0 }
                } else {
                    PlayerState::Standing
                }
            },
            PlayerState::Jumping { velocity, elapsed_time } => {
                let new_elapsed = elapsed_time + dt;
                if velocity - 9.8 * new_elapsed <= 0.0 {
                    PlayerState::Standing
                } else {
                    PlayerState::Jumping { 
                        velocity, 
                        elapsed_time: new_elapsed,
                    }
                }
            },
            PlayerState::Ducking { duration } => {
                if !is_down_pressed() {
                    PlayerState::Standing
                } else {
                    PlayerState::Ducking { duration: duration + dt }
                }
            }
        };
    }
}

fn is_space_pressed() -> bool { false }
fn is_down_pressed() -> bool { false }
Enter fullscreen mode Exit fullscreen mode

This approach eliminates dynamic dispatch overhead and enables more optimizations, which is critical for game development.

Conclusion

Rust's design patterns demonstrate how safety constraints can lead to better architectures. By forcing us to think about ownership, lifetimes, and types, Rust guides us toward patterns that are not just safer but often more efficient than traditional implementations.

I've found that the best Rust code doesn't blindly translate patterns from other languages but instead reimagines them to take advantage of Rust's strengths. This often leads to solutions that are both more elegant and more performant.

When implementing patterns in Rust, consider:

  • Using generics and associated types for zero-cost abstractions
  • Leveraging the type system to encode invariants
  • Embracing Rust's ownership model rather than fighting it
  • Using enums for state machines when possible
  • Employing channels for observer-like patterns

These approaches have consistently led to code that's not only more robust but easier to maintain and evolve as requirements change.


101 Books

101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.

Check out our book Golang Clean Code available on Amazon.

Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!

Our Creations

Be sure to check out our creations:

Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools


We are on Medium

Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva

Top comments (0)