Introdução à Injeção de Dependência em Rust
Quando você escreve programas em Rust, mais cedo ou mais tarde se depara com o problema: como tornar o código flexível, testável e com baixo acoplamento? A resposta é Injeção de Dependência (DI). Este é um padrão de projeto no qual um objeto recebe suas dependências de fora, em vez de criá-las ele mesmo.
Neste artigo, vamos analisar como implementar Injeção de Dependência em Rust com exemplos práticos. Você aprenderá a usar trait-objetos e generics para criar uma arquitetura flexível. O material é voltado para desenvolvedores Rust iniciantes, familiarizados com os fundamentos da linguagem.
Por que a Injeção de Dependência é necessária?
Imagine que você está escrevendo um aplicativo para enviar notificações. Sem DI, a classe NotificationService estará rigidamente ligada a uma implementação específica (por exemplo, EmailSender). Isso torna o código:
- Difícil de testar — é impossível substituir o serviço real por um objeto mock.
- Difícil de estender — adicionar um novo método de envio (SMS, Push) exige alteração do código existente.
- Fortemente acoplado — mudanças em uma parte quebram a outra.
A Injeção de Dependência resolve esses problemas passando as dependências através do construtor, método ou parâmetro. Rust, sendo uma linguagem de sistema com tipagem forte, oferece maneiras elegantes de implementar DI.
Abordagens principais para DI em Rust
Em Rust, existem duas maneiras principais de implementar injeção de dependências: através de trait-objetos (despacho dinâmico) e através de generics (despacho estático). Vamos analisar ambas as abordagens.
1. DI via trait-objetos (despacho dinâmico)
Esta abordagem usa polimorfismo em tempo de execução. Você define um trait e então passa Box<dyn Trait> ou uma referência &dyn Trait.
Exemplo: serviço de logging
// Definindo um trait para loggingtrait Logger { fn log(&self, message: &str);}
// Implementação para saída no consolestruct ConsoleLogger;
impl Logger for ConsoleLogger { fn log(&self, message: &str) { println!("[LOG]: {}", message); }}
// Implementação para arquivostruct FileLogger { filename: String,}
impl Logger for FileLogger { fn log(&self, message: &str) { // Escrita simplificada em arquivo std::fs::write(&self.filename, message).unwrap(); }}
// Serviço usando DIstruct UserService { logger: Box<dyn Logger>,}
impl UserService { // Injeção de dependência via construtor fn new(logger: Box<dyn Logger>) -> Self { UserService { logger } }
fn create_user(&self, name: &str) { self.logger.log(&format!("Usuário criado: {}", name)); // Lógica de criação de usuário... }}
fn main() { // Usando ConsoleLogger let console_logger = Box::new(ConsoleLogger); let service = UserService::new(console_logger); service.create_user("Alice");
// Usando FileLogger let file_logger = Box::new(FileLogger { filename: "log.txt".into() }); let service2 = UserService::new(file_logger); service2.create_user("Bob");}Vantagens: flexibilidade, é possível mudar a implementação em tempo de execução. Desvantagens: pequena sobrecarga devido a chamadas virtuais.
2. DI via generics (despacho estático)
Aqui, as dependências são definidas em tempo de compilação através de parâmetros de tipo. Esta é uma abordagem mais performática, mas menos flexível.
Exemplo: o mesmo serviço de logging com generics
trait Logger { fn log(&self, message: &str);}
struct ConsoleLogge