Skip to content

🎯 Meta: conocer el lenguaje que ofrece máximo rendimiento y máxima seguridad de memoria sin recolector de basura. Rust es exigente al aprender pero, una vez que compila, tu programa es rapidísimo y muy difícil de romper. Es el lenguaje de sistemas más querido de la última década.

Versiones: Rust (edición 2024, canal estable) · Axum 0.8 · Tokio · SQLx.


I.1 · ¿Por qué Rust? (y por qué es duro)

  • Rápido como C/C++, pero seguro en memoria sin recolector de basura (GC): no hay pausas de GC, uso de RAM mínimo y predecible.
  • Sin null, sin excepciones no controladas, sin data races. El compilador los impide en tiempo de compilación. Si compila, muchísimas clases de bugs simplemente no pueden ocurrir.
  • El precio: la curva de aprendizaje. El borrow checker (verás) te pelea al principio.

🧠 La filosofía de Rust: "si compila, funciona". El compilador es estricto y a veces frustrante, pero atrapa en tu escritorio errores que en otros lenguajes explotarían en producción (null pointers, uso tras liberar memoria, condiciones de carrera). Cambia horas de debugging en producción por minutos peleando con el compilador. Para servicios críticos de alto rendimiento, es un trato excelente.

🔗 Rust se compara con Go (cap. 08): ambos compilan a binarios rápidos. Go prioriza simplicidad (se aprende en días); Rust prioriza control y seguridad máximos (se aprende en semanas). Elige Go para la mayoría de servicios; Rust cuando el rendimiento o la seguridad de memoria son críticos.


I.2 · Instalación

bash
# Instala Rust con rustup
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version        # compilador
cargo --version        # cargo: gestor de paquetes + build + test, todo en uno

# Nuevo proyecto
cargo new tienda-api
cd tienda-api
cargo run              # compila y ejecuta

cargo es como npm + build + test + fmt juntos. Añades dependencias con cargo add.


I.3 · Fundamentos en 5 minutos

rust
// Variables: inmutables por defecto (¡seguridad!). "mut" para mutable.
let nombre = "Ana";              // inmutable
let mut edad = 29;               // mutable
edad += 1;

// Funciones: tipos explícitos, la última expresión (sin ;) es el retorno
fn sumar(a: i32, b: i32) -> i32 {
    a + b                        // sin "return" ni ";"
}

// Structs
struct Producto {
    id: i64,
    nombre: String,
    precio: f64,
}

// El sistema de errores: Result<T, E> (no hay excepciones)
fn dividir(a: f64, b: f64) -> Result<f64, String> {
    if b == 0.0 {
        return Err("división por cero".to_string());
    }
    Ok(a / b)
}

// Option<T> en vez de null (imposible tener un "null pointer")
fn buscar(id: i64) -> Option<Producto> {
    // Some(producto) si existe, None si no
    None
}

🧠 Result y Option: la muerte del null y las excepciones. En Rust, una función que puede fallar devuelve Result<Ok, Err>; una que puede no tener valor devuelve Option<Some, None>. El compilador te obliga a manejar ambos casos. No existe "olvidé comprobar null" ni "excepción no capturada". El operador ? propaga errores de forma concisa:

rust
fn procesar() -> Result<f64, String> {
    let x = dividir(10.0, 2.0)?;   // si es Err, retorna el error aquí; si es Ok, saca el valor
    Ok(x * 2.0)
}

I.4 · El borrow checker (lo que hace único a Rust)

Rust gestiona la memoria con reglas de propiedad (ownership) que el compilador verifica, sin GC:

rust
let s1 = String::from("hola");
let s2 = s1;              // s1 se "mueve" a s2; s1 ya NO es válido
// println!("{}", s1);   // ❌ Error: s1 fue movido

let s3 = String::from("hola");
let s4 = &s3;            // s4 "toma prestado" (borrow) s3; s3 sigue válido
println!("{} {}", s3, s4);   // ✅

Las reglas (simplificadas): cada valor tiene un dueño; puedes tener muchas referencias de solo lectura o una de escritura, nunca ambas a la vez.

🧠 Por qué esto importa: esas reglas eliminan, en compilación, las condiciones de carrera y el acceso a memoria liberada — bugs que en C causan la mayoría de vulnerabilidades de seguridad graves. Al principio el borrow checker te frustra ("¿por qué no me deja?"); con el tiempo, interiorizas sus reglas y escribes código concurrente seguro sin pensarlo. Es la característica que define a Rust.


I.5 · API REST con Axum

Axum (del equipo de Tokio) es el framework web más popular de Rust en 2026: ergonómico, async y sobre el ecosistema Tower. Añade dependencias:

bash
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json
rust
// src/main.rs
use axum::{routing::{get, post}, Router, Json, extract::Path, http::StatusCode};
use serde::{Deserialize, Serialize};

#[derive(Serialize)]
struct Producto {
    id: i64,
    nombre: String,
    precio: f64,
}

#[derive(Deserialize)]
struct CrearProducto {
    nombre: String,
    precio: f64,
}

#[tokio::main]
async fn main() {
    let app = Router::new()
        .route("/productos", get(listar).post(crear))
        .route("/productos/{id}", get(ver));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:8000").await.unwrap();
    println!("Escuchando en :8000");
    axum::serve(listener, app).await.unwrap();
}

// GET /productos
async fn listar() -> Json<Vec<Producto>> {
    Json(vec![Producto { id: 1, nombre: "Mouse".into(), precio: 49.9 }])
}

// GET /productos/{id}
async fn ver(Path(id): Path<i64>) -> Result<Json<Producto>, StatusCode> {
    if id == 1 {
        Ok(Json(Producto { id: 1, nombre: "Mouse".into(), precio: 49.9 }))
    } else {
        Err(StatusCode::NOT_FOUND)      // 404 tipado
    }
}

// POST /productos
async fn crear(Json(datos): Json<CrearProducto>) -> (StatusCode, Json<Producto>) {
    let producto = Producto { id: 2, nombre: datos.nombre, precio: datos.precio };
    (StatusCode::CREATED, Json(producto))   // 201
}

🧠 Extractores de Axum: Path(id), Json(datos), Query(...), State(...) "extraen" partes de la petición y las convierten al tipo que pides, validando por el camino. Si el JSON no encaja con CrearProducto, Axum responde error solo. Es el mismo concepto de inyección/validación de FastAPI y Elysia, con la garantía de tipos de Rust.


I.6 · Base de datos con SQLx (SQL verificado en compilación)

SQLx es asombroso: verifica tus consultas SQL contra la BD real en tiempo de compilación. Si escribes mal una columna, no compila.

bash
cargo add sqlx --features runtime-tokio,postgres,macros
rust
use sqlx::postgres::PgPoolOptions;

#[derive(sqlx::FromRow, Serialize)]
struct Producto {
    id: i64,
    nombre: String,
    precio: f64,
}

async fn crear(pool: &sqlx::PgPool, datos: CrearProducto) -> Result<Producto, sqlx::Error> {
    // query_as! verifica ESTE SQL contra tu BD al COMPILAR
    let producto = sqlx::query_as!(
        Producto,
        "INSERT INTO productos (nombre, precio) VALUES ($1, $2)
         RETURNING id, nombre, precio",
        datos.nombre, datos.precio
    )
    .fetch_one(pool)
    .await?;
    Ok(producto)
}

💡 query_as! es magia útil: durante cargo build, SQLx se conecta a tu BD, valida que la consulta es correcta y que los tipos coinciden con tu struct. Un typo en una columna o un tipo equivocado = error de compilación, no un fallo en producción. Ningún otro stack del libro atrapa errores de SQL tan pronto.


I.7 · Tokio, estado compartido y manejo de errores idiomático

Tokio es el runtime async que hace posible todo lo anterior — Axum no tiene runtime propio, corre encima de Tokio. #[tokio::main] arranca ese runtime; cada request se maneja en una tarea (task) ligera, no un hilo del sistema operativo:

rust
// Lanzar trabajo en paralelo dentro de un handler — tareas, no hilos (mucho más baratas)
async fn resumen_pedido(Path(id): Path<i64>) -> Json<ResumenPedido> {
    let (productos, envio) = tokio::join!(
        obtener_productos(id),      // ambas llamadas EN PARALELO, no una tras otra
        calcular_envio(id),
    );
    Json(ResumenPedido { productos, envio })
}

🧠 tokio::join! es el Promise.all de Rust. Sin él, dos .await seguidos se ejecutan en serie (espera al primero, luego al segundo). join! los lanza a la vez y espera a ambos — igual de importante que en JS/Python async (caps. 05-07), y igual de fácil de olvidar.

Estado compartido entre requests (un pool de conexiones, config, un caché) viaja con el extractor State, envuelto en Arc (puntero compartido de solo lectura entre tareas):

rust
#[derive(Clone)]
struct AppState {
    db: sqlx::PgPool,           // el pool YA es internamente compartible (clonar es barato)
}

#[tokio::main]
async fn main() {
    let pool = PgPoolOptions::new().max_connections(10).connect(&db_url).await.unwrap();
    let estado = AppState { db: pool };

    let app = Router::new()
        .route("/productos", get(listar))
        .with_state(estado);        // disponible en cualquier handler vía State<AppState>
}

async fn listar(State(estado): State<AppState>) -> Json<Vec<Producto>> {
    let productos = sqlx::query_as!(Producto, "SELECT id, nombre, precio FROM productos")
        .fetch_all(&estado.db)
        .await
        .unwrap();               // en producción: mapea a un error tipado, ver abajo
    Json(productos)
}

Errores propios que se convierten en respuestas HTTP — el patrón idiomático en vez de unwrap() (que hace panic! y tumba la tarea):

rust
enum ApiError {
    NoEncontrado,
    BaseDeDatos(sqlx::Error),
}

impl axum::response::IntoResponse for ApiError {
    fn into_response(self) -> axum::response::Response {
        let (status, mensaje) = match self {
            ApiError::NoEncontrado => (StatusCode::NOT_FOUND, "no existe".to_string()),
            ApiError::BaseDeDatos(e) => {
                tracing::error!(error = %e, "fallo de base de datos");   // se loguea, no se expone
                (StatusCode::INTERNAL_SERVER_ERROR, "error interno".to_string())
            }
        };
        (status, Json(serde_json::json!({ "error": mensaje }))).into_response()
    }
}

// El operador ? ahora propaga ApiError directamente hasta la respuesta HTTP
async fn ver(Path(id): Path<i64>, State(estado): State<AppState>) -> Result<Json<Producto>, ApiError> {
    let producto = sqlx::query_as!(Producto, "SELECT * FROM productos WHERE id = $1", id)
        .fetch_optional(&estado.db)
        .await
        .map_err(ApiError::BaseDeDatos)?
        .ok_or(ApiError::NoEncontrado)?;
    Ok(Json(producto))
}

🧠 IntoResponse es el catch centralizado de Rust. En vez de un middleware de errores global (como en Express/NestJS), cada tipo de error sabe convertirse a sí mismo en la respuesta HTTP correcta. El compilador te obliga a manejar todos los casos de tu enum — no hay forma de "olvidar" un tipo de error, a diferencia de un catch genérico que puede tragarse cosas.

Middleware con Tower (logging, CORS, timeouts) se compone como capas (layers), igual que los middlewares de Express o los interceptors de NestJS:

rust
use tower_http::{trace::TraceLayer, cors::CorsLayer, timeout::TimeoutLayer};
use std::time::Duration;

let app = Router::new()
    .route("/productos", get(listar))
    .with_state(estado)
    .layer(TraceLayer::new_for_http())              // logging estructurado de cada request
    .layer(CorsLayer::permissive())                 // cap. 15.5 — ajusta en producción
    .layer(TimeoutLayer::new(Duration::from_secs(10)));  // corta requests colgadas
bash
cargo add tower-http --features trace,cors,timeout
cargo add tracing tracing-subscriber

⚠️ El orden de las capas importa — se aplican de fuera hacia dentro en la petición, y al revés en la respuesta. TraceLayer normalmente va más "afuera" (primero) para medir el tiempo total real, incluidos otros middlewares.

Apagado ordenado (graceful shutdown) — igual que en Node/Nest (cap. 14): terminar las peticiones en curso antes de morir, no cortarlas a mitad:

rust
axum::serve(listener, app)
    .with_graceful_shutdown(esperar_ctrl_c())
    .await
    .unwrap();

async fn esperar_ctrl_c() {
    tokio::signal::ctrl_c().await.expect("no se pudo escuchar Ctrl+C");
    tracing::info!("señal de apagado recibida, drenando conexiones...");
}

I.8 · Tests (integrados en cargo)

rust
// Al final del archivo, o en tests/
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn dividir_normal() {
        assert_eq!(dividir(10.0, 2.0), Ok(5.0));
    }

    #[test]
    fn dividir_por_cero_falla() {
        assert!(dividir(10.0, 0.0).is_err());
    }

    #[tokio::test]                  // test async
    async fn crea_producto() {
        let datos = CrearProducto { nombre: "Test".into(), precio: 10.0 };
        // ...con una BD de test...
    }
}
bash
cargo test              # ejecuta todos los tests
cargo clippy            # linter (excelente, te enseña Rust idiomático)
cargo fmt               # formato automático

💡 cargo clippy es tu profesor de Rust. No solo detecta errores: te sugiere formas más idiomáticas de escribir. Ejecútalo siempre; aprenderás muchísimo de sus consejos.


I.9 · Despliegue (binario minúsculo)

Como Go (cap. 08), Rust compila a un binario sin dependencias de runtime. En Docker (multi-stage, cap. 12) obtienes imágenes de pocos MB:

dockerfile
FROM rust:1-slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release           # binario optimizado

FROM debian:stable-slim
COPY --from=builder /app/target/release/tienda-api /usr/local/bin/
CMD ["tienda-api"]

Consumo de RAM en producción: típicamente una fracción del de Node/Python/Java. Ideal para servicios donde cada MB y cada milisegundo cuentan.


I.10 · Cuándo usar Rust (y cuándo no)

Úsalo para: servicios de altísimo rendimiento, procesamiento intensivo, sistemas donde la seguridad de memoria es crítica, componentes de infraestructura, cuando el coste de RAM/CPU a escala importa mucho.

Piénsatelo si: necesitas prototipar rápido, el equipo no conoce Rust, o el proyecto es un CRUD normal sin exigencias de rendimiento (Laravel/FastAPI/Nest te darán más velocidad de desarrollo).

🧠 Rust no es "mejor" que los otros stacks del libro; es diferente. Cambia velocidad de desarrollo por rendimiento y garantías. Muchas empresas lo usan para el 5% crítico de su sistema (el motor de pagos, el procesador de eventos) y mantienen el resto en algo más ágil. Conocerlo te abre ese tipo de trabajo, muy demandado y bien pagado.


✅ Ejercicio del apéndice

1. Instala Rust y crea la API del blog básica con Axum (listar, ver, crear artículos).
2. Usa Result/Option correctamente: buscar un artículo devuelve Option; crear
   devuelve Result con validación (título no vacío → Err).
3. Conecta PostgreSQL con SQLx y usa query_as! (rompe una columna a propósito y
   observa que NO compila).
4. Crea un enum ApiError con al menos dos variantes (NoEncontrado, BaseDeDatos) e
   impleméntale IntoResponse. Usa el operador ? en tus handlers para propagarlo.
5. Comparte el pool de conexiones con State<AppState> y añade TraceLayer +
   CorsLayer + TimeoutLayer con Tower.
6. Añade apagado ordenado con with_graceful_shutdown y verifica que una petición
   en curso termina antes de que el proceso muera al pulsar Ctrl+C.
7. Escribe tests con cargo test para la validación.
8. Pasa cargo clippy y aplica sus sugerencias.
9. Dockeriza con multi-stage y compara el tamaño de la imagen y el uso de RAM
   con tu versión en Node/Python.
💡 Pistas de la solución
  • Si el borrow checker te bloquea en el punto 2, la solución casi siempre es clonar (.clone()) mientras aprendes — es menos eficiente pero compila, y te deja avanzar. Vuelve después a optimizar con referencias cuando entiendas mejor el ownership.
  • Para el punto 3, sqlx::query_as! necesita una base de datos accesible en tiempo de compilación (variable DATABASE_URL o el modo offline con cargo sqlx prepare) — si el build falla con un error de conexión, es justo esa validación en acción, no un bug tuyo.
  • El error de columna a propósito debe fallar en cargo build, con un mensaje claro sobre qué columna no existe — si en cambio falla en runtime, revisa que estás usando query_as! (con !) y no query_as (sin verificación en compilación).
  • Para comparar RAM: docker stats con ambos contenedores corriendo bajo la misma carga (ab o hey) te da una comparación justa.

🧠 Autoevaluación

El compilador verifica las reglas de ownership (I.4) en tiempo de compilación: cada valor tiene un dueño, y solo puede haber múltiples referencias de lectura o una de escritura, nunca ambas. Esas reglas eliminan la necesidad de un GC en tiempo de ejecución porque el compilador ya garantizó que no habrá acceso inválido a memoria.

Result<T, E>. Rust no tiene excepciones no controladas: el tipo de retorno declara explícitamente que la función puede fallar, y el compilador obliga a manejar el caso Err antes de poder usar el valor — no hay forma de "olvidar" comprobar un error como en lenguajes con excepciones silenciosas.

Verifica la consulta SQL contra la base de datos real durante cargo build — si una columna no existe o el tipo no coincide con tu struct, el programa no compila. Ningún ORM/query builder de los demás capítulos atrapa errores de SQL antes de ejecutar el código.

No, salvo que haya una razón de peso adicional (seguridad de memoria crítica, por ejemplo). La curva de aprendizaje de Rust se paga en velocidad de desarrollo; para un CRUD normal, un stack como Laravel o FastAPI (caps. 03-05) entrega valor más rápido sin sacrificar calidad.

unwrap() provoca un panic! que tumba la tarea (y, sin cuidado extra, puede tumbar el proceso) ante cualquier error, sin control sobre qué código HTTP o mensaje recibe el cliente. Con IntoResponse, el propio tipo de error decide su código de estado y su mensaje, el compilador obliga a manejar cada variante del enum, y el operador ? propaga el error de forma concisa hasta esa conversión — nunca hay un error "no manejado" en tiempo de compilación.

Dos .await consecutivos ejecutan una tarea, esperan a que termine, y solo entonces empiezan la siguiente — en serie. tokio::join! lanza ambas tareas a la vez y espera a que las dos terminen, aprovechando que mientras una espera una respuesta de red o de base de datos, el runtime puede avanzar la otra. Es el mismo principio que Promise.all en JavaScript o asyncio.gather en Python (caps. 05-07).


Volver al: README.md · Relacionado: 08-go.md, 12-docker.md