Rust Design Patterns â Builder, Newtype, RAII & More
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
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 BoxBox<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(¤t);
}
}
}
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
Related Concepts
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