Migrar de Node.js para Rust: Guia 2026 | Rust Brasil

Guia de migração de Node.js para Rust: async/await, web frameworks, npm vs Cargo e estratégia incremental.

Introdução

Node.js revolucionou o desenvolvimento backend ao permitir que desenvolvedores JavaScript criassem servidores de alta concorrência. Porém, à medida que aplicações crescem, os limites do Node.js aparecem: single-threaded por natureza, garbage collector que causa pausas de latência, tipagem dinâmica que permite bugs sutis em produção, e consumo elevado de memória.

Rust oferece um caminho natural para desenvolvedores Node.js que buscam mais performance, segurança de tipos e eficiência de recursos. Com o framework Axum (construído sobre Tokio), a experiência de desenvolvimento é surpreendentemente similar ao Express/Fastify, mas com as garantias do compilador Rust.

O Problema: Limites do Node.js em Alta Escala

Node.js (Express) — API Típica

// Node.js com Express
const express = require('express');
const app = express();
app.use(express.json());

// Sem tipos — bugs em runtime
const usuarios = new Map();
let proximoId = 1;

app.get('/usuarios/:id', (req, res) => {
    const id = parseInt(req.params.id); // Pode ser NaN!
    const usuario = usuarios.get(id);

    if (!usuario) {
        return res.status(404).json({ erro: 'Usuário não encontrado' });
    }

    res.json(usuario);
});

app.post('/usuarios', (req, res) => {
    const { nome, email } = req.body;
    // Sem validação: nome pode ser undefined, null, 42, {}...

    const id = proximoId++;
    const usuario = { id, nome, email };
    usuarios.set(id, usuario);

    res.status(201).json(usuario);
});

app.listen(3000, () => {
    console.log('Servidor rodando na porta 3000');
});

Rust (Axum) — Mesma API, com Segurança de Tipos

use axum::{
    extract::{Path, State},
    http::StatusCode,
    routing::{get, post},
    Json, Router,
};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};

#[derive(Serialize, Clone)]
struct Usuario {
    id: u64,
    nome: String,
    email: String,
}

#[derive(Deserialize)]
struct CriarUsuario {
    nome: String,
    email: String,
}

#[derive(Clone)]
struct AppState {
    usuarios: Arc<Mutex<HashMap<u64, Usuario>>>,
    proximo_id: Arc<Mutex<u64>>,
}

async fn buscar_usuario(
    State(state): State<AppState>,
    Path(id): Path<u64>,  // Já é tipado — nunca NaN
) -> Result<Json<Usuario>, StatusCode> {
    let usuarios = state.usuarios.lock().unwrap();
    usuarios
        .get(&id)
        .cloned()
        .map(Json)
        .ok_or(StatusCode::NOT_FOUND)
}

async fn criar_usuario(
    State(state): State<AppState>,
    Json(payload): Json<CriarUsuario>,  // Validação automática via serde
) -> (StatusCode, Json<Usuario>) {
    let mut proximo_id = state.proximo_id.lock().unwrap();
    let id = *proximo_id;
    *proximo_id += 1;

    let usuario = Usuario {
        id,
        nome: payload.nome,
        email: payload.email,
    };

    state.usuarios.lock().unwrap().insert(id, usuario.clone());
    (StatusCode::CREATED, Json(usuario))
}

#[tokio::main]
async fn main() {
    let state = AppState {
        usuarios: Arc::new(Mutex::new(HashMap::new())),
        proximo_id: Arc::new(Mutex::new(1)),
    };

    let app = Router::new()
        .route("/usuarios/{id}", get(buscar_usuario))
        .route("/usuarios", post(criar_usuario))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    println!("Servidor rodando na porta 3000");
    axum::serve(listener, app).await.unwrap();
}
# Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Mapeamento de Conceitos: Node.js vs Rust

Tipos TypeScript vs Tipos Rust

TypeScript:

// TypeScript — tipos em tempo de desenvolvimento, removidos em runtime
interface Usuario {
    id: number;
    nome: string;
    email: string;
    idade?: number;  // Opcional
}

interface Resposta<T> {
    dados: T;
    sucesso: boolean;
    erro?: string;
}

function buscar(id: number): Resposta<Usuario> {
    // Em runtime, nada impede um campo estar errado
    return { dados: { id, nome: "Maria", email: "m@m.com" }, sucesso: true };
}

Rust:

use serde::{Deserialize, Serialize};

// Rust — tipos verificados em tempo de COMPILAÇÃO e em RUNTIME
#[derive(Debug, Serialize, Deserialize)]
struct Usuario {
    id: u64,
    nome: String,
    email: String,
    idade: Option<u8>,  // Opcional
}

#[derive(Serialize)]
struct Resposta<T: Serialize> {
    dados: T,
    sucesso: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    erro: Option<String>,
}

fn buscar(id: u64) -> Resposta<Usuario> {
    Resposta {
        dados: Usuario {
            id,
            nome: "Maria".to_string(),
            email: "m@m.com".to_string(),
            idade: None,
        },
        sucesso: true,
        erro: None,
    }
}

npm vs Cargo

npmCargoNotas
package.jsonCargo.tomlManifesto do projeto
package-lock.jsonCargo.lockLockfile de dependências
node_modules/~/.cargo/registry/Cache de dependências (global)
npm installcargo buildBaixa e compila dependências
npm testcargo testExecuta testes
npm run buildcargo build --releaseBuild de produção
npxcargo installExecutar binários
npm publishcargo publishPublicar pacote
npmjs.comcrates.ioRegistro de pacotes

Async/Await: Surpreendentemente Similar

Node.js:

// Node.js: async/await nativo com event loop
const fetch = require('node-fetch');

async function buscarDados(url) {
    try {
        const response = await fetch(url);
        const dados = await response.json();
        return dados;
    } catch (error) {
        console.error(`Erro ao buscar ${url}: ${error.message}`);
        throw error;
    }
}

async function buscarVarios(urls) {
    // Paralelo com Promise.all
    const resultados = await Promise.all(
        urls.map(url => buscarDados(url))
    );
    return resultados;
}

Rust:

use anyhow::{Context, Result};
use serde_json::Value;

async fn buscar_dados(url: &str) -> Result<Value> {
    let response = reqwest::get(url)
        .await
        .with_context(|| format!("Erro ao buscar {url}"))?;

    let dados: Value = response.json()
        .await
        .with_context(|| format!("Erro ao parsear resposta de {url}"))?;

    Ok(dados)
}

async fn buscar_varios(urls: &[&str]) -> Result<Vec<Value>> {
    // Paralelo com join_all (equivalente ao Promise.all)
    let futures: Vec<_> = urls.iter().map(|url| buscar_dados(url)).collect();
    let resultados = futures::future::try_join_all(futures).await?;
    Ok(resultados)
}
# Cargo.toml
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde_json = "1"
anyhow = "1"
futures = "0.3"

Middleware: Express vs Axum

Express (Node.js):

const express = require('express');
const app = express();

// Middleware de logging
app.use((req, res, next) => {
    const inicio = Date.now();
    res.on('finish', () => {
        const duracao = Date.now() - inicio;
        console.log(`${req.method} ${req.url} - ${res.statusCode} (${duracao}ms)`);
    });
    next();
});

// Middleware de autenticação
function autenticar(req, res, next) {
    const token = req.headers['authorization'];
    if (!token || token !== 'Bearer meu-token-secreto') {
        return res.status(401).json({ erro: 'Não autorizado' });
    }
    next();
}

// Rota protegida
app.get('/admin', autenticar, (req, res) => {
    res.json({ mensagem: 'Área administrativa' });
});

Axum (Rust):

use axum::{
    extract::Request,
    http::{header, StatusCode},
    middleware::{self, Next},
    response::Response,
    routing::get,
    Json, Router,
};
use serde_json::json;
use std::time::Instant;

async fn logging_middleware(request: Request, next: Next) -> Response {
    let metodo = request.method().clone();
    let uri = request.uri().clone();
    let inicio = Instant::now();

    let response = next.run(request).await;

    let duracao = inicio.elapsed();
    println!(
        "{} {} - {} ({:?})",
        metodo,
        uri,
        response.status(),
        duracao
    );

    response
}

async fn autenticacao_middleware(
    request: Request,
    next: Next,
) -> Result<Response, StatusCode> {
    let auth_header = request
        .headers()
        .get(header::AUTHORIZATION)
        .and_then(|v| v.to_str().ok());

    match auth_header {
        Some("Bearer meu-token-secreto") => Ok(next.run(request).await),
        _ => Err(StatusCode::UNAUTHORIZED),
    }
}

async fn area_admin() -> Json<serde_json::Value> {
    Json(json!({ "mensagem": "Área administrativa" }))
}

async fn index() -> &'static str {
    "Página pública"
}

#[tokio::main]
async fn main() {
    // Rotas protegidas (com autenticação)
    let rotas_admin = Router::new()
        .route("/admin", get(area_admin))
        .layer(middleware::from_fn(autenticacao_middleware));

    // App completa
    let app = Router::new()
        .route("/", get(index))
        .merge(rotas_admin)
        .layer(middleware::from_fn(logging_middleware));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();

    axum::serve(listener, app).await.unwrap();
}
# Cargo.toml
[dependencies]
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde_json = "1"

JSON Handling

Node.js:

// Node.js: JSON é nativo
const dados = JSON.parse('{"nome": "Maria", "idade": 30}');
console.log(dados.nome);          // Maria
console.log(dados.inexistente);   // undefined (sem erro!)

const json = JSON.stringify({ mensagem: "ok", codigo: 200 });

Rust:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Debug)]
struct Pessoa {
    nome: String,
    idade: u8,
}

#[derive(Serialize)]
struct Resposta {
    mensagem: String,
    codigo: u16,
}

fn main() -> Result<(), serde_json::Error> {
    // Parse com tipos — campos ausentes geram ERRO, não undefined
    let dados: Pessoa = serde_json::from_str(r#"{"nome": "Maria", "idade": 30}"#)?;
    println!("{}", dados.nome); // Maria

    // Para JSON dinâmico (como JS), use serde_json::Value
    let dinamico: serde_json::Value =
        serde_json::from_str(r#"{"nome": "Maria", "extra": true}"#)?;
    println!("{}", dinamico["nome"]);       // "Maria"
    println!("{}", dinamico["inexistente"]); // null (não panic)

    // Serialização
    let json = serde_json::to_string(&Resposta {
        mensagem: "ok".into(),
        codigo: 200,
    })?;
    println!("{json}");

    Ok(())
}
# Cargo.toml
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"

Guia Passo a Passo: Migrando uma API Express para Axum

Passo 1: Mapear Rotas

// Express original
app.get('/api/produtos', listarProdutos);
app.get('/api/produtos/:id', buscarProduto);
app.post('/api/produtos', criarProduto);
app.put('/api/produtos/:id', atualizarProduto);
app.delete('/api/produtos/:id', deletarProduto);
// Axum equivalente
let app = Router::new()
    .route("/api/produtos", get(listar_produtos).post(criar_produto))
    .route(
        "/api/produtos/{id}",
        get(buscar_produto)
            .put(atualizar_produto)
            .delete(deletar_produto),
    )
    .with_state(state);

Passo 2: Converter Handlers

Express:

async function criarProduto(req, res) {
    try {
        const { nome, preco, estoque } = req.body;

        if (!nome || !preco) {
            return res.status(400).json({ erro: 'Nome e preço são obrigatórios' });
        }

        const produto = await db.query(
            'INSERT INTO produtos (nome, preco, estoque) VALUES ($1, $2, $3) RETURNING *',
            [nome, preco, estoque || 0]
        );

        res.status(201).json(produto.rows[0]);
    } catch (error) {
        console.error(error);
        res.status(500).json({ erro: 'Erro interno' });
    }
}

Axum:

use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};

#[derive(Deserialize)]
struct CriarProduto {
    nome: String,
    preco: f64,
    #[serde(default)]
    estoque: i32,
}

#[derive(Serialize)]
struct Produto {
    id: i64,
    nome: String,
    preco: f64,
    estoque: i32,
}

async fn criar_produto(
    State(pool): State<sqlx::PgPool>,
    Json(payload): Json<CriarProduto>,
) -> Result<(StatusCode, Json<Produto>), (StatusCode, Json<serde_json::Value>)> {
    // Validação via tipos — nome e preco são obrigatórios pelo tipo
    // Se o JSON não tiver esses campos, retorna 422 automaticamente

    let produto = sqlx::query_as!(
        Produto,
        r#"INSERT INTO produtos (nome, preco, estoque)
           VALUES ($1, $2, $3) RETURNING id, nome, preco, estoque"#,
        payload.nome,
        payload.preco,
        payload.estoque
    )
    .fetch_one(&pool)
    .await
    .map_err(|e| {
        eprintln!("Erro no banco: {e}");
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            Json(serde_json::json!({ "erro": "Erro interno" })),
        )
    })?;

    Ok((StatusCode::CREATED, Json(produto)))
}

Passo 3: Variáveis de Ambiente (dotenv)

Node.js:

require('dotenv').config();
const port = process.env.PORT || 3000;
const dbUrl = process.env.DATABASE_URL;

Rust:

fn main() {
    dotenvy::dotenv().ok(); // Carrega .env se existir

    let port: u16 = std::env::var("PORT")
        .unwrap_or_else(|_| "3000".to_string())
        .parse()
        .expect("PORT deve ser um número");

    let db_url = std::env::var("DATABASE_URL")
        .expect("DATABASE_URL é obrigatório");

    println!("Porta: {port}, DB: {db_url}");
}
# Cargo.toml
[dependencies]
dotenvy = "0.15"

Armadilhas Comuns para Desenvolvedores Node.js

1. Tudo É Single-Threaded no Node — Não em Rust

use std::sync::{Arc, Mutex};

// Em Rust, estado compartilhado entre threads precisa de sincronização
#[derive(Clone)]
struct AppState {
    // Arc = referência contada (como shared_ptr do C++)
    // Mutex = exclusão mútua (lock/unlock)
    contador: Arc<Mutex<u64>>,
}

async fn incrementar(State(state): State<AppState>) -> String {
    let mut lock = state.contador.lock().unwrap();
    *lock += 1;
    format!("Contador: {}", *lock)
}

use axum::{extract::State, routing::get, Router};

#[tokio::main]
async fn main() {
    let state = AppState {
        contador: Arc::new(Mutex::new(0)),
    };

    let app = Router::new()
        .route("/incrementar", get(incrementar))
        .with_state(state);

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
        .await
        .unwrap();
    axum::serve(listener, app).await.unwrap();
}

2. Sem Null/Undefined — Use Option<T>

// Node.js: undefined e null são silenciosos
const user = {};
console.log(user.endereco.rua); // TypeError em RUNTIME
#[derive(Debug)]
struct Endereco {
    rua: String,
}

#[derive(Debug)]
struct User {
    endereco: Option<Endereco>,
}

fn main() {
    let user = User { endereco: None };

    // Isso NÃO COMPILA — força você a lidar com a ausência
    // println!("{}", user.endereco.rua);

    // CORRETO: Trate a possibilidade de None
    match &user.endereco {
        Some(end) => println!("Rua: {}", end.rua),
        None => println!("Endereço não informado"),
    }

    // Ou mais conciso:
    let rua = user.endereco
        .as_ref()
        .map(|e| e.rua.as_str())
        .unwrap_or("N/A");
    println!("Rua: {rua}");
}

3. Package.json Scripts vs Cargo Aliases

Crie um Makefile ou use cargo-make para scripts customizados:

# .cargo/config.toml
[alias]
dev = "run"
prod = "run --release"
lint = "clippy --all-targets"
fmt = "fmt --all"
cargo dev     # = cargo run
cargo prod    # = cargo run --release
cargo lint    # = cargo clippy --all-targets

4. Callbacks → Async/Await (Já Resolvido!)

Boa notícia: se você já usa async/await no Node.js, a transição é natural:

// Node.js
const resultado = await funcaoAssincrona();
// Rust — praticamente idêntico
let resultado = funcao_assincrona().await;

A diferença principal: em Rust, .await vem depois da chamada.

Comparativo de Performance

MétricaNode.js (Express)Rust (Axum)
Requisições/seg (JSON)~30.000~300.000
Latência p99~15ms~1ms
Memória (idle)~50MB~2MB
Memória (sob carga)~200MB~15MB
Startup time~500ms<1ms
Tamanho do binário~70MB (node)~5MB

Estratégia de Migração Recomendada

  1. Comece com um novo serviço — Não reescreva tudo de uma vez
  2. Escolha um microserviço — Ideal para serviços de alta carga
  3. Mantenha a mesma API — Clientes não percebem a mudança
  4. Use Axum — Mais similar ao Express em filosofia
  5. Migre middleware por middleware — Autenticação, logging, CORS
  6. Integre com o existente — REST/gRPC entre serviços Node e Rust

Checklist de Migração

  1. Mapeie rotas — Express routes para Axum Router
  2. Converta handlers — req/res para extractors/responses tipados
  3. Defina tipos — TypeScript interfaces para Rust structs
  4. Middleware — Express middleware para Axum layers
  5. Variáveis de ambiente — dotenv para dotenvy
  6. Testes — Jest/Mocha para cargo test
  7. Docker — Adapte Dockerfile para build Rust
  8. CI/CD — Adapte GitHub Actions
  9. Monitore — Tracing substitui console.log
  10. Benchmark — Confirme o ganho de performance

Veja Também