Dependency Injection in Rust for Beginners

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: Why is Dependency Injection Important in Rust?

\n\n

When 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\n
struct 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\n

This 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\n

In 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\n

1. Basic Approach: Passing Dependencies via Constructor

\n\n

The 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\n

Bad Example (without DI)

\n\n
struct 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\n

Good Example (with DI)

\n\n
struct 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\n

What 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.

\n\n

2. Using Traits for Dependency Abstraction

\n\n

Traits in Rust are analogous to interfaces in other languages. They allow you to define a contract that dependencies must follow.

\n\n

Defining the Logger Trait

\n\n
trait 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\n

Injecting the Trait via Generics

\n\n
struct 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                

Blogs

Book Recommendations