🎯 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
# 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 ejecutacargo es como npm + build + test + fmt juntos. Añades dependencias con cargo add.
I.3 · Fundamentos en 5 minutos
// 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
}🧠
ResultyOption: la muerte del null y las excepciones. En Rust, una función que puede fallar devuelveResult<Ok, Err>; una que puede no tener valor devuelveOption<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:
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:
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:
cargo add axum
cargo add tokio --features full
cargo add serde --features derive
cargo add serde_json// 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 conCrearProducto, 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.
cargo add sqlx --features runtime-tokio,postgres,macrosuse 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: durantecargo 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:
// 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 elPromise.allde Rust. Sin él, dos.awaitseguidos 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):
#[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):
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))
}🧠
IntoResponsees elcatchcentralizado 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 uncatchgené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:
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 colgadascargo 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.
TraceLayernormalmente 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:
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)
// 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...
}
}cargo test # ejecuta todos los tests
cargo clippy # linter (excelente, te enseña Rust idiomático)
cargo fmt # formato automático💡
cargo clippyes 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:
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 (variableDATABASE_URLo el modo offline concargo 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 usandoquery_as!(con!) y noquery_as(sin verificación en compilación). - Para comparar RAM:
docker statscon ambos contenedores corriendo bajo la misma carga (abohey) 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