Introduction: SOLID Principles in Rust — Why Is This Needed?
SOLID is a set of five fundamental principles of object-oriented programming that help create flexible, maintainable, and scalable code. Although Rust is not a classic OOP language (it does not have class inheritance in the usual sense), its ownership system, traits, and generics allow for brilliant implementation of all SOLID principles. Understanding these principles in the context of Rust helps write code that is easy to test, refactor, and extend.
In this article, we will analyze each of the five SOLID principles with practical examples in Rust. You will see how trait replaces interfaces, how Box<dyn Trait> helps with dependency inversion, and how to avoid common mistakes.
1. Single Responsibility Principle (SRP) in Rust
Single Responsibility Principle (SRP): each module, struct, or function should have only one reason to change. In Rust, this means a struct should be responsible for one entity, rather than mixing business logic, data handling, and output.
Example of SRP Violation
struct User { name: String, email: String,}
impl User { // Mixing business logic and persistence fn save_to_db(&self) { println!("Saving {} to DB", self.name); } fn send_email(&self, message: &str) { println!("Sending email to {}: {}", self.email, message); }}Here, the User struct is responsible for user data, persistence, and sending emails. This violates SRP.
Correct SRP Implementation
struct User { name: String, email: String,}
struct UserRepository;impl UserRepository { fn save(&self, user: &User) { println!("Saving {} to DB", user.name); }}
struct EmailService;impl EmailService { fn send(&self, user: &User, message: &str) { println!("Sending email to {}: {}", user.email, message); }}Now each struct has a single responsibility: User — data, UserRepository — persistence, EmailService — sending. The code has become more modular and testable.
2. Open/Closed Principle (OCP) via Traits
Open/Closed Principle (OCP): software entities should be open for extension but closed for modification. In Rust, this is ideally implemented using trait and dynamic dispatch.
Example without OCP
fn calculate_area(shape: &str, params: &[f64]) -> f64 { match shape { "circle" => std::f64::consts::PI * params[0] * params[0], "rectangle" => params[0] * params[1], _ => panic!("Unknown shape"), }}When adding a new shape (e.g., triangle), the function must be modified.
Correct Approach with OCP
trait Shape { fn area(&self) -> f64;}
struct Circle { radius: f64 }impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius }}
struct Rectangle { width: f64, height: f64 }impl Shape for Rectangle { fn area(&self) -> f64 { self.width * self.height }}
// Function is closed for modification but open for extensionfn print_area(shape: &dyn Shape) { println!("Area: {}", shape.area());}Now, to add a triangle, you only need to create a new struct and implement the trait Shape. The print_area function requires no changes.
3. Liskov Substitution Principle (LSP) and Inheritance via Traits
Liskov Substitution Principle (LSP): objects in a program should be replaceable with instances of