Introduction: Why is Dependency Injection Important in Rust?
\n\nWhen writing programs in Rust, sooner or later you will encounter a situation where one module (e.g., a service or controller) needs to use the capabilities of another module (database, API, logger). The simplest way is to create the required object directly inside the code that uses it:
\n\nstruct UserService;\n\nimpl UserService {\n fn get_user(&self, id: u32) -> String {\n let db = Database::new(); // ❌ Tight coupling\n db.query_user(id)\n }\n}\n\n\nThis approach creates tight coupling. The code becomes difficult to test, reuse, and modify. Dependency Injection (DI) is a pattern where dependencies are passed to an object from the outside, rather than created inside it. Rust, being a language with an ownership system and traits, offers elegant ways to implement DI without frameworks.
\n\nIn this article, we will explore how to inject dependencies in Rust using practical examples: from simple struct passing to using traits and smart pointers.
\n\n1. Basic Approach: Passing Dependencies via Constructor
\n\nThe simplest way to inject a dependency in Rust is to pass it as an argument to a constructor or function. Let's look at an example with a logger.
\n\nBad Example (without DI)
\n\nstruct ConsoleLogger;\n\nimpl ConsoleLogger {\n fn log(&self, msg: &str) {\n println!("[LOG]: {}", msg);\n }\n}\n\nstruct OrderService {\n logger: ConsoleLogger, // ❌ Tight coupling to a specific type\n}\n\nimpl OrderService {\n fn new() -> Self {\n Self {\n logger: ConsoleLogger, // Created internally\n }\n }\n\n fn place_order(&self, item: &str) {\n self.logger.log(&format!("Order: {}", item));\n }\n}\n\n\nGood Example (with DI)
\n\nstruct ConsoleLogger;\n\nimpl ConsoleLogger {\n fn log(&self, msg: &str) {\n println!("[LOG]: {}", msg);\n }\n}\n\nstruct OrderService {\n logger: ConsoleLogger, // Dependency is passed from outside\n}\n\nimpl OrderService {\n // Constructor accepts the dependency\n fn new(logger: ConsoleLogger) -> Self {\n Self { logger }\n }\n\n fn place_order(&self, item: &str) {\n self.logger.log(&format!("Order: {}", item));\n }\n}\n\nfn main() {\n let logger = ConsoleLogger;\n let service = OrderService::new(logger);\n service.place_order("Rust book");\n}\n\n\nWhat changed? Now OrderService does not create the logger itself. It receives it ready-made. This is already DI, but there is a nuance: we are still tied to the specific type ConsoleLogger. To make the code flexible, we need to use traits.
2. Using Traits for Dependency Abstraction
\n\nTraits in Rust are analogous to interfaces in other languages. They allow you to define a contract that dependencies must follow.
\n\nDefining the Logger Trait
\n\ntrait Logger {\n fn log(&self, msg: &str);\n}\n\nstruct ConsoleLogger;\nimpl Logger for ConsoleLogger {\n fn log(&self, msg: &str) {\n println!("[Console]: {}", msg);\n }\n}\n\nstruct FileLogger {\n filename: String,\n}\nimpl Logger for FileLogger {\n fn log(&self, msg: &str) {\n // Simulating file write\n println!("[File {}]: {}", self.filename, msg);\n }\n}\n\n\nInjecting the Trait via Generics
\n\nstruct OrderService<L: Logger> {\n logger: L, // Generic type implementing the Logger trait\n}\n\nimpl<L: Logger> OrderService<L> {\n fn new(logger: L) -> Self {\n Self { logger }\n }\n\n fn place_order(&self, item: &str) {\n self.logger.log(&format!("Order: {}", item));\n }\n}\n\nfn main() {\n let console_logge