Arquitetando Sistemas em Rust
A maioria das linguagens permite que você adicione arquitetura depois do fato: desenhe caixas e setas em um quadro branco, e espere que o código siga.
Rust não oferece essa cortesia, porque o compilador impõe um grafo de dependências real, um grafo de propriedade real e um conjunto real de tipos no momento em que você escreve use e struct.
Esta página é a âncora conceitual para o restante da seção architecture-design: ela explica por que limites de crates, estratégia de erros e modelagem de domínio são realmente a mesma atividade vista de três ângulos, e por que adiar qualquer uma delas é mais caro em Rust do que em uma linguagem de tipagem dinâmica.
Resumo
- Em Rust, a arquitetura é codificada diretamente no grafo de crates/módulos, na hierarquia de tipos de erro e nos tipos de domínio, em vez de viver apenas em diagramas ou documentação.
- Por que Importa: Decisões tomadas tardiamente (limites de crates flexíveis, tratamento de erros ad hoc, dados de domínio com tipos primitivos) se acumulam em acoplamento em tempo de compilação que é muito mais difícil de desfazer do que em linguagens sem um borrow checker.
- Conceitos Chave: grafo de crates, limite de módulo, camadas de erro, newtype, typestate, estado ilegal.
- Quando Usar: Ao iniciar um workspace com múltiplos crates, ao introduzir uma segunda superfície de integração (HTTP mais CLI, por exemplo), ao projetar um tipo de erro para uma biblioteca que outros consumirão, ou ao modelar um domínio com invariantes reais (pedidos, contas, fluxos de trabalho).
- Limitações / Trade-offs: Aplicar excessivamente essas ideias a um script pequeno ou protótipo adiciona cerimônia (crates extras, variantes de enum extras, newtypes extras) que desacelera a iteração inicial sem um retorno correspondente.
- Tópicos Relacionados: propriedade e tempos de vida, abstração baseada em traits, ferramentas de workspace, design de API.
Fundamentos
Arquitetura, despojada de jargões, é o conjunto de decisões sobre o que depende do quê, o que pode falhar e como a falha é representada, e quais formatos de dados são permitidos existir.
Rust torna todas essas três decisões visíveis e, crucialmente, aplicáveis pelo compilador, em vez de apenas por convenção ou revisão de código.
Um grafo de crates é o grafo direcionado formado pelas entradas de dependência do Cargo.toml; um limite de módulo é o equivalente de granularidade mais fina dentro de um único crate, desenhado com pub, pub(crate) e itens privados.
Como Rust proíbe explicitamente dependências circulares entre crates, o grafo de crates é um grafo acíclico forçado, o que significa que o próprio compilador rejeita uma certa classe de "arquitetura espaguete" que outros ecossistemas apenas desencorajam.
Camadas de erro significam que cada camada de um sistema (lógica de domínio, adaptadores de infraestrutura, manipuladores de transporte) possui um tipo de erro moldado para suas próprias preocupações, com tradução ocorrendo nas junções em vez de um único enum de erro gigante atravessando tudo.
Um newtype é uma struct tupla de campo único que envolve um primitivo (struct OrderId(u64)) para que o verificador de tipos, e não uma verificação em tempo de execução, impeça a mistura de um ID de pedido e um ID de cliente, mesmo que ambos sejam u64 por baixo.
Um estado ilegal é um valor que o seu modelo de dados pode tecnicamente representar, mas que nunca deveria existir na prática, como um pedido que está simultaneamente "cancelado" e "enviado" quando esses são armazenados como dois booleanos independentes em vez de um enum.
A percepção unificadora em tudo isso é que o sistema de tipos e o modelo de propriedade de Rust não são apenas recursos de segurança, eles são uma linguagem de descrição de arquitetura que o compilador verifica ativamente a cada build.
Mecânicas e Interações
Esses três pilares interagem em vez de ficarem isolados.
Os limites dos crates determinam quais tipos de erro são visíveis para quais camadas, porque um tipo de erro definido em um crate domain não pode vazar detalhes específicos do framework (um código de status axum, um erro de linha sqlx) sem que esse crate dependa de axum ou sqlx, o que o limite deveria proibir.
A modelagem de domínio molda o que os tipos de erro precisam expressar, porque um domínio bem tipado muitas vezes transforma classes inteiras de erros de tempo de execução em impossibilidades em tempo de compilação, encolhendo o enum de erro em vez de aumentá-lo.
A direção da dependência que mais importa é que os crates de infraestrutura e transporte dependem internamente do crate de domínio, nunca o contrário, o que mantém as regras de negócio testáveis sem um banco de dados ou um servidor HTTP em execução.
// crate de domínio: nenhum tokio, axum ou sqlx em Cargo.toml
pub enum OrderError { NotFound(OrderId), AlreadyShipped }
// crate de infraestrutura depende do domínio; o domínio nunca depende da infraestrutura
impl From<sqlx::Error> for OrderError {
fn from(_: sqlx::Error) -> Self { OrderError::NotFound(OrderId(0)) }
}O trecho acima é menos sobre o código e mais sobre o que está ausente: o Cargo.toml do crate domain não tem como importar acidentalmente um framework web, então o compilador impede fisicamente a deriva arquitetural mais comum, a lógica de negócio dependendo silenciosamente do mecanismo de entrega.
Uma falha de raciocínio comum é tratar isso como um passo de configuração único; na prática, o grafo deve ser reavaliado sempre que um novo crate é adicionado, porque uma única instrução use mal colocada através de um limite pode reintroduzir silenciosamente o acoplamento que a divisão pretendia prevenir.
Considerações Avançadas e Aplicações
Em escala, o grafo de crates se torna uma alavanca de desempenho de build tanto quanto uma de design, porque o Cargo recompila um crate e tudo o que está a jusante dele sempre que sua API pública muda, então crates instáveis perto da raiz do grafo desaceleram o loop interno de cada desenvolvedor.
A estratégia de erros precisa levar em conta a observabilidade desde o início: um enum de erro de domínio que é #[non_exhaustive] permite adicionar novos casos de falha sem uma quebra de semver, enquanto um conjunto estável de códigos de erro externos mantém os SDKs de clientes e dashboards sem quebrar em refatorações internas.
A modelagem de domínio compensa mais em sistemas com máquinas de estado reais, fluxos de trabalho, pagamentos, ciclos de vida de pedidos, onde um padrão de typestate (cada estado é um tipo distinto, e apenas os métodos de transição válidos existem em cada tipo) transforma bugs de "esquecemos de verificar isso?" em erros do tipo "este código não compila".
Nenhum desses três pilares é gratuito, e a habilidade avançada é saber até onde empurrar cada um para o perfil de risco real do sistema, em vez de aplicar rigor máximo em todos os lugares.
| Abordagem | Força | Fraqueza | Melhor Ajuste |
|---|---|---|---|
| Crate único, apenas módulos | Iteração rápida, sem cerimônia de workspace | Sem limite imposto pelo compilador, fácil de confundir camadas | Projetos solo, protótipos, CLIs pequenas |
| Workspace com múltiplos crates e regra de dependência interna | Limites impostos pelo compilador, compilação paralela, domínio testável isoladamente | Mais arquivos Cargo.toml para manter, custo de design inicial | Sistemas com 2+ superfícies de integração ou múltiplos contribuidores |
| Um único enum de erro "deus" compartilhado | Simples de começar, um tipo para match em todos os lugares | Cada camada recompila em qualquer mudança de erro, vaza detalhes internos para o exterior | Nunca recomendado além de um protótipo descartável |
Erros em camadas com tradução #[from] nas junções | Cada camada possui suas próprias preocupações, mudanças permanecem locais | Requer escrita de impls de conversão em cada junção | Qualquer sistema com mais de um crate ou API externa |
Dados de domínio com tipos primitivos (raw u64, String, flags bool) | Zero cerimônia, rápido de escrever | Estados ilegais compilam, invariantes vivem apenas em comentários | Scripts, ferramentas descartáveis |
| Newtypes mais enums para estado de domínio | Estados ilegais se tornam erros de compilação, auto-documentados | Mais tipos para definir e passar pelo codebase | Regras de negócio onde a correção importa e bugs são caros |
Conceitos Equivocados Comuns
- "Arquitetura significa escolher o padrão de design certo." - Em Rust, as decisões de maior alavancagem são a direção da dependência do crate e a forma dos tipos de domínio; padrões como builder ou visitor são detalhes de implementação sobrepostos, não a arquitetura em si.
- "Posso adicionar limites de crate mais tarde, quando o projeto crescer." - Dividir um crate depois que a lógica de negócio e o código do framework já estão emaranhados é um refator real, não uma movimentação de arquivo, porque cada
usede um tipo de framework dentro do código "de domínio" tem que ser encontrado e removido. - "Um grande enum de erro mantém as coisas simples." - Parece simples no início, mas significa que cada crate que produz ou consome erros recompila sempre que qualquer caso de erro individual muda em qualquer lugar do sistema, e força detalhes internos para consumidores de API pública.
- "O sistema de tipos substitui a necessidade de testes." - Newtypes e enums eliminam categorias inteiras de bugs (IDs misturados, combinações de estado impossíveis), mas não verificam a lógica de negócio como cálculos de impostos ou regras de agendamento; você ainda precisa de testes para eles.
- "Mais crates são sempre melhores para a arquitetura." - Dividir em muitos crates pequenos antes que os limites sejam estáveis adiciona sobrecarga de
Cargo.tomle turbulência no grafo de dependências sem um benefício correspondente; três a sete crates é uma faixa comum para sistemas de médio porte, não trinta.
FAQs
O que "arquitetura" significa concretamente em um codebase Rust?
É a combinação de três coisas que o compilador realmente impõe: o grafo acíclico de dependências de crate/módulo, a hierarquia de tipos de erro e onde a tradução ocorre entre as camadas, e os tipos de domínio que determinam quais estados são representáveis.
Por que a estratégia de erros deve ser decidida cedo em vez de evoluir naturalmente?
Uma vez que algumas dezenas de sites de chamada usam .unwrap() ou convertem tudo genericamente para anyhow::Error, retrofitar erros tipados e em camadas significa tocar em cada um desses sites de chamada individualmente.
Decidir a forma antecipadamente, com erros de domínio tipados internamente e tradução nas bordas, custa pouco quando o codebase é pequeno e acumula economia à medida que cresce.
Como o grafo de crates realmente impõe limites arquiteturais?
O Cargo não compilará uma dependência circular entre crates, então se domain nunca listar infra como uma dependência, o código em domain fisicamente não pode importar nada de infra, nem mesmo por acidente.
Isso é mais forte do que uma regra de lint ou de revisão de código porque é verificado em cada build, não apenas quando alguém se lembra de olhar.
Como a modelagem de domínio realmente reduz o tamanho de um tipo de erro?
Cada verificação em tempo de execução que você move para o sistema de tipos remove uma variante Err correspondente que, de outra forma, existiria para relatar a violação em tempo de execução.
Um newtype NonEmptyVec<LineItem> significa que "o pedido não tem itens de linha" não é mais um caso de erro possível, porque não é mais um valor possível.
Quando um único crate com módulos é a escolha certa em vez de um workspace?
Projetos solo, protótipos e CLIs pequenas com uma única superfície de integração raramente se beneficiam da sobrecarga do workspace; limites de módulo pub(crate) dão a maior parte do benefício organizacional sem arquivos Cargo.toml extras para manter.
Opte por um workspace quando houver duas ou mais superfícies de integração (HTTP e CLI, por exemplo) ou mais de um contribuinte trabalhando em paralelo.
Qual é o trade-off de levar a modelagem de typestate longe demais?
Typestate (um tipo distinto por estado, com apenas métodos de transição válidos disponíveis) é poderoso para fluxos de trabalho genuinamente multi-etapas, mas aplicá-lo a simples flags de dois estados multiplica o número de tipos e funções para pouco ganho real de segurança. Reserve-o para máquinas de estado onde uma transição inválida seria um bug real e custoso, não para cada booleano no sistema.
Um workspace com muitos crates pequenos é sempre mais "arquitetado" do que um crate grande?
Não; crates extras adicionam custo real (complexidade do grafo de build, mais arquivos para manter sincronizados, cargo check mais lento em alguns fluxos de trabalho) e esse custo deve ser pago apenas quando ele compra algo, como isolamento de compilação para uma camada de domínio estável ou um limite de equipe genuíno.
Um monólito com limites de módulo limpos pode ser tão bem arquitetado quanto um workspace, e muitas vezes é o melhor ponto de partida.
Como esses três pilares aparecem de forma diferente para um crate de biblioteca versus uma aplicação binária?
Um crate de biblioteca deve evitar anyhow completamente e expor um enum de erro preciso e #[non_exhaustive], porque seu tipo de erro faz parte de seu contrato de API pública com consumidores downstream.
Uma aplicação binária tem mais liberdade para agregar erros de forma flexível no nível superior (em main, por exemplo), pois é o fim da cadeia de chamadas, não uma API da qual outro código depende.
Qual é o equívoco mais comum que causa dor real na produção?
Acreditar que os limites dos crates podem ser adicionados depois "quando tivermos tempo", o que na prática significa que a lógica de negócio acumula silenciosamente importações de framework (um código de status HTTP aqui, um tipo de linha de banco de dados ali) até que separá-los exija tocar em grande parte do codebase de uma vez.
Como a regra de dependência interna interage com os testes?
Como a infraestrutura depende do domínio e não o contrário, a lógica de domínio pode ser testada unitariamente sem uma conexão de banco de dados, um cliente HTTP ou qualquer I/O, o que torna o conjunto de testes rápido porque não há nada lento para esperar. Os adaptadores de infraestrutura recebem seus próprios testes de integração separados que exercitam as chamadas reais de banco de dados ou de rede.
Fazer estados ilegais não representáveis significa que não preciso mais de validação em tempo de execução?
Não inteiramente; os dados ainda chegam de fora do controle do sistema de tipos, pela rede como JSON, de uma linha de banco de dados, de entrada do usuário, então uma etapa de validação ou TryFrom na borda ainda é necessária para converter entrada de tipagem flexível no tipo de domínio estrito.
O trabalho do sistema de tipos começa assim que essa conversão é bem-sucedida, garantindo que o valor resultante não possa se tornar inválido depois.
Como decido em qual dos três pilares investir primeiro em um novo projeto?
Comece com a decisão do limite do crate/módulo, porque ela determina onde os outros dois vivem; decida a estratégia de erros em seguida, pois ela molda como cada camada comunica falhas; trate a tipagem rica de domínio como a camada que você aprofunda incrementalmente à medida que regras de negócio reais e seus bugs associados emergem.
Relacionados
- Workspace & Limites de Crate - o pilar do grafo de crates em profundidade, com um layout de workspace concreto e regras de dependência.
- Estratégia de Erros - como sobrepor tipos de erro de domínio, infraestrutura e API com tradução nas junções.
- Modelagem de Domínio com Tipos - newtypes, enums e typestate para tornar estados ilegais não representáveis.
- Abstração Baseada em Traits - como traits definem as portas que permitem que a infraestrutura se conecte ao domínio sem que o domínio dependa dela.
- Checklist de Decisão de Arquitetura - um checklist prático para aplicar esses pilares ao tomar uma decisão arquitetural real.
- Padrão Typestate - o padrão por trás da modelagem de domínio estilo máquina de estado referenciada acima.
Versões da Stack: Esta página foi escrita para Rust 1.97.0 (edição 2024), Tokio 1.x, Axum 0.8, serde 1.0, sqlx 0.8, clap 4, e Polars 0.46+.