Strategy Pattern in Rust
You're building a notification service. The user can receive alerts via email, SMS, or push notification. Your core logic loops through a list of users and sends a message, but the sending mechanism changes based on the user's preference. In Python, you'd pass a function or a class instance. In JavaScript, you'd pass a callback. Rust gives you three distinct ways to structure this, and the choice affects your memory layout, performance, and API ergonomics.
The Strategy Pattern isn't a library you import. It's a design idiom. You separate the algorithm from the context that uses it. You define a contract, implement that contract for different behaviors, and pass the implementation to the context. Rust enforces that contract at compile time. The context doesn't care how the work gets done, only that the trait is satisfied.
Think of a power drill. The drill is the context. The bit is the strategy. You can swap bits to change behavior. In Rust, you can swap bits by wrapping them in a universal holder that fits any bit, by buying a drill manufactured specifically for one bit type, or by using a drill with a fixed set of slots for known bits. Each approach has trade-offs.
Trait objects: runtime flexibility
The most familiar approach uses trait objects. You define a trait, implement it for your strategies, and store the strategy behind a Box<dyn Trait>. This allows you to swap strategies at runtime. You can hold a collection of different strategies in a single Vec.
trait NotificationStrategy {
fn send(&self, message: &str) -> Result<(), String>;
}
struct EmailStrategy {
address: String,
}
impl NotificationStrategy for EmailStrategy {
fn send(&self, message: &str) -> Result<(), String> {
// Simulate SMTP call
println!("Sending email to {}: {}", self.address, message);
Ok(())
}
}
struct SmsStrategy {
phone: String,
}
impl NotificationStrategy for SmsStrategy {
fn send(&self, message: &str) -> Result<(), String> {
// Simulate SMS API call
println!("Sending SMS to {}: {}", self.phone, message);
Ok(())
}
}
struct Notifier {
// Box<dyn Trait> allocates on the heap and stores a fat pointer.
strategy: Box<dyn NotificationStrategy>,
}
impl Notifier {
fn new(strategy: Box<dyn NotificationStrategy>) -> Self {
Notifier { strategy }
}
fn notify(&self, message: &str) -> Result<(), String> {
// Dynamic dispatch happens here via the vtable.
self.strategy.send(message)
}
}
fn main() {
let email = Notifier::new(Box::new(EmailStrategy {
address: "user@example.com".to_string(),
}));
email.notify("Hello!").unwrap();
let sms = Notifier::new(Box::new(SmsStrategy {
phone: "123-456-7890".to_string(),
}));
sms.notify("Hi!").unwrap();
}
When you call Notifier::new(Box::new(EmailStrategy { ... })), the EmailStrategy struct moves onto the heap. The Box holds a pointer to that data. The dyn NotificationStrategy part adds a second pointer to a virtual table (vtable). The vtable is a static table generated by the compiler containing function pointers for every method in the trait.
When you call self.strategy.send(message), the code doesn't jump directly to EmailStrategy::send. It looks up the function pointer in the vtable and jumps through that. This is dynamic dispatch. The cost is a heap allocation when you create the Box, and an indirection through the vtable on every method call.
Convention aside: always write dyn explicitly. Older Rust allowed Box<NotificationStrategy>, but that syntax is deprecated. The dyn keyword signals to readers that this is a dynamic trait object, not a concrete type. It makes the intent unambiguous.
Trait objects trade a bit of speed for the freedom to change behavior at runtime.
Generics: compile-time dispatch
If you know the strategy at compile time, generics are usually the better choice. You parameterize the context with a type S that implements the trait. The compiler generates separate code for each concrete type you use. This is static dispatch.
struct Notifier<S> {
// S is stored inline. No heap allocation for the strategy itself.
strategy: S,
}
impl<S: NotificationStrategy> Notifier<S> {
fn new(strategy: S) -> Self {
Notifier { strategy }
}
fn notify(&self, message: &str) -> Result<(), String> {
// The compiler inlines this call. No vtable lookup.
self.strategy.send(message)
}
}
fn main() {
// Notifier<EmailStrategy> and Notifier<SmsStrategy> are distinct types.
let email = Notifier::new(EmailStrategy {
address: "user@example.com".to_string(),
});
email.notify("Hello!").unwrap();
let sms = Notifier::new(SmsStrategy {
phone: "123-456-7890".to_string(),
});
sms.notify("Hi!").unwrap();
}
Here, Notifier<EmailStrategy> and Notifier<SmsStrategy> are two completely different types in the compiled binary. The compiler performs monomorphization. It takes the generic code and generates a specialized copy for each type parameter. The notify call resolves directly to EmailStrategy::send or SmsStrategy::send. There is no vtable. There is no heap allocation for the strategy wrapper. The call can be inlined, allowing the optimizer to propagate constants and eliminate branches.
The trade-off is code size. If you use Notifier with dozens of different strategy types, the binary grows because each instantiation produces its own copy of the code. This is rarely a problem in practice, but it's the cost of zero-cost abstraction. You also cannot store Notifier<EmailStrategy> and Notifier<SmsStrategy> in the same Vec because they have different sizes and types.
Generics are the performance king. If you can lock the type at compile time, do it.
Enums: closed sets of strategies
When you have a fixed list of strategies defined within your own crate, an enum is often the cleanest solution. You avoid traits entirely. You get exhaustive matching. The compiler warns you if you add a new variant and forget to handle it.
enum NotificationMethod {
Email { address: String },
Sms { phone: String },
}
struct Notifier {
method: NotificationMethod,
}
impl Notifier {
fn new(method: NotificationMethod) -> Self {
Notifier { method }
}
fn notify(&self, message: &str) -> Result<(), String> {
// Direct dispatch via match. No trait overhead.
match &self.method {
NotificationMethod::Email { address } => {
println!("Sending email to {}: {}", address, message);
Ok(())
}
NotificationMethod::Sms { phone } => {
println!("Sending SMS to {}: {}", phone, message);
Ok(())
}
}
}
}
fn main() {
let notifier = Notifier::new(NotificationMethod::Email {
address: "user@example.com".to_string(),
});
notifier.notify("Hello!").unwrap();
}
The enum stores the data inline. The size of NotificationMethod is the size of the largest variant plus a discriminant byte to track which variant is active. When you call notify, the match checks the discriminant and jumps to the correct branch. There is no pointer indirection. There is no heap allocation. The code is fast and compact.
The limitation is extensibility. If a third-party crate wants to provide a new strategy, it can't add a variant to your enum. You must own the enum definition. This makes enums ideal for domain-specific logic where the set of options is controlled by the library author.
Enums are the Swiss army knife for closed sets. You get safety, speed, and no heap allocation.
Pitfalls and compiler errors
A common mistake is trying to return a trait object without boxing it. Traits are unsized types. The compiler needs to know the size of a value at compile time to allocate stack space. A dyn Trait has no fixed size because different implementations can have different sizes.
If you write fn get_strategy() -> NotificationStrategy, the compiler rejects it. You'll see an error about the size for values of type dyn NotificationStrategy cannot be known at compile-time. The fix is to wrap the trait object in a pointer type like Box, &, or Rc. Change the return type to Box<dyn NotificationStrategy>.
Another error appears when mixing generics and trait objects. Suppose you have a generic Notifier<S> and you try to pass a Box<dyn NotificationStrategy> to it.
// This fails to compile.
let notifier = Notifier::new(Box::new(EmailStrategy { ... }));
The compiler complains with E0277 (trait bound not satisfied). The error says the trait NotificationStrategy is not implemented for Box<dyn NotificationStrategy>. The box itself doesn't implement the trait; the dyn NotificationStrategy inside the box does. The generic S expects a type that implements the trait directly. You need to either change the generic to accept Box<dyn NotificationStrategy> explicitly, or switch to the trait object version of the context.
The compiler will reject unsized types in value positions. Wrap them in a Box, &, or Rc.
When to use which approach
Use Box<dyn Trait> when you need runtime polymorphism and the strategy can change after construction. This is the right tool for plugins, configuration-driven behavior, or when third-party code provides the implementation. You pay a small cost for heap allocation and virtual dispatch, but you gain the ability to hold heterogeneous strategies in a single collection.
Use generics when the strategy is fixed at compile time and you want zero-cost abstraction. This is the default for high-performance libraries. The compiler monomorphizes the code, inlining the strategy calls and eliminating indirection. Watch for code bloat if you instantiate the generic with many distinct types.
Use enums when you have a closed set of strategies defined within your own crate. Enums avoid trait overhead entirely and give you exhaustive matching. The compiler forces you to handle every variant, which prevents silent failures when you add a new strategy. This is often the cleanest solution for domain-specific logic like payment methods or serialization formats.