Dependency Injection in Rust: In Practice

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

Dependency Injection in Rust: In Practice



Dependency Injection (DI) is a design pattern that allows passing dependencies to an object from the outside, rather than creating them internally. In Rust, this approach is especially useful thanks to the ownership system, traits, and generics. In this article, we will look at how to implement DI in Rust in practice, from simple examples to advanced techniques with dependency containers.



Why is DI Important in Rust?

In Rust, where there is no classical OOP with inheritance, DI helps to:

  • Reduce coupling — modules depend on abstractions, not concrete implementations.
  • Simplify testing — you can replace real implementations with mocks.
  • Increase flexibility — it's easy to change system behavior without modifying its code.


Basics: Passing Dependencies via Parameters

The simplest way to inject a dependency is to pass it as a parameter to a function or struct constructor.



// Define a trait for abstractiontrait Logger {    fn log(&self, message: &str);}

// Console implementationstruct ConsoleLogger;

impl Logger for ConsoleLogger { fn log(&self, message: &str) { println!("LOG: {}", message); }}

// File implementationstruct FileLogger { filename: String,}

impl Logger for FileLogger { fn log(&self, message: &str) { // In real code, this would write to a file println!("FILE: {} -> {}", self.filename, message); }}

// Service using DI via constructorstruct UserService<L: Logger> { logger: L,}

impl<L: Logger> UserService<L> { fn new(logger: L) -> Self { Self { logger } }

fn create_user(&self, name: &str) { self.logger.log(&format!("User created: {}", name)); }}

fn main() { let console_logger = ConsoleLogger; let user_service = UserService::new(console_logger); user_service.create_user("Alice");}


In this example, UserService does not know which specific logger is used — it only depends on the Logger trait. This is the essence of DI.



Advanced Approach: Trait Objects and Dynamic Dispatch

When you need to store different implementations in a single container, use trait objects (Box<dyn Trait>).



trait Database {    fn query(&self, sql: &str) -> Vec<String>;}

struct PostgresDb;impl Database for PostgresDb { fn query(&self, sql: &str) -> Vec<String> { vec!["result1".to_string(), "result2".to_string()] }}

struct SqliteDb;impl Database for SqliteDb { fn query(&self, sql: &str) -> Vec<String> { vec!["sqlite_result".to_string()] }}

struct App { db: Box<dyn Database>, logger: Box<dyn Logger>,}

impl App { fn new(db: Box<dyn Database>, logger: Box<dyn Logger>) -> Self { Self { db, logger } }

fn run(&self) { let results = self.db.query("SELECT * FROM users"); for r in results { self.logger.log(&r); } }}

fn main() { let app = App::new( Box::new(PostgresDb), Box::new(ConsoleLogger), ); app.run();}


The advantage of trait objects is that you can change implementations at runtime. The disadvantage is a slight overhead from dynamic dispatch.



Dependency Container: Inversion of Control

For large projects, it is convenient to use a dependency container. Although Rust does not have a standard DI container, it is easy to implement one yourself or use crates such as shaku or dice.



Example of a simple manual container:



use std::collections::HashMap;use std::any::Any;

// Ty

Blogs

Book Recommendations