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 to Dependency Injection in Rust



When you write programs in Rust, sooner or later you encounter a problem: how to make code flexible, testable, and loosely coupled? The answer is Dependency Injection (DI). This is a design pattern where an object receives its dependencies from the outside, rather than creating them itself.



In this article, we will explore how to implement Dependency Injection in Rust with practical examples. You will learn how to use trait objects and generics to create a flexible architecture. The material is intended for beginner Rust developers familiar with the basics of the language.



Why is Dependency Injection needed?



Imagine you are writing an application for sending notifications. Without DI, the NotificationService class will be tightly coupled to a specific implementation (e.g., EmailSender). This makes the code:

  • Difficult to test — it is impossible to replace the real service with a mock object.
  • Hard to extend — adding a new sending method (SMS, Push) requires changing existing code.
  • Tightly coupled — changes in one part break another.


Dependency Injection solves these problems by passing dependencies through a constructor, method, or parameter. Rust, being a systems language with strong typing, offers elegant ways to implement DI.



Main Approaches to DI in Rust



In Rust, there are two main ways to implement dependency injection: through trait objects (dynamic dispatch) and through generics (static dispatch). Let's look at both approaches.



1. DI via trait objects (dynamic dispatch)



This approach uses runtime polymorphism. You define a trait, and then pass a Box<dyn Trait> or a reference &dyn Trait.



Example: logging service



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

// Implementation for console outputstruct ConsoleLogger;

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

// Implementation for filestruct FileLogger { filename: String,}

impl Logger for FileLogger { fn log(&self, message: &str) { // Simplified file writing std::fs::write(&self.filename, message).unwrap(); }}

// Service using DIstruct UserService { logger: Box<dyn Logger>,}

impl UserService { // Dependency injection via constructor fn new(logger: Box<dyn Logger>) -> Self { UserService { logger } }

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

fn main() { // Using ConsoleLogger let console_logger = Box::new(ConsoleLogger); let service = UserService::new(console_logger); service.create_user("Alice");

// Using FileLogger let file_logger = Box::new(FileLogger { filename: "log.txt".into() }); let service2 = UserService::new(file_logger); service2.create_user("Bob");}


Advantages: flexibility, you can change the implementation at runtime. Disadvantages: slight overhead due to virtual calls.



2. DI via generics (static dispatch)



Here, dependencies are determined at compile time through type parameters. This is a more performant approach, but less flexible.



Example: the same logging service with generics



trait Logger {    fn log(&self, message: &str);}

struct ConsoleLogge

Blogs

Book Recommendations