Preparación para entrevista de Rust

Preguntas de entrevista para desarrolladores backend Rust

15 preguntas seleccionadas para entrevistas backend de Rust, agrupadas por nivel de experiencia. Úsalas para repasar fundamentos, compromisos prácticos y el razonamiento de producción a nivel senior.

Iniciar una entrevista de IA de RustNo se requiere tarjeta de crédito. 1 sesión gratuita disponible.
Práctica de entrevistas técnicas en inglésDiseñado para hablantes no nativos que desean practicar entrevistas técnicas en inglés.

Preguntas para Junior

1Explique las reglas de propiedad de Rust y cómo influyen en las decisiones prácticas de backend, como la clonación, el préstamo y la compartición del estado de la aplicación.

Rust asigna a cada valor un único propietario. Mover un valor que no es `Copy` transfiere la propiedad, y el valor es descartado cuando su propietario sale del ámbito. El código puede tomar prestados datos de forma inmutable a través de referencias compartidas o de forma mutable a través de una referencia mutable exclusiva. En el código de backend, esto determina si una función toma la propiedad, toma prestado temporalmente, clona para obtener un valor propio independiente, o comparte estado de larga duración a través de manejadores como `Arc`, con sincronización u otras primitivas de concurrencia cuando se necesita una mutación compartida.

use std::sync::Arc;

struct AppState {
    service_name: String,
}

async fn handler(state: Arc<AppState>, request_id: String) -> String {
    // Borrow: no ownership transfer of the String inside state.
    let name: &str = &state.service_name;

    // Clone only if an owned independent value is needed.
    let owned_name = name.to_owned();

    format!("{owned_name}:{request_id}")
}

fn configure(state: Arc<AppState>) {
    // Cloning Arc increments the reference count; it does not clone AppState.
    let state_for_route = Arc::clone(&state);
    let _ = state_for_route;
}
Probar responder esta pregunta con un coach de IA

2¿Cuál es la diferencia entre el *borrowing* (préstamo) con `&T` y `&mut T`, y cómo afectan estas reglas a las firmas de las API (Application Programming Interface) y los *handlers* (manejadores)?

`&T` es una referencia compartida: permite acceso de solo lectura y muchas referencias compartidas al mismo valor pueden estar activas a la vez. `&mut T` es una referencia mutable exclusiva: permite la mutación, pero mientras está activa, ninguna otra referencia compartida o mutable al mismo valor puede usarse. Estas reglas dan forma a las API al hacer que las funciones de solo lectura acepten `&T` o tipos prestados más específicos como `&str`, mientras que las funciones de mutación aceptan `&mut T`, toman la propiedad o usan mutabilidad interior/sincronización. Los manejadores de *backend* suelen evitar el acceso `&mut` directo al estado compartido de la aplicación entre solicitudes concurrentes y, en su lugar, utilizan manejadores compartidos más sincronización u otros patrones de propiedad.

struct User {
    name: String,
    login_count: u64,
}

fn display_name(user: &User) -> &str {
    &user.name
}

fn record_login(user: &mut User) {
    user.login_count += 1;
}

fn main() {
    let mut user = User { name: "Ada".into(), login_count: 0 };

    let name = display_name(&user); // shared borrow for reading
    println!("{name}");

    record_login(&mut user); // exclusive mutable borrow for mutation
}
Probar responder esta pregunta con un coach de IA

3¿Cómo funciona `Box` y qué problemas resuelve la asignación en el *heap* (montón) a través de `Box`?

`Box<T>` es un puntero inteligente con posesión (`owning smart pointer`) cuyo `T` se almacena en el *heap* (montón), mientras que el manejador `Box` es un valor de tamaño fijo similar a un puntero. Tiene una única posesión y libera la asignación del *heap* cuando sale de ámbito. `Box` proporciona indirección, lo que ayuda con valores grandes, tipos recursivos que necesitan un tamaño conocido, valores de tamaño dinámico y *trait objects* como `Box<dyn Trait>`. Mover un `Box` transfiere la posesión de la asignación sin mover ni copiar el valor almacenado en el *heap* en sí.

enum List {
    Cons(i32, Box<List>),
    Nil,
}

let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
Probar responder esta pregunta con un coach de IA

4Describe `Result` y `Option` y las principales formas idiomáticas de trabajar con la ausencia de valores y las operaciones falibles.

`Option<T>` representa `Some(T)` o `None` y se utiliza para la ausencia esperada de un valor. `Result<T, E>` representa `Ok(T)` o `Err(E)` y se utiliza para operaciones falibles donde la información del error es relevante. El manejo idiomático incluye `match`, `if let`/`let else`, combinadores como `map`, `and_then`, `ok_or`, `unwrap_or` y `unwrap_or_else`, y el operador `?` para la propagación temprana desde funciones que devuelven tipos `Option` o `Result` compatibles. `unwrap` y `expect` se reservan mejor para invariantes, pruebas, prototipos o situaciones irrecuperables, no para errores recuperables normales del backend.

fn parse_port(s: Option<&str>) -> u16 {
    s.and_then(|v| v.parse::<u16>().ok())
     .unwrap_or(8080)
}

assert_eq!(parse_port(Some("3000")), 3000);
assert_eq!(parse_port(None), 8080);
Probar responder esta pregunta con un coach de IA

5¿Cómo funciona el operador `?` y qué permite la conversión entre diferentes tipos de error?

El operador `?` es una abreviatura para propagar fallos. Para `Result`, `expr?` devuelve el valor `Ok` y continúa, o retorna anticipadamente de la función actual con un `Err`; para `Option`, devuelve el valor `Some` o retorna `None`. La función contenedora debe devolver un tipo compatible. Para `Result`, los diferentes tipos de error pueden componerse porque el error de origen se convierte al tipo de error de la función usando una conversión de estilo `From`/`Into`, a menudo implementada manualmente o derivada con crates de ayuda como `thiserror`.

use std::{fs, io, num::ParseIntError};

#[derive(Debug)]
enum AppError {
    Io(io::Error),
    Parse(ParseIntError),
}

impl From<io::Error> for AppError {
    fn from(e: io::Error) -> Self { AppError::Io(e) }
}
impl From<ParseIntError> for AppError {
    fn from(e: ParseIntError) -> Self { AppError::Parse(e) }
}

fn read_number(path: &str) -> Result<i32, AppError> {
    let s = fs::read_to_string(path)?; // io::Error -> AppError
    let n = s.trim().parse::<i32>()?;  // ParseIntError -> AppError
    Ok(n)
}
Probar responder esta pregunta con un coach de IA

6Explica la coincidencia de patrones en enumeraciones y cómo la coincidencia exhaustiva mejora la corrección del dominio y de la API.

Las enumeraciones (enums) modelan un valor que puede ser una de un conjunto fijo de variantes, y las variantes pueden contener datos. La expresión `match` ramifica por variante y puede desestructurar los datos internos. Rust verifica que las coincidencias de enumeraciones sean exhaustivas, a menos que se use un patrón comodín o de captura total (catch-all pattern). Esto mejora la corrección del dominio y de la API (Application Programming Interface) porque cuando se añade una nueva variante de estado o respuesta, el compilador puede obligar al código que coincide con ella a decidir cómo manejar el nuevo caso en lugar de ignorarlo silenciosamente.

enum PaymentStatus {
    Pending,
    Paid { receipt_id: String },
    Failed(String),
}

fn message(status: PaymentStatus) -> String {
    match status {
        PaymentStatus::Pending => "Payment is pending".to_string(),
        PaymentStatus::Paid { receipt_id } => format!("Paid, receipt {receipt_id}"),
        PaymentStatus::Failed(reason) => format!("Payment failed: {reason}"),
    }
}
Probar responder esta pregunta con un coach de IA

7¿Qué son las envolturas newtype y cómo mejoran la seguridad de tipos para identificadores, dinero, tokens y secretos?

Una envoltura newtype es un tipo Rust distinto, a menudo una estructura de tupla de un campo, alrededor de una representación existente, como `struct UserId(Uuid);` o `struct AccessToken(String);`. Mejora la seguridad de tipos porque el compilador no confundirá valores semánticamente diferentes que comparten el mismo tipo subyacente, como `UserId` frente a `OrderId`, céntimos frente a dólares, o cadenas de texto sin procesar frente a tokens validados. Los newtypes también pueden centralizar las reglas de construcción y validación, y controlar traits como `Display`, `Debug`, `Serialize`, o el comportamiento de redacción para secretos.

use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct UserId(Uuid);

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
struct OrderId(Uuid);

struct SecretToken(String);

impl std::fmt::Debug for SecretToken {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("SecretToken(**redacted**)")
    }
}

fn load_user(id: UserId) {
    // query by user id
}

fn example(user_id: UserId, order_id: OrderId) {
    load_user(user_id);
    // load_user(order_id); // does not compile: expected UserId, found OrderId
}
Probar responder esta pregunta con un coach de IA

Preguntas para Middle

8¿Cómo funciona el `trait Drop`, incluyendo el orden de eliminación, la limpieza `RAII` (Resource Acquisition Is Initialization) y las limitaciones en torno a la limpieza asíncrona?

`Drop` es el gancho de limpieza determinista de Rust. Cuando un valor poseído es destruido, como cuando sale de ámbito, Rust llama automáticamente a su método `drop(&mut self)` si implementa `Drop`, y luego elimina sus campos. Esto soporta `RAII`: recursos como archivos, sockets, bloqueos, transacciones, permisos o búferes están vinculados a valores/guardias poseedores y se liberan cuando esos valores son eliminados. Las variables locales se eliminan en orden inverso a su creación; para una estructura con `Drop`, el método `drop` de la estructura se ejecuta antes de que sus campos sean eliminados, y los campos se eliminan en el orden de declaración. `Drop::drop` es síncrono y no puede ser `async` ni `awaited`, por lo que una limpieza asíncrona elegante generalmente requiere un método asíncrono explícito de cierre/apagado/vaciamiento o otro diseño; `Drop` solo puede realizar una limpieza síncrona o de mejor esfuerzo.

struct Guard(&'static str);

impl Drop for Guard {
    fn drop(&mut self) {
        println!("drop {}", self.0);
    }
}

struct Pair {
    first: Guard,
    second: Guard,
}

impl Drop for Pair {
    fn drop(&mut self) {
        println!("drop Pair");
    }
}

fn main() {
    let _a = Guard("a");
    let _pair = Pair {
        first: Guard("first"),
        second: Guard("second"),
    };
    let _b = Guard("b");
}
Probar responder esta pregunta con un coach de IA

9Explica los tiempos de vida y por qué las anotaciones explícitas de tiempo de vida a veces son necesarias en las APIs de Rust.

Un tiempo de vida describe cuánto tiempo es válida una referencia, permitiendo que el compilador rechace referencias colgantes. Las anotaciones de tiempo de vida son restricciones en tiempo de compilación que expresan relaciones entre referencias; no extienden el tiempo de vida de los datos subyacentes ni añaden comportamiento en tiempo de ejecución. Muchas firmas simples son manejadas por la elisión de tiempo de vida, pero se necesitan anotaciones explícitas cuando el compilador no puede inferir cómo se relacionan las referencias de entrada y salida, cuando los tipos almacenan referencias, o cuando una API necesita expresar restricciones genéricas de préstamo.

fn longest<'a>(left: &'a str, right: &'a str) -> &'a str {
    if left.len() >= right.len() { left } else { right }
}

struct UserView<'a> {
    name: &'a str,
}

fn main() {
    let a = String::from("short");
    let b = String::from("longer");
    let result = longest(&a, &b);
    let view = UserView { name: result };
    println!("{}", view.name);
}
Probar responder esta pregunta con un coach de IA

10¿Qué significa la vida útil `'static` y cómo interactúa con las tareas generadas y el diseño de la API backend?

La ligadura `'static` en una referencia significa que los datos referenciados son válidos durante la totalidad del programa, como ocurre con los literales de cadena o los elementos `static`. Una ligadura como `T: 'static` significa que los valores de tipo `T` no contienen referencias prestadas no-`'static`, por lo que pueden ser retenidos durante una vida útil arbitraria; no significa que el valor en sí mismo deba vivir para siempre o no pueda ser descartado. Las tareas generadas a menudo requieren futuros `'static` porque el ejecutor puede seguir ejecutándolas después de que el marco de pila que las generó ha regresado. En el código backend, esto generalmente significa mover datos propios a la tarea con `async move`, usar tipos propios o clonar manejadores compartidos como `Arc`, en lugar de tomar prestadas variables locales. Las API deberían usar ligaduras `'static` para callbacks almacenados, trabajos en segundo plano y estado de larga duración, pero evitar ligaduras `'static` innecesarias para operaciones prestadas de corta duración.

use std::sync::Arc;

struct AppState {
    name: String,
}

fn spawn_background(state: Arc<AppState>, user_id: String) {
    tokio::spawn(async move {
        // The task owns an Arc handle and a String, so it does not borrow this function's stack.
        println!("{}:{user_id}", state.name);
    });
}
Probar responder esta pregunta con un coach de IA

11Describa cómo Rust utiliza los *traits* para el polimorfismo y compare `impl Trait`, los genéricos y `dyn Trait`.

Rust utiliza *traits* para expresar comportamientos compartidos. Las restricciones genéricas como `fn f<T: Trait>(x: T)` usualmente usan despacho estático a través de la monomorfización. `impl Trait` en posición de argumento es principalmente una abreviatura para un parámetro genérico anónimo. `impl Trait` en posición de retorno oculta un tipo de retorno concreto elegido por la función. `dyn Trait` es un objeto *trait* utilizado detrás de un puntero como `&dyn Trait` o `Box<dyn Trait>`; utiliza despacho dinámico a través de una *vtable* y soporta el borrado de tipos en tiempo de ejecución y valores heterogéneos sujetos a restricciones de seguridad de objetos.

trait Render { fn render(&self) -> String; }

struct Html;
struct Json;

impl Render for Html { fn render(&self) -> String { "html".into() } }
impl Render for Json { fn render(&self) -> String { "json".into() } }

fn render_generic<T: Render>(x: &T) -> String { x.render() } // static dispatch
fn render_impl_arg(x: &impl Render) -> String { x.render() } // anonymous generic
fn make_renderer() -> impl Render { Html } // one hidden concrete type
fn render_dyn(x: &dyn Render) -> String { x.render() } // dynamic dispatch

fn main() {
    let items: Vec<Box<dyn Render>> = vec![Box::new(Html), Box::new(Json)];
    for item in items { println!("{}", render_dyn(item.as_ref())); }
}
Probar responder esta pregunta con un coach de IA

12¿Qué son los tipos asociados en traits y cuándo son preferibles a los parámetros de tipo genéricos?

Los tipos asociados son marcadores de posición de tipo con nombre declarados dentro de un trait y especificados por cada implementación, por ejemplo `Iterator::Item`. Son preferibles cuando el tipo es parte del contrato del trait y cada implementador tiene una elección natural para él. Los parámetros de tipo genéricos son preferibles cuando quien llama debe elegir el tipo o cuando el mismo implementador puede necesitar múltiples implementaciones para diferentes elecciones de tipo.

trait Repository {
    type Entity;
    fn get(&self, id: u64) -> Option<Self::Entity>;
}

struct User;
struct UserRepo;

impl Repository for UserRepo {
    type Entity = User;
    fn get(&self, _id: u64) -> Option<Self::Entity> { Some(User) }
}

fn load<R: Repository>(repo: &R) -> Option<R::Entity> {
    repo.get(1)
}
Probar responder esta pregunta con un coach de IA

Preguntas para Senior

13¿Cómo implementarías el patrón Transactional Outbox en un servicio Rust?

Para implementar el patrón Transactional Outbox en un servicio Rust, se deben escribir las actualizaciones de datos de dominio y las cargas útiles de eventos salientes de forma atómica dentro de una única transacción de base de datos (por ejemplo, usando `sqlx::Transaction`). Una tabla `outbox` almacena el tema de destino, la carga útil y el estado o la marca de tiempo de creación. Un worker asíncrono en segundo plano (o un proceso de Change Data Capture (CDC) como Debezium) sondea o transmite eventos pendientes y los publica en el intermediario de mensajes (como Kafka, RabbitMQ o NATS). Al usar un worker de sondeo en Rust (por ejemplo, en un bucle `tokio::spawn`), el uso de `SELECT ... FOR UPDATE SKIP LOCKED` permite que varias instancias del servicio recuperen y despachen filas de la bandeja de salida concurrentemente sin contención de bloqueos. Una vez que se reconoce la entrega al intermediario, el worker actualiza el estado del registro de la bandeja de salida o elimina la fila. Debido a que los reintentos de red pueden resultar en una entrega al menos una vez, los consumidores posteriores deben diseñarse para manejar los mensajes de forma idempotente.

use sqlx::{PgPool, Postgres, Transaction};
use uuid::Uuid;

pub async fn create_user_with_outbox(
    mut tx: Transaction<'_, Postgres>,
    username: &str,
    email: &str,
) -> Result<(), sqlx::Error> {
    let user_id = Uuid::new_v4();
    sqlx::query!(
        "INSERT INTO users (id, username, email) VALUES ($1, $2, $3)",
        user_id, username, email
    )
    .execute(&mut *tx)
    .await?;

    let payload = serde_json::json!({"event": "UserCreated", "user_id": user_id, "email": email});
    sqlx::query!(
        "INSERT INTO outbox_events (id, topic, payload, status) VALUES ($1, $2, $3, 'PENDING')",
        Uuid::new_v4(),
        "user_events",
        payload
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

pub async fn run_outbox_relay(pool: PgPool) {
    loop {
        let mut tx = match pool.begin().await { Ok(t) => t, Err(_) => continue };
        let rows = sqlx::query!(
            "SELECT id, topic, payload FROM outbox_events WHERE status = 'PENDING' LIMIT 50 FOR UPDATE SKIP LOCKED"
        )
        .fetch_all(&mut *tx)
        .await;

        if let Ok(events) = rows {
            for event in events {
                // publish_to_broker(&event.topic, &event.payload).await;
                let _ = sqlx::query!("UPDATE outbox_events SET status = 'PROCESSED' WHERE id = $1", event.id)
                    .execute(&mut *tx)
                    .await;
            }
            let _ = tx.commit().await;
        }
        tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
    }
}
Probar responder esta pregunta con un coach de IA

14¿Cómo diseñaría una operación de múltiples pasos que actualice una base de datos y llame a una API (Application Programming Interface) externa?

El diseño de una operación de múltiples pasos que abarque una escritura en la base de datos y una llamada a una API externa requiere gestionar fallos parciales y particiones de red sin depender de *two-phase commit* (2PC). 1. **Persistir el estado / intención primero:** Registre un estado inicial (como `PENDING` o `INITIATED`) y la carga útil requerida dentro de la transacción de la base de datos local. Confirme esta transacción antes de iniciar la llamada a la red externa para evitar mantener bloqueos de la base de datos y ranuras del pool de conexiones durante E/S de red lentas. 2. **Solicitud externa idempotente:** Llame a la API externa utilizando una clave de idempotencia determinista (p. ej., derivada del `UUID` de la operación). Esto asegura que los intentos repetidos no causen efectos secundarios duplicados en el mundo real (como cargos dobles). 3. **Transición de estado y manejo de tiempos de espera:** Si la llamada a la API devuelve una respuesta exitosa, actualice el registro de la base de datos a `COMPLETED`. Si falla permanentemente, márquelo como `FAILED` y ejecute acciones de compensación. Si la llamada excede el tiempo de espera o devuelve un error de red, marque el registro como `INDETERMINATE` / `REQUIRES_RECONCILIATION` y permita que un trabajador en segundo plano reconcilie el estado consultando el punto final de estado del proveedor o reintentando con la misma clave de idempotencia.

use uuid::Uuid;

pub async fn process_external_charge(pool: &sqlx::PgPool, user_id: Uuid, amount: i64) -> Result<(), String> {
    let payment_id = Uuid::new_v4();
    let idempotency_key = format!("pay_{}", payment_id);

    // 1. Commit intent locally before making external network calls
    sqlx::query!(
        "INSERT INTO payments (id, user_id, amount, status, idempotency_key) VALUES ($1, $2, $3, 'PENDING', $4)",
        payment_id, user_id, amount, idempotency_key
    )
    .execute(pool)
    .await
    .map_err(|e| e.to_string())?;

    // 2. Call external API with timeout and idempotency key
    let client = reqwest::Client::new();
    let api_result = client.post("https://api.payment.com/v1/charges")
        .header("Idempotency-Key", &idempotency_key)
        .json(&serde_json::json!({ "amount": amount }))
        .timeout(std::time::Duration::from_secs(5))
        .send()
        .await;

    // 3. Update state based on outcome; handle timeouts via reconciliation
    match api_result {
        Ok(resp) if resp.status().is_success() => {
            sqlx::query!("UPDATE payments SET status = 'SUCCESS' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Ok(_) => {
            sqlx::query!("UPDATE payments SET status = 'FAILED' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
        Err(_) => {
            sqlx::query!("UPDATE payments SET status = 'REQUIRES_RECONCILIATION' WHERE id = $1", payment_id)
                .execute(pool)
                .await
                .map_err(|e| e.to_string())?;
        }
    }
    Ok(())
}
Probar responder esta pregunta con un coach de IA

15¿Cómo diseñarías un *backend* multi-inquilino en Rust con aislamiento estricto de datos y enrutamiento dinámico?

El diseño de un *backend* multi-inquilino en Rust implica tres áreas clave: identificación del inquilino, estrategia de aislamiento de datos y enrutamiento dinámico. 1. **Extracción del Inquilino y Propagación del Contexto**: Un *middleware* HTTP (por ejemplo, en Axum o Actix-web) resuelve la identidad del inquilino a partir de reclamaciones JWT (JSON Web Token), subdominios o encabezados. Construye un `TenantContext` fuertemente tipado almacenado en las extensiones de la solicitud. Los *handlers* utilizan extractores de tipo de Rust para asegurar que la lógica de negocio no pueda ejecutarse sin un contexto de inquilino válido. 2. **Estrategia de Aislamiento de Datos**: * ***Base de Datos Compartida con Seguridad a Nivel de Columna / Fila (RLS)***: Todos los inquilinos comparten tablas con una columna `tenant_id`. El aislamiento se aplica a través de RLS de PostgreSQL utilizando variables de sesión a nivel de conexión (por ejemplo, `SET LOCAL app.tenant_id = $1`) o *wrappers* de constructores de consultas. * ***Esquema por Inquilino***: Los inquilinos comparten una instancia de base de datos pero tienen esquemas aislados; el enrutamiento ajusta la ruta de búsqueda por conexión. * ***Base de Datos por Inquilino***: Los inquilinos tienen bases de datos separadas. El servicio mantiene un registro de *pools* (por ejemplo, `Arc<DashMap<TenantId, PgPool>>` o una caché de *pool* LRU (Least Recently Used)) para resolver dinámicamente los *pools* de conexión. 3. **Prevención de Fugas**: Para evitar la contaminación entre inquilinos en aplicaciones asíncronas Tokio, el contexto del inquilino debe pasarse explícitamente a través de los límites de las tareas en lugar de almacenarse en estado global o almacenamiento local de hilos (`thread_local!`), lo que se rompe a través de las migraciones de hilos de trabajo asíncronos.

use axum::{extract::{FromRequestParts, State}, http::request::Parts, async_trait};
use std::sync::Arc;
use dashmap::DashMap;
use sqlx::PgPool;

#[derive(Clone, Debug, Hash, PartialEq, Eq)]
pub struct TenantId(pub String);

#[derive(Clone)]
pub struct AppState {
    pub pool_registry: Arc<DashMap<TenantId, PgPool>>,
}

#[derive(Clone)]
pub struct TenantContext {
    pub tenant_id: TenantId,
    pub pool: PgPool,
}

#[async_trait]
impl FromRequestParts<AppState> for TenantContext {
    type Rejection = (axum::http::StatusCode, &'static str);

    async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
        let tenant_header = parts.headers.get("X-Tenant-ID")
            .and_then(|v| v.to_str().ok())
            .ok_or((axum::http::StatusCode::BAD_REQUEST, "Missing tenant ID"))?;
        
        let tenant_id = TenantId(tenant_header.to_string());
        
        let pool = match state.pool_registry.get(&tenant_id) {
            Some(p) => p.value().clone(),
            None => return Err((axum::http::StatusCode::NOT_FOUND, "Tenant database pool not found")),
        };

        Ok(TenantContext { tenant_id, pool })
    }
}
Probar responder esta pregunta con un coach de IA