The hardcoded trap
You are building a notification service. It needs to send emails. You write a SmtpClient struct and put it inside your NotificationService. The code works. You run it, and an email flies out to your inbox.
Then you write a test. You want to verify that the service sends the right message when an error occurs. You run the test, and your real SMTP client tries to connect to the mail server. The test fails because the server is down, or worse, it succeeds and you just spammed your inbox with test data. You realize you cannot test the logic of NotificationService without the side effects of SmtpClient.
The problem is not your test. The problem is that NotificationService creates its dependency internally. It is coupled to SmtpClient. You cannot swap the client for a mock without rewriting the service.
Dependency injection solves this by moving the creation of dependencies outside the struct that uses them. The service receives the dependency through its constructor or method arguments. This decouples the logic from the implementation. You can inject a real SMTP client in production and a mock client in tests.
Hardcode nothing. Inject everything.
Traits as sockets
Rust does not have a keyword for dependency injection. You implement it using traits. A trait defines a set of methods that a type must implement. In the context of injection, the trait acts as the interface. The struct that holds the trait acts as the consumer.
Think of a power socket. The lamp plugged into the socket does not care whether the electricity comes from a coal plant, a nuclear reactor, or a solar panel. The lamp only cares that the plug fits the socket and delivers voltage. The trait is the socket shape. The implementation is the power source.
Define the trait first. It describes the behavior the service needs.
/// Defines the contract for sending notifications.
/// Any type implementing this trait can be used as a backend.
trait Notifier {
/// Sends a message to the recipient.
fn send(&self, message: &str);
}
Next, create a concrete implementation. This is the real backend.
struct SmtpNotifier {
host: String,
}
impl Notifier for SmtpNotifier {
fn send(&self, message: &str) {
// Simulate sending via SMTP.
println!("Sending via SMTP to {}: {}", self.host, message);
}
}
Now create the service. Instead of holding a SmtpNotifier, it holds something that implements Notifier. This is where Rust offers a choice. You can use a trait object or a generic.
Define the behavior, not the bits. The trait is the contract.
Injecting with trait objects
The most direct way to accept any implementation of a trait is to use a trait object. A trait object erases the concrete type and keeps only the trait interface. You create a trait object by putting dyn in front of the trait name and boxing it behind a pointer.
struct NotificationService {
// Box<dyn Notifier> holds a pointer to the heap.
// The heap contains the concrete type and a vtable for dynamic dispatch.
notifier: Box<dyn Notifier>,
}
impl NotificationService {
/// Creates a new service with the provided notifier.
/// The caller decides which implementation to inject.
fn new(notifier: Box<dyn Notifier>) -> Self {
Self { notifier }
}
/// Triggers a notification using the injected backend.
fn trigger(&self, message: &str) {
// Dynamic dispatch looks up the method in the vtable.
self.notifier.send(message);
}
}
The Box is necessary because Rust needs to know the size of the field at compile time. Different implementations of Notifier might have different sizes. A Box has a fixed size (a pointer), so the struct layout remains stable. The dyn keyword tells the compiler to use dynamic dispatch. The compiler generates a virtual method table (vtable) for the trait. When you call send, the code looks up the function pointer in the vtable and jumps to the correct implementation.
This approach allows you to swap implementations at runtime. You can change the notifier while the service is running. It also allows you to store a collection of heterogeneous notifiers in a Vec<Box<dyn Notifier>>.
Trait objects let you swap implementations at runtime. Pay the allocation cost for the flexibility.
The generic alternative
Rust developers often prefer generics for dependency injection. Generics provide static dispatch. The compiler generates a separate version of the code for each concrete type used. There is no Box allocation. There is no vtable lookup. The call is direct and inlined.
struct NotificationService<T> {
// T is a concrete type known at compile time.
// The compiler monomorphizes this struct for each T.
notifier: T,
}
impl<T> NotificationService<T>
where
// The where clause constrains T to types that implement Notifier.
// This is the trait bound.
T: Notifier,
{
/// Creates a new service with the provided notifier.
fn new(notifier: T) -> Self {
Self { notifier }
}
/// Triggers a notification using the injected backend.
fn trigger(&self, message: &str) {
// Static dispatch. The compiler knows the exact type of T.
// This call can be inlined and optimized aggressively.
self.notifier.send(message);
}
}
Usage looks slightly different. You pass the concrete type directly.
fn main() {
let smtp = SmtpNotifier { host: "mail.example.com".to_string() };
// The compiler infers T as SmtpNotifier.
let service = NotificationService::new(smtp);
service.trigger("Hello world");
}
Generics are faster and use less memory. They are the default choice in Rust. The trade-off is code size. If you use the service with ten different notifier types, the compiler generates ten versions of NotificationService. This is usually fine. The performance gain outweighs the binary size increase.
Generics win on performance. Boxes win on flexibility. Choose based on your bottleneck.
Shared dependencies and Arc
Sometimes multiple services need the same dependency instance. For example, a UserService and an OrderService might both need to access the same database connection pool. If you inject the dependency by value, each service gets its own copy. That defeats the purpose of sharing.
Use Arc (Atomic Reference Counted) to share ownership. Arc wraps the dependency and keeps a count of how many owners exist. When the last owner drops, the dependency is freed. Combine Arc with a trait object to share a dependency without knowing its concrete type.
use std::sync::Arc;
struct UserService {
// Arc allows multiple owners. dyn Notifier allows any implementation.
notifier: Arc<dyn Notifier>,
}
struct OrderService {
notifier: Arc<dyn Notifier>,
}
fn main() {
// Create the notifier once.
let notifier = Arc::new(SmtpNotifier { host: "mail.example.com".to_string() });
// Clone the Arc. This bumps the reference count.
// It does not clone the SmtpNotifier.
let user_service = UserService { notifier: notifier.clone() };
let order_service = OrderService { notifier: notifier.clone() };
// Both services share the same underlying SmtpNotifier.
user_service.notifier.send("User alert");
order_service.notifier.send("Order alert");
}
Convention aside: When cloning an Arc or Rc, use arc.clone() rather than Arc::clone(&arc). Both compile, but the community prefers the method call form for brevity. The explicit form is reserved for cases where you want to emphasize that you are cloning the wrapper, not the inner value.
Shared state needs shared ownership. Arc keeps the dependency alive across multiple owners.
No magic containers
In languages like Java or C#, dependency injection often involves a framework. You register types in a container, and the container wires everything together automatically. Rust does not have this pattern. You wire dependencies manually.
This is a feature. Manual wiring makes the dependency graph explicit. You can see exactly where every dependency comes from by reading the code. There is no hidden magic. The compiler guarantees that the graph is valid. If you forget to pass a dependency, the code does not compile.
You construct the graph in main or in a dedicated builder function.
fn build_app() -> NotificationService<SmtpNotifier> {
// Explicit construction.
// The types are clear. The flow is obvious.
let notifier = SmtpNotifier { host: "mail.example.com".to_string() };
NotificationService::new(notifier)
}
Some crates provide container-like abstractions, but they are rare and usually unnecessary. The type system handles the wiring. If your dependencies are complex, consider a builder pattern or a configuration struct to group them. Do not reach for a framework.
Wire your dependencies explicitly. The compiler guarantees the graph is valid at compile time.
Pitfalls and compiler errors
Dependency injection in Rust is safe, but the type system enforces strict rules. You will encounter errors if you violate trait bounds or misuse trait objects.
If you forget the trait bound on a generic, the compiler rejects the code with E0277 (trait bound not satisfied). The error message tells you exactly which trait is missing. Add the where T: Trait clause or the <T: Trait> syntax to fix it.
Trait objects have limitations. You cannot use methods that return Self in a trait object. The compiler erases the concrete type, so it cannot know what Self is. If you need Self returns, use generics instead.
trait Builder {
// This method cannot be part of a dyn Trait object.
// Self is unknown for dyn Builder.
fn build(self) -> Self;
}
If you try to put such a trait behind dyn, the compiler blocks you. The error mentions that the trait is not object-safe. Object safety is the set of rules that allow a trait to be used as a trait object.
Trait objects also cannot have generic methods. A method like fn process<T>(&self, item: T) cannot be dispatched dynamically because the vtable cannot index over all possible types T. Use generics for the struct if you need generic methods.
Trait objects have rules. No Self returns, no generic methods. Read the error; it tells you exactly what the trait object can't do.
Decision: choosing your injection strategy
Rust gives you multiple tools for dependency injection. Pick the right one based on your needs.
Use generics when you want zero-cost abstraction and the dependency type is known at compile time. This is the default choice for most Rust code. It provides the best performance and allows the compiler to inline and optimize calls.
Use Box<dyn Trait> when you need to swap implementations at runtime or store heterogeneous dependencies in a collection. This adds a heap allocation and a vtable lookup, but it gives you dynamic flexibility.
Use Arc<dyn Trait> when multiple parts of your application share the same dependency instance. This combines shared ownership with dynamic dispatch. Use this for configuration objects, connection pools, or global services.
Use &dyn Trait when the dependency lives longer than the service and you do not need to take ownership. This avoids allocation entirely. The lifetime of the reference must outlive the service, so this works well for dependencies created early and dropped late.
Use impl Trait in function arguments when you want to accept any type implementing a trait without exposing a generic parameter on the struct. This is a form of reverse injection where the caller provides the type, but the function signature remains clean. It keeps the performance of generics while hiding the type parameter.