15 questions d'entretien Rust backend sélectionnées, groupées par niveau d'ancienneté. Utilisez-les pour réviser les fondamentaux, les compromis pratiques et le raisonnement de production de niveau senior.
1Expliquez les règles de possession (ownership) de Rust et comment elles influencent les choix pratiques de backend tels que le clonage, l'emprunt (borrowing) et le partage de l'état de l'application.
Chaque valeur a un propriétaire ; déplacer une valeur non `Copy` transfère cette responsabilité. La valeur est détruite quand son propriétaire sort de portée, sauf transfert. Empruntez avec une référence partagée ou mutable exclusive selon le besoin. Clonez seulement si vous voulez une nouvelle valeur possédée. Pour un état partagé entre requêtes, utilisez par exemple `Arc`, avec une synchronisation adaptée si l’état change.
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;
}
2Quelle est la différence entre l'emprunt avec `&T` et `&mut T`, et comment ces règles affectent-elles les signatures d'API (Application Programming Interface) et de gestionnaires ?
`&T` autorise plusieurs emprunts partagés. Il ne donne pas accès à une mutation ordinaire, mais certains types autorisent la mutabilité intérieure. `&mut T` permet la mutation avec un accès exclusif pendant l’emprunt. Une fonction de lecture prend `&T` ou `&str` selon son besoin ; une fonction de mutation prend `&mut T` ou un mécanisme dédié. Les gestionnaires concurrents partagent souvent un état via un pointeur et des primitives de synchronisation.
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
}
3Comment fonctionne `Box`, et quels problèmes l'allocation sur le tas via `Box` résout-elle ?
`Box<T>` possède sa valeur, placée sur le tas. Le pointeur a une taille connue, même lorsque la valeur est de taille dynamique comme `dyn Trait`. Cette indirection permet notamment de définir un type récursif de taille finie. Déplacer la boîte transfère la propriété sans déplacer la valeur sur le tas. À sa destruction, la boîte détruit la valeur et libère l’allocation.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Décrivez `Result` et `Option` ainsi que les principales façons idiomatiques de gérer l'absence et les opérations faillibles.
`Option<T>` représente une valeur présente (`Some`) ou absente (`None`). `Result<T, E>` distingue le succès (`Ok`) d’un échec qui porte une erreur (`Err`). Utilisez `match`, `if let`, `let ... else` ou les combinateurs comme `map`, `and_then` et `ok_or`. `?` propage une absence ou une erreur depuis une fonction au type compatible. Réservez `unwrap` et `expect` aux invariants ou aux tests, pas aux erreurs ordinaires d’un service.
5Comment fonctionne l'opérateur `?`, et qu'est-ce qui permet la conversion entre différents types d'erreurs ?
Sur `Result`, `expr?` donne la valeur de `Ok` ou quitte la fonction en renvoyant `Err`. Sur `Option`, il donne la valeur de `Some` ou renvoie `None` immédiatement. Le type de retour doit être compatible. Pour `Result`, une implémentation de `From` pour le type d’erreur de retour permet de convertir l’erreur source ; une bibliothèque comme `thiserror` peut générer cette implémentation.
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)
}
6Expliquez le filtrage par motif sur les énumérations et comment le filtrage exhaustif améliore la correction du domaine et de l'API.
Une énumération définit des variantes qui peuvent porter des données. `match` sélectionne une variante et déstructure ses données. Le compilateur exige que chaque possibilité soit couverte. Si vous nommez toutes les variantes, en ajouter une oblige à examiner les sites qui font un `match` exhaustif. Un motif générique comme `_` couvre aussi les variantes nouvelles et peut donc masquer un cas métier à traiter.
7Que sont les types enveloppes (newtypes) et comment renforcent-ils le typage des identifiants, montants, jetons et secrets ?
Un newtype est un type distinct, souvent une structure à un champ, comme `struct UserId(Uuid);`. Le compilateur distingue ainsi `UserId` de `OrderId`, les centimes d’une autre unité, ou un jeton validé d’une chaîne brute. Gardez le champ privé si la construction doit valider une règle, et contrôlez `Display`, `Debug` et la sérialisation pour éviter d’exposer les secrets.
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
}
8Comment fonctionne le trait `Drop` de Rust, y compris l'ordre de suppression, le nettoyage RAII et les limitations concernant le nettoyage asynchrone ?
`Drop::drop` est appelé à la destruction de la valeur, avant la destruction de ses champs dans l’ordre de déclaration. Les variables locales d’une portée sont détruites en ordre inverse. L’acquisition de ressources liée à l’initialisation (RAII) rattache fichiers, verrous ou permis à une valeur ou à une garde. `Drop` est synchrone : une vidange ou une fermeture asynchrone fiable nécessite une méthode explicite à attendre.
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");
}
9Expliquez les durées de vie et pourquoi les annotations explicites de durée de vie sont parfois requises dans les API (Application Programming Interface) en Rust.
Une durée de vie (lifetime) décrit la durée pendant laquelle une référence est valide, permettant au compilateur de rejeter les références pendantes. Les annotations de durée de vie sont des contraintes de compilation qui expriment des relations entre les références ; elles ne prolongent pas la durée de vie des données sous-jacentes et n'ajoutent pas de comportement d'exécution. De nombreuses signatures simples sont gérées par l'élision des durées de vie, mais des annotations explicites sont nécessaires lorsque le compilateur ne peut pas inférer la relation entre les références d'entrée et de sortie, lorsque les types stockent des références, ou lorsqu'une API a besoin d'exprimer des contraintes d'emprunt génériques.
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);
}
10Que signifie la durée de vie `'static`, et comment interagit-elle avec les tâches générées et la conception d'API backend ?
Une référence `&'static T` peut rester valide pendant tout le programme, comme une référence vers un littéral. La borne `T: 'static` signifie que `T` ne dépend pas d’un emprunt de durée plus courte ; une valeur possédée peut tout de même être détruite. Une tâche lancée peut survivre à son appelant : déplacez-y des valeurs possédées avec `async move` ou clonez un `Arc`. N’imposez cette borne que si la durée de vie du travail l’exige.
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);
});
}
11Décrivez comment Rust utilise les traits pour le polymorphisme, et comparez `impl Trait`, les génériques et `dyn Trait`.
Les traits décrivent un comportement commun. Un paramètre `T: Trait` est généralement spécialisé à la compilation ; `impl Trait` en argument représente un paramètre générique anonyme. En retour, `impl Trait` masque un type concret choisi par la fonction. `&dyn Trait` ou `Box<dyn Trait>` permettent un choix dynamique via une table de méthodes, si le trait est compatible avec `dyn`.
12Que sont les types associés d’un trait, et quand les préférer à des paramètres de type génériques ?
Un type associé, comme `Iterator::Item`, est déclaré dans le trait et fixé par chaque implémentation. Utilisez-le si le contrat d’une implémentation détermine naturellement ce type. Un paramètre générique du trait convient quand un même type doit fournir plusieurs implémentations pour des choix différents, ou quand l’appelant doit exprimer ce choix.
13Comment implémenteriez-vous le patron de l'outbox transactionnelle dans un service Rust ?
Écrivez les modifications métier et l’événement dans la même transaction SQL. Un processus lit la table d’envoi ou un flux de changements et publie vers le courtier. Pour répartir la prise en charge, utilisez par exemple `FOR UPDATE SKIP LOCKED` dans une transaction courte et un bail récupérable. Marquez l’événement publié après accusé. Une panne entre publication et marquage entraîne une répétition : fournissez un identifiant et dédupliquez côté consommateur.
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;
}
}
14Comment concevriez-vous une opération en plusieurs étapes qui met à jour une base de données et appelle une API externe?
Enregistrez et validez d’abord une intention `PENDING` avec son identifiant. Appelez le fournisseur hors transaction, avec la même clé à chaque tentative si son contrat la prend en charge. En cas de succès confirmé, passez à `COMPLETED`. Distinguez un rejet certain d’un résultat inconnu après un délai dépassé. Pour ce dernier, vérifiez l’état auprès du fournisseur ou réessayez selon sa garantie de déduplication. Compensez un échec définitif si nécessaire.
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(())
}
15Comment concevriez-vous un backend Rust multilocataire avec isolation stricte et routage dynamique ?
Un middleware Axum ou Actix-web résout le locataire demandé depuis des attributs d’identité vérifiés ou des entrées de routage et vérifie l'autorisation de l'appelant. Un en-tête ou sous-domaine client n'est pas une autorité de confiance. Créez un TenantContext fortement typé dans les extensions de requête ; les extracteurs des gestionnaires imposent sa présence valide. Pour l'isolation, une base partagée utilise tenant_id et la sécurité au niveau des lignes (RLS) de PostgreSQL avec politiques et rôles incapables de les contourner, par exemple SELECT set_config('app.tenant_id', $1, true) paramétré dans la transaction. Un simple wrapper de requête est un contrôle applicatif distinct. Définissez ce contexte local à chaque transaction sur toute connexion empruntée, et testez réemploi du pool et tâches de fond. Avec un schéma par locataire, routez par identifiants correctement cités ou en liste autorisée et search_path de portée maîtrisée ; les schémas ne remplacent pas privilèges et autorisation. Avec une base par locataire, un registre, comme Arc<DashMap<TenantId, PgPool>>, ou cache de groupes de connexions évincant les moins récemment utilisés (LRU) résout dynamiquement la connexion. Transmettez explicitement le contexte entre tâches Tokio : globales et thread_local! ne conviennent pas aux migrations des tâches entre threads et risquent de mélanger les locataires.
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 })
}
}