15 ausgewählte Rust Backend-Interviewfragen, gruppiert nach Erfahrungsstufe. Nutzen Sie diese, um Grundlagen, praktische Kompromisse und Überlegungen für die Produktionsumgebung auf Senior-Niveau zu überprüfen.
1Erklären Sie Rusts Eigentumsregeln und wie sie praktische Backend-Entscheidungen wie Klonen, Ausleihen und das Teilen des Anwendungszustands beeinflussen.
Rust weist jedem Wert einen einzigen Besitzer zu. Das Verschieben eines Werts, der nicht `Copy` ist, überträgt das Eigentum, und der Wert wird freigegeben, wenn sein Besitzer den Gültigkeitsbereich verlässt. Code kann Daten entweder unveränderlich über geteilte Referenzen oder veränderlich über eine exklusive veränderliche Referenz ausleihen. Im Backend-Code beeinflusst dies, ob eine Funktion das Eigentum übernimmt, Daten temporär ausleiht, klont, um einen unabhängigen, besessenen Wert zu erhalten, oder langlebigen Zustand über Handles wie `Arc` teilt, gegebenenfalls mit Synchronisierungs- oder anderen Nebenläufigkeits-Primitiven, wenn eine geteilte Veränderung (shared mutation) erforderlich ist.
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;
}
2Was ist der Unterschied zwischen dem Borgen mit `&T` und `&mut T`, und wie beeinflussen diese Regeln API- und Handler-Signaturen?
`&T` ist eine geteilte Referenz (shared reference): sie erlaubt Lesezugriff, und viele geteilte Referenzen auf denselben Wert können gleichzeitig aktiv sein. `&mut T` ist eine exklusive, mutable Referenz (exclusive mutable reference): sie erlaubt Mutationen, aber solange sie aktiv ist, dürfen keine anderen geteilten oder mutablen Referenzen auf denselben Wert verwendet werden. Diese Regeln prägen APIs, indem schreibgeschützte Funktionen `&T` oder engere geliehene Typen wie `&str` akzeptieren, während mutierende Funktionen `&mut T` akzeptieren, die Eigentümerschaft übernehmen oder interne Mutabilität/Synchronisation verwenden. Backend-Handler vermeiden in der Regel den direkten `&mut`-Zugriff auf den geteilten Anwendungsstatus über konkurrierende Anfragen hinweg und verwenden stattdessen geteilte Handles plus Synchronisation oder andere Eigentümerschaftsmuster.
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
}
3Wie funktioniert `Box`, und welche Probleme löst die Heap-Allokation über `Box`?
`Box<T>` ist ein besitzanzeigender Smart Pointer, dessen `T` auf dem Heap gespeichert wird, während der `Box`-Handle ein festgroßer zeigerähnlicher Wert ist. Er hat einzigartige Eigentümerschaft und gibt die Heap-Allokation frei, wenn er den Gültigkeitsbereich verlässt. `Box` bietet Indirektion, was bei großen Werten, rekursiven Typen, die eine bekannte Größe benötigen, dynamisch dimensionierten Werten und Trait-Objekten wie `Box<dyn Trait>` hilft. Das Verschieben einer `Box` überträgt die Eigentümerschaft der Allokation, ohne den auf dem Heap gespeicherten Wert selbst zu verschieben oder zu kopieren.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Beschreiben Sie `Result` und `Option` sowie die wichtigsten idiomatischen Vorgehensweisen, um mit dem Fehlen von Werten und fehleranfälligen Operationen umzugehen.
`Option<T>` stellt entweder `Some(T)` oder `None` dar und wird für das erwartete Fehlen eines Wertes verwendet. `Result<T, E>` stellt entweder `Ok(T)` oder `Err(E)` dar und wird für fehleranfällige Operationen verwendet, bei denen Fehlerinformationen wichtig sind. Zur idiomatischen Handhabung gehören `match`, `if let`/`let else`, Kombinatoren wie `map`, `and_then`, `ok_or`, `unwrap_or`, und `unwrap_or_else`, sowie der Operator `?` zur frühzeitigen Weitergabe aus Funktionen, die kompatible `Option`- oder `Result`-Typen zurückgeben. `unwrap` und `expect` sollten am besten für Invarianten, Tests, Prototypen oder nicht wiederherstellbare Situationen reserviert werden, nicht für normale, wiederherstellbare Backend-Fehler.
5Wie funktioniert der `?`-Operator, und was ermöglicht die Konvertierung zwischen verschiedenen Fehlertypen?
Der `?`-Operator ist eine Kurzform zur Fehlerfortpflanzung. Für `Result` gibt `expr?` den `Ok`-Wert zurück und fährt fort, oder kehrt frühzeitig aus der aktuellen Funktion mit einem `Err` zurück; für `Option` gibt er den `Some`-Wert zurück oder `None`. Die umschließende Funktion muss einen kompatiblen Typ zurückgeben. Für `Result` können sich verschiedene Fehlertypen zusammensetzen, da der Quellfehler mithilfe einer `From`/`Into`-artigen Konvertierung in den Fehlertyp der Funktion umgewandelt wird, oft manuell implementiert oder mit Hilfs-Crates wie `thiserror` abgeleitet.
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)
}
6Erläutern Sie Pattern Matching auf Enums und wie vollständiges Matching die Korrektheit von Domäne und API verbessert.
Enums modellieren einen Wert, der eine von einer festen Menge von Varianten sein kann, und Varianten können Daten enthalten. `match` verzweigt basierend auf der Variante und kann die darin enthaltenen Daten dekonstruieren. Rust prüft, ob Enum-Matches vollständig (exhaustive) sind, es sei denn, ein Wildcard- oder Catch-all-Muster wird verwendet. Dies verbessert die Korrektheit der Domäne und der API, weil, wenn eine neue Zustands- oder Antwortvariante hinzugefügt wird, der Compiler Code, der darauf matcht, zwingen kann, zu entscheiden, wie der neue Fall behandelt werden soll, anstatt ihn stillschweigend zu ignorieren.
7Was sind Newtype-Wrapper und wie verbessern sie die Typsicherheit für IDs, Geld, Tokens und Geheimnisse?
Ein Newtype-Wrapper ist ein eigener Rust-Typ, oft ein Tuple-Struct mit einem Feld, der eine bestehende Repräsentation umhüllt, wie z.B. `struct UserId(Uuid);` oder `struct AccessToken(String);`. Er verbessert die Typsicherheit, da der Compiler semantisch unterschiedliche Werte, die denselben zugrunde liegenden Typ teilen, nicht verwechselt, z.B. `UserId` versus `OrderId`, Cent versus Dollar oder rohe Zeichenketten versus validierte Tokens. Newtypes können auch Konstruktions- und Validierungsregeln zentralisieren und das Verhalten von Traits wie `Display`, `Debug`, `Serialize` oder das Redaktionsverhalten für Geheimnisse steuern.
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
}
8Wie funktioniert das `Drop`-Trait, einschließlich der Reihenfolge des Drop-Vorgangs, der RAII (Resource Acquisition Is Initialization)-Bereinigung und Einschränkungen bei asynchroner Bereinigung?
`Drop` ist Rusts deterministischer Bereinigungs-Hook. Wenn ein eigener Wert zerstört wird, zum Beispiel wenn er den Gültigkeitsbereich verlässt, ruft Rust automatisch dessen `drop(&mut self)`-Methode auf, falls er `Drop` implementiert, und gibt dann seine Felder frei. Dies unterstützt RAII (Resource Acquisition Is Initialization): Ressourcen wie Dateien, Sockets, Locks, Transaktionen, Permits oder Puffer sind an besitzende Werte/Guards gebunden und werden freigegeben, wenn diese Werte „gedroppt“ werden. Lokale Variablen werden in umgekehrter Reihenfolge ihrer Erstellung „gedroppt“; bei einer Struktur mit `Drop` läuft die `drop`-Methode der Struktur, bevor ihre Felder freigegeben werden, und die Felder werden in Deklarationsreihenfolge freigegeben. `Drop::drop` ist synchron und kann nicht `async` sein oder awaited werden, daher erfordert eine saubere asynchrone Bereinigung normalerweise eine explizite asynchrone close/shutdown/flush-Methode oder ein anderes Design; `Drop` kann nur synchrone oder Best-Effort-Bereinigungen durchführen.
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");
}
9Erklären Sie Lebensdauern und warum explizite Lebensdauer-Annotationen manchmal in Rust-APIs erforderlich sind.
Eine Lebensdauer beschreibt, wie lange eine Referenz gültig ist, wodurch der Compiler verwaiste Referenzen ablehnen kann. Lebensdauer-Annotationen sind Kompilierzeit-Constraints, die Beziehungen zwischen Referenzen ausdrücken; sie verlängern nicht die Lebensdauer der zugrunde liegenden Daten und fügen kein Laufzeitverhalten hinzu. Viele einfache Signaturen werden durch Lebensdauer-Elision behandelt, aber explizite Annotationen sind erforderlich, wenn der Compiler nicht ableiten kann, wie Eingabe- und Ausgabe-Referenzen zusammenhängen, wenn Typen Referenzen speichern oder wenn eine API generische Borrowing-Constraints ausdrücken muss.
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);
}
10Was bedeutet die `'static`-Lebensdauer, und wie interagiert sie mit gespawnten Aufgaben und dem Backend-API-Design?
`'static` bei einer Referenz bedeutet, dass die referenzierten Daten für die gesamte Programmlaufzeit gültig sind, wie bei String-Literalen oder `static`-Elementen. Eine Bound (Typbeschränkung) wie `T: 'static` bedeutet, dass Werte des Typs `T` keine nicht-`'static`-geborgten Referenzen enthalten, sodass sie für eine beliebige Lebensdauer gehalten werden können; es bedeutet nicht, dass der Wert selbst ewig leben muss oder nicht fallen gelassen (dropped) werden kann. Gespawnte Aufgaben benötigen oft `'static`-Futures, da der Executor sie möglicherweise weiter ausführt, nachdem der Stack-Frame, der sie erzeugt hat, zurückgekehrt ist. Im Backend-Code bedeutet dies in der Regel, eigene Daten mit `async move` in die Aufgabe zu verschieben, eigene (owned) Typen zu verwenden oder gemeinsame Handles wie `Arc` zu klonen, anstatt lokale Variablen auszuleihen. APIs sollten `'static`-Bounds für gespeicherte Callbacks, Hintergrundaufgaben und langlebigen Zustand verwenden, aber unnötige `'static`-Bounds für kurzlebige ausgeliehene Operationen vermeiden.
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);
});
}
11Beschreiben Sie, wie Rust Traits für Polymorphie verwendet, und vergleichen Sie `impl Trait`, Generics und `dyn Trait`.
Rust verwendet Traits, um gemeinsames Verhalten auszudrücken. Generische Bounds wie `fn f<T: Trait>(x: T)` nutzen normalerweise statischen Dispatch durch Monomorphisierung. `impl Trait` in Argumentposition ist meist eine Kurzform für einen anonymen generischen Parameter. `impl Trait` in Rückgabeposition verbirgt einen konkreten Rückgabetyp, der von der Funktion gewählt wird. `dyn Trait` ist ein Trait-Objekt, das hinter einem Pointer wie `&dyn Trait` oder `Box<dyn Trait>` verwendet wird; es nutzt dynamischen Dispatch über eine Vtable und unterstützt Laufzeit-Typ-Löschung (type erasure) / heterogene Werte, vorbehaltlich Objektsicherheitsbeschränkungen.
12Was sind assoziierte Typen in Traits, und wann sind sie generischen Typparametern vorzuziehen?
Assoziierte Typen sind benannte Typ-Platzhalter, die innerhalb eines Traits deklariert und von jeder Implementierung spezifiziert werden, zum Beispiel `Iterator::Item`. Sie sind vorzuziehen, wenn der Typ Teil des Trait-Kontrakts ist und jeder Implementierer eine natürliche Wahl dafür hat. Generische Typparameter sind vorzuziehen, wenn der Aufrufer den Typ wählen sollte oder wenn derselbe Implementierer möglicherweise mehrere Implementierungen für verschiedene Typ-Auswahlen benötigt.
13Wie würden Sie das Transaktionale Outbox-Muster in einem Rust-Dienst implementieren?
Um das Transaktionale Outbox-Muster in einem Rust-Dienst zu implementieren, schreiben Sie Domänendatenaktualisierungen und Payloads ausgehender Ereignisse atomar innerhalb einer einzigen Datenbanktransaktion (z.B. unter Verwendung von `sqlx::Transaction`). Eine `outbox`-Tabelle speichert das Ziel-Topic, den Payload und den Status oder Erstellungszeitstempel. Ein asynchroner Hintergrund-Worker (oder ein Change Data Capture (CDC)-Prozess wie Debezium) fragt dann ausstehende Ereignisse ab oder streamt sie und veröffentlicht sie an den Message Broker (wie Kafka, RabbitMQ oder NATS). Bei der Verwendung eines abfragenden Workers in Rust (z.B. in einer `tokio::spawn`-Schleife) ermöglicht die Verwendung von `SELECT ... FOR UPDATE SKIP LOCKED`, dass mehrere Dienstinstanzen Outbox-Zeilen gleichzeitig abrufen und versenden können, ohne dass Sperrkonflikte (lock contention) auftreten. Sobald die Broker-Zustellung bestätigt wird, aktualisiert der Worker den Outbox-Datensatzstatus oder löscht die Zeile. Da Netzwerk-Wiederholungen zu einer mindestens einmaligen Zustellung führen können, müssen nachgeschaltete Consumer so konzipiert sein, dass sie Nachrichten idempotent verarbeiten.
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;
}
}
14Wie würden Sie eine mehrstufige Operation entwerfen, die eine Datenbank aktualisiert und eine externe API aufruft?
Der Entwurf einer mehrstufigen Operation, die eine Datenbankaktualisierung und einen externen API-Aufruf umfasst, erfordert den Umgang mit Teilausfällen und Netzwerkpartitionen, ohne sich auf Two-Phase-Commit (2PC) zu verlassen.
1. **Zuerst Zustand / Absicht persistieren**: Speichern Sie einen Anfangszustand (wie `PENDING` oder `INITIATED`) und die erforderlichen Nutzdaten innerhalb der lokalen Datenbanktransaktion. Committen Sie diese Transaktion, bevor Sie den externen Netzwerkaufruf initiieren, um zu vermeiden, dass Datenbanksperren und Verbindungspool-Slots während langsamer Netzwerk-E/A gehalten werden.
2. **Idempotenter externer Request**: Rufen Sie die externe API (Application Programming Interface) mithilfe eines deterministischen Idempotenzschlüssels (z. B. abgeleitet von der UUID der Operation) auf. Dies stellt sicher, dass wiederholte Versuche keine doppelten realen Nebeneffekte (wie doppelte Abrechnungen) verursachen.
3. **Zustandswechsel & Timeout-Behandlung**: Wenn der API-Aufruf eine erfolgreiche Antwort zurückgibt, aktualisieren Sie den Datenbankeintrag auf `COMPLETED`. Schlägt er dauerhaft fehl, markieren Sie ihn als `FAILED` und führen Sie kompensierende Aktionen aus. Hat der Aufruf ein Timeout oder gibt einen Netzwerkfehler zurück, markieren Sie den Eintrag als `INDETERMINATE` / `REQUIRES_RECONCILIATION` und lassen Sie einen Hintergrund-Worker den Zustand abgleichen, indem er den Status-Endpunkt des Anbieters abfragt oder den Versuch mit demselben Idempotenzschlüssel wiederholt.
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(())
}
15Wie würden Sie ein mandantenfähiges Rust-Backend mit strikter Datenisolierung und dynamischem Routing entwerfen?
Der Entwurf eines mandantenfähigen Rust-Backends umfasst drei Schlüsselbereiche: Mandantenidentifikation, Strategie zur Datenisolierung und dynamisches Routing.
1. **Mandantenextraktion & Kontextpropagierung**:
Eine HTTP-Middleware (z.B. in `Axum` oder `Actix-web`) löst die Mandantenidentität aus JWT-Claims (JSON Web Token), Subdomains oder Headern auf. Sie erstellt einen streng typisierten `TenantContext`, der in Request-Extensions gespeichert wird. Handler verwenden Rust-Typ-Extraktoren, um sicherzustellen, dass Geschäftslogik ohne einen gültigen Mandantenkontext nicht ausgeführt werden kann.
2. **Strategie zur Datenisolierung**:
* **Gemeinsame Datenbank mit Spalten-/Zeilen-Level-Security (RLS)**: Alle Mandanten teilen sich Tabellen mit einer `tenant_id`-Spalte. Die Isolation wird über PostgreSQL RLS (Row-Level Security) mittels verbindungsspezifischer Session-Variablen (z.B. `SET LOCAL app.tenant_id = $1`) oder Query-Builder-Wrappern erzwungen.
* **Schema pro Mandant**: Mandanten teilen sich eine Datenbankinstanz, haben aber isolierte Schemata; das Routing passt den Suchpfad pro Verbindung an.
* **Datenbank pro Mandant**: Mandanten haben separate Datenbanken. Der Dienst verwaltet ein Pool-Registry (z.B. `Arc<DashMap<TenantId, PgPool>>` oder einen LRU-Pool-Cache (Least Recently Used)), um dynamisch Verbindungspools aufzulösen.
3. **Leckage-Prävention**:
Um mandantenübergreifende Kontamination in asynchronen `Tokio`-Anwendungen zu vermeiden, muss der Mandantenkontext explizit über Task-Grenzen hinweg übergeben werden und darf nicht in globalem Zustand oder Thread-Local Storage (`thread_local!`) gespeichert werden, da dies bei Migrationen zwischen asynchronen Worker-Threads nicht funktioniert.
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 })
}
}