Padrões de Projeto em Rust: Guia Completo | Rust Brasil

Padrões de projeto em Rust: Builder, Strategy, Observer, State e mais. Implementações idiomáticas com traits e enums.

Introdução

Padrões de projeto clássicos do Gang of Four (GoF) foram concebidos para linguagens orientadas a objetos com herança. Rust não tem herança, classes ou interfaces tradicionais — em vez disso, oferece traits, enums, ownership e um sistema de tipos expressivo que permite implementar padrões de formas únicas e muitas vezes mais seguras.

Neste artigo, vamos explorar os padrões de projeto mais úteis e idiomáticos em Rust, mostrando como os recursos da linguagem transformam padrões clássicos em soluções elegantes com garantias em tempo de compilação.

O Problema: Código sem Estrutura

Não Faça Isso: Construtores com Muitos Parâmetros

// ERRADO: Construtor com muitos parâmetros — fácil errar a ordem
struct Servidor {
    host: String,
    porta: u16,
    max_conexoes: u32,
    timeout_ms: u64,
    tls: bool,
    certificado: Option<String>,
    log_level: String,
}

impl Servidor {
    fn new(
        host: String,
        porta: u16,
        max_conexoes: u32,
        timeout_ms: u64,
        tls: bool,
        certificado: Option<String>,
        log_level: String,
    ) -> Self {
        Servidor { host, porta, max_conexoes, timeout_ms, tls, certificado, log_level }
    }
}

fn main() {
    // Qual argumento é qual? Fácil trocar 100 com 5000
    let server = Servidor::new(
        "localhost".into(), 8080, 100, 5000, true, None, "info".into()
    );
}

Não Faça Isso: Tipos Primitivos para Tudo

// ERRADO: Tipos primitivos não previnem erros lógicos
fn transferir(de: u64, para: u64, valor: f64) {
    // de e para são ambos u64 — fácil trocar os argumentos
    println!("Transferindo {valor} da conta {de} para conta {para}");
}

fn main() {
    // Bug silencioso: argumentos trocados, compila sem problemas
    transferir(999, 123, 500.0); // Queria 123 → 999 mas escreveu ao contrário
}

Padrão 1: Builder Pattern

O Builder é o padrão mais comum em Rust para construir structs complexas com validação:

/// Configuração de um servidor HTTP.
pub struct ServidorConfig {
    host: String,
    porta: u16,
    max_conexoes: u32,
    timeout_ms: u64,
    tls: bool,
}

/// Builder para construir ServidorConfig passo a passo.
pub struct ServidorConfigBuilder {
    host: String,
    porta: u16,
    max_conexoes: u32,
    timeout_ms: u64,
    tls: bool,
}

impl ServidorConfigBuilder {
    pub fn new() -> Self {
        ServidorConfigBuilder {
            host: "127.0.0.1".to_string(),
            porta: 8080,
            max_conexoes: 100,
            timeout_ms: 30_000,
            tls: false,
        }
    }

    pub fn host(mut self, host: &str) -> Self {
        self.host = host.to_string();
        self
    }

    pub fn porta(mut self, porta: u16) -> Self {
        self.porta = porta;
        self
    }

    pub fn max_conexoes(mut self, max: u32) -> Self {
        self.max_conexoes = max;
        self
    }

    pub fn timeout_ms(mut self, timeout: u64) -> Self {
        self.timeout_ms = timeout;
        self
    }

    pub fn tls(mut self, tls: bool) -> Self {
        self.tls = tls;
        self
    }

    pub fn build(self) -> Result<ServidorConfig, String> {
        if self.porta == 0 {
            return Err("Porta não pode ser zero".into());
        }
        if self.max_conexoes == 0 {
            return Err("max_conexoes deve ser maior que zero".into());
        }

        Ok(ServidorConfig {
            host: self.host,
            porta: self.porta,
            max_conexoes: self.max_conexoes,
            timeout_ms: self.timeout_ms,
            tls: self.tls,
        })
    }
}

fn main() {
    // Legível, com valores padrão, e validação no build()
    let config = ServidorConfigBuilder::new()
        .host("0.0.0.0")
        .porta(3000)
        .max_conexoes(500)
        .tls(true)
        .build()
        .expect("Configuração inválida");

    println!("Servidor em {}:{}", config.host, config.porta);
}

Com a crate derive_builder, você pode gerar o builder automaticamente:

# Cargo.toml
[dependencies]
derive_builder = "0.20"
use derive_builder::Builder;

#[derive(Builder, Debug)]
#[builder(setter(into))]
pub struct Email {
    destinatario: String,
    assunto: String,
    corpo: String,
    #[builder(default = "false")]
    html: bool,
}

fn main() {
    let email = EmailBuilder::default()
        .destinatario("user@example.com")
        .assunto("Bem-vindo!")
        .corpo("Olá, seja bem-vindo ao sistema.")
        .build()
        .unwrap();

    println!("{:?}", email);
}

Padrão 2: Newtype Pattern

Newtype wraps um tipo primitivo em uma struct para criar um tipo distinto com semântica:

/// ID de conta bancária — tipo distinto de u64.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ContaId(u64);

impl ContaId {
    pub fn new(id: u64) -> Self {
        ContaId(id)
    }

    pub fn valor(&self) -> u64 {
        self.0
    }
}

/// Valor monetário em centavos — evita erros de ponto flutuante.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Dinheiro(i64);

impl Dinheiro {
    pub fn reais(valor: i64) -> Self {
        Dinheiro(valor * 100)
    }

    pub fn centavos(valor: i64) -> Self {
        Dinheiro(valor)
    }

    pub fn em_centavos(&self) -> i64 {
        self.0
    }

    pub fn em_reais(&self) -> f64 {
        self.0 as f64 / 100.0
    }
}

impl std::fmt::Display for Dinheiro {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "R$ {:.2}", self.em_reais())
    }
}

impl std::ops::Add for Dinheiro {
    type Output = Self;
    fn add(self, rhs: Self) -> Self {
        Dinheiro(self.0 + rhs.0)
    }
}

// Agora é IMPOSSÍVEL trocar conta_origem com conta_destino
fn transferir(de: ContaId, para: ContaId, valor: Dinheiro) -> Result<(), String> {
    if valor.em_centavos() <= 0 {
        return Err("Valor deve ser positivo".into());
    }
    println!("Transferindo {valor} da conta {:?} para {:?}", de, para);
    Ok(())
}

fn main() {
    let alice = ContaId::new(123);
    let bob = ContaId::new(456);
    let valor = Dinheiro::reais(500);

    // transferir(bob, alice, valor) — a ordem é clara pelo nome dos tipos
    transferir(alice, bob, valor).unwrap();

    // Isso NÃO COMPILA — tipos diferentes
    // transferir(123u64, 456u64, 500.0f64);
}

Padrão 3: Typestate Pattern

O Typestate Pattern usa o sistema de tipos para garantir que operações ocorram na ordem correta em tempo de compilação:

use std::marker::PhantomData;

// Estados (tipos sem dados, usados apenas no sistema de tipos)
pub struct Rascunho;
pub struct Revisado;
pub struct Publicado;

/// Documento cujo estado é rastreado pelo sistema de tipos.
pub struct Documento<Estado> {
    titulo: String,
    conteudo: String,
    _estado: PhantomData<Estado>,
}

impl Documento<Rascunho> {
    pub fn novo(titulo: &str) -> Self {
        Documento {
            titulo: titulo.to_string(),
            conteudo: String::new(),
            _estado: PhantomData,
        }
    }

    pub fn escrever(&mut self, texto: &str) {
        self.conteudo.push_str(texto);
    }

    /// Envia para revisão — consome o Rascunho e retorna Revisado
    pub fn enviar_para_revisao(self) -> Documento<Revisado> {
        println!("'{}' enviado para revisão", self.titulo);
        Documento {
            titulo: self.titulo,
            conteudo: self.conteudo,
            _estado: PhantomData,
        }
    }
}

impl Documento<Revisado> {
    /// Aprova e publica — consome Revisado e retorna Publicado
    pub fn aprovar(self) -> Documento<Publicado> {
        println!("'{}' aprovado e publicado", self.titulo);
        Documento {
            titulo: self.titulo,
            conteudo: self.conteudo,
            _estado: PhantomData,
        }
    }

    /// Rejeita e volta para rascunho
    pub fn rejeitar(self, motivo: &str) -> Documento<Rascunho> {
        println!("'{}' rejeitado: {motivo}", self.titulo);
        Documento {
            titulo: self.titulo,
            conteudo: self.conteudo,
            _estado: PhantomData,
        }
    }
}

impl Documento<Publicado> {
    pub fn url(&self) -> String {
        format!("/artigos/{}", self.titulo.to_lowercase().replace(' ', "-"))
    }
}

fn main() {
    // Fluxo correto: Rascunho → Revisado → Publicado
    let mut doc = Documento::<Rascunho>::novo("Meu Artigo");
    doc.escrever("Conteúdo do artigo...");

    let doc = doc.enviar_para_revisao();
    let doc = doc.aprovar();
    println!("Publicado em: {}", doc.url());

    // Isso NÃO COMPILA — não pode publicar diretamente do rascunho:
    // let doc = Documento::<Rascunho>::novo("Teste");
    // doc.aprovar(); // ERRO: método `aprovar` não existe para Documento<Rascunho>
}

Padrão 4: RAII (Resource Acquisition Is Initialization)

Rust implementa RAII nativamente com Drop. Recursos são liberados automaticamente quando saem do escopo:

use std::io::{self, Write, BufWriter};
use std::fs::File;

/// Timer que mede a duração de um escopo automaticamente.
pub struct Timer {
    nome: String,
    inicio: std::time::Instant,
}

impl Timer {
    pub fn new(nome: &str) -> Self {
        println!("[TIMER] '{}' iniciado", nome);
        Timer {
            nome: nome.to_string(),
            inicio: std::time::Instant::now(),
        }
    }
}

impl Drop for Timer {
    fn drop(&mut self) {
        let duracao = self.inicio.elapsed();
        println!("[TIMER] '{}' finalizado em {:?}", self.nome, duracao);
    }
}

/// Guard para arquivo temporário que é deletado ao sair do escopo.
pub struct ArquivoTemporario {
    caminho: std::path::PathBuf,
}

impl ArquivoTemporario {
    pub fn new(nome: &str) -> io::Result<Self> {
        let caminho = std::env::temp_dir().join(nome);
        File::create(&caminho)?;
        Ok(ArquivoTemporario { caminho })
    }

    pub fn escrever(&self, conteudo: &str) -> io::Result<()> {
        let file = File::create(&self.caminho)?;
        let mut writer = BufWriter::new(file);
        writer.write_all(conteudo.as_bytes())?;
        writer.flush()
    }

    pub fn caminho(&self) -> &std::path::Path {
        &self.caminho
    }
}

impl Drop for ArquivoTemporario {
    fn drop(&mut self) {
        if let Err(e) = std::fs::remove_file(&self.caminho) {
            eprintln!("Aviso: não foi possível remover {:?}: {e}", self.caminho);
        } else {
            println!("Arquivo temporário {:?} removido", self.caminho);
        }
    }
}

fn main() -> io::Result<()> {
    let _timer = Timer::new("main");

    {
        let temp = ArquivoTemporario::new("dados.tmp")?;
        temp.escrever("dados temporários")?;
        println!("Arquivo em: {:?}", temp.caminho());
        // temp é automaticamente deletado aqui
    }

    println!("Arquivo temporário já foi removido");
    Ok(())
}

Padrão 5: Strategy Pattern com Traits

Traits em Rust substituem interfaces e permitem polimorfismo em tempo de compilação (generics) ou em tempo de execução (trait objects):

/// Trait que define a estratégia de cálculo de desconto.
pub trait Desconto {
    fn calcular(&self, valor: f64) -> f64;
    fn nome(&self) -> &str;
}

pub struct SemDesconto;
impl Desconto for SemDesconto {
    fn calcular(&self, valor: f64) -> f64 { valor }
    fn nome(&self) -> &str { "Sem desconto" }
}

pub struct DescontoPercentual {
    percentual: f64,
}

impl DescontoPercentual {
    pub fn new(percentual: f64) -> Self {
        DescontoPercentual { percentual }
    }
}

impl Desconto for DescontoPercentual {
    fn calcular(&self, valor: f64) -> f64 {
        valor * (1.0 - self.percentual / 100.0)
    }
    fn nome(&self) -> &str { "Desconto percentual" }
}

pub struct DescontoFixo {
    valor_desconto: f64,
}

impl DescontoFixo {
    pub fn new(valor: f64) -> Self {
        DescontoFixo { valor_desconto: valor }
    }
}

impl Desconto for DescontoFixo {
    fn calcular(&self, valor: f64) -> f64 {
        (valor - self.valor_desconto).max(0.0)
    }
    fn nome(&self) -> &str { "Desconto fixo" }
}

/// Carrinho que aceita qualquer estratégia de desconto.
pub struct Carrinho {
    itens: Vec<(String, f64)>,
    desconto: Box<dyn Desconto>,
}

impl Carrinho {
    pub fn new(desconto: Box<dyn Desconto>) -> Self {
        Carrinho {
            itens: Vec::new(),
            desconto,
        }
    }

    pub fn adicionar(&mut self, nome: &str, preco: f64) {
        self.itens.push((nome.to_string(), preco));
    }

    pub fn total(&self) -> f64 {
        let subtotal: f64 = self.itens.iter().map(|(_, p)| p).sum();
        self.desconto.calcular(subtotal)
    }
}

fn main() {
    let mut carrinho = Carrinho::new(Box::new(DescontoPercentual::new(15.0)));
    carrinho.adicionar("Teclado", 250.0);
    carrinho.adicionar("Mouse", 150.0);
    println!("Total com 15% de desconto: R$ {:.2}", carrinho.total());

    let mut carrinho2 = Carrinho::new(Box::new(DescontoFixo::new(50.0)));
    carrinho2.adicionar("Monitor", 1200.0);
    println!("Total com R$50 de desconto: R$ {:.2}", carrinho2.total());
}

Padrão 6: Observer com Channels

Em vez de callbacks, Rust usa channels para comunicação desacoplada:

use std::sync::mpsc;
use std::thread;

#[derive(Debug, Clone)]
pub enum Evento {
    UsuarioCriado { id: u64, nome: String },
    PedidoRealizado { id: u64, valor: f64 },
    PagamentoConfirmado { pedido_id: u64 },
}

/// Publisher que envia eventos para múltiplos subscribers.
pub struct EventBus {
    senders: Vec<mpsc::Sender<Evento>>,
}

impl EventBus {
    pub fn new() -> Self {
        EventBus { senders: Vec::new() }
    }

    pub fn subscribe(&mut self) -> mpsc::Receiver<Evento> {
        let (tx, rx) = mpsc::channel();
        self.senders.push(tx);
        rx
    }

    pub fn publicar(&self, evento: Evento) {
        // Remove senders desconectados mantendo os ativos
        for sender in &self.senders {
            let _ = sender.send(evento.clone());
        }
    }
}

fn main() {
    let mut bus = EventBus::new();

    // Subscriber 1: Logger
    let rx_logger = bus.subscribe();
    let logger = thread::spawn(move || {
        while let Ok(evento) = rx_logger.recv() {
            println!("[LOG] Evento recebido: {evento:?}");
        }
    });

    // Subscriber 2: Notificador
    let rx_notif = bus.subscribe();
    let notificador = thread::spawn(move || {
        while let Ok(evento) = rx_notif.recv() {
            if let Evento::PedidoRealizado { id, valor } = evento {
                println!("[NOTIF] Novo pedido #{id}: R$ {valor:.2}");
            }
        }
    });

    // Publicar eventos
    bus.publicar(Evento::UsuarioCriado {
        id: 1,
        nome: "Maria".into(),
    });
    bus.publicar(Evento::PedidoRealizado { id: 100, valor: 299.90 });
    bus.publicar(Evento::PagamentoConfirmado { pedido_id: 100 });

    // Fechar o bus (drop dos senders)
    drop(bus);

    logger.join().unwrap();
    notificador.join().unwrap();
}

Padrão 7: Command Pattern com Enums

Enums em Rust são ideais para representar comandos:

/// Comandos para um editor de texto.
#[derive(Debug)]
pub enum Comando {
    Inserir { posicao: usize, texto: String },
    Deletar { posicao: usize, quantidade: usize },
    Substituir { de: String, para: String },
}

pub struct Editor {
    conteudo: String,
    historico: Vec<(Comando, String)>, // (comando, estado anterior)
}

impl Editor {
    pub fn new(conteudo: &str) -> Self {
        Editor {
            conteudo: conteudo.to_string(),
            historico: Vec::new(),
        }
    }

    pub fn executar(&mut self, comando: Comando) {
        let estado_anterior = self.conteudo.clone();

        match &comando {
            Comando::Inserir { posicao, texto } => {
                self.conteudo.insert_str(*posicao, texto);
            }
            Comando::Deletar { posicao, quantidade } => {
                let fim = (*posicao + *quantidade).min(self.conteudo.len());
                self.conteudo.drain(*posicao..fim);
            }
            Comando::Substituir { de, para } => {
                self.conteudo = self.conteudo.replace(de, para);
            }
        }

        self.historico.push((comando, estado_anterior));
    }

    pub fn desfazer(&mut self) -> bool {
        if let Some((_comando, estado_anterior)) = self.historico.pop() {
            self.conteudo = estado_anterior;
            true
        } else {
            false
        }
    }

    pub fn conteudo(&self) -> &str {
        &self.conteudo
    }
}

fn main() {
    let mut editor = Editor::new("Olá Mundo");
    println!("Inicial: '{}'", editor.conteudo());

    editor.executar(Comando::Substituir {
        de: "Mundo".into(),
        para: "Rust".into(),
    });
    println!("Após substituir: '{}'", editor.conteudo());

    editor.executar(Comando::Inserir {
        posicao: 4,
        texto: ", bem-vindo ao".into(),
    });
    println!("Após inserir: '{}'", editor.conteudo());

    editor.desfazer();
    println!("Após desfazer: '{}'", editor.conteudo());

    editor.desfazer();
    println!("Após desfazer: '{}'", editor.conteudo());
}

Armadilhas Comuns

1. Builder sem Validação

// ERRADO: Builder que sempre retorna Ok
impl ServidorConfigBuilder {
    pub fn build(self) -> ServidorConfig {
        // Nenhuma validação — aceita configuração inválida
        ServidorConfig { /* ... */ }
    }
}

// CORRETO: build() retorna Result
impl ServidorConfigBuilder {
    pub fn build(self) -> Result<ServidorConfig, String> {
        // Valida antes de construir
        if self.porta == 0 {
            return Err("Porta inválida".into());
        }
        Ok(ServidorConfig { /* ... */ })
    }
}

2. Newtype sem Implementar Traits Necessários

// ERRADO: Newtype sem Debug, Clone, etc.
struct UserId(u64);
// Não pode imprimir, copiar, comparar...

// CORRETO: Derive os traits necessários
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);

3. Trait Objects Onde Generics Bastam

// Desnecessário para um único tipo: Box<dyn> tem overhead de indireção
fn processar(estrategia: Box<dyn Desconto>, valor: f64) -> f64 {
    estrategia.calcular(valor)
}

// Melhor quando o tipo é conhecido em compilação: sem overhead
fn processar<D: Desconto>(estrategia: &D, valor: f64) -> f64 {
    estrategia.calcular(valor)
}

Quando Usar Cada Padrão

PadrãoUse Quando
BuilderStruct com muitos campos ou configuração complexa
NewtypeDistinguir tipos primitivos com mesma representação
TypestateGarantir sequência de operações em tempo de compilação
RAIIGerenciar recursos (arquivos, locks, conexões)
StrategyAlgoritmos intercambiáveis, polimorfismo
ObserverComunicação desacoplada entre componentes
CommandOperações reversíveis, filas de comandos

Veja Também