15 geselecteerde Rust backend interviewvragen gegroepeerd op senioriteitsniveau. Gebruik ze om basisprincipes, praktische afwegingen en het redeneren op senior-niveau voor productie te herzien.
1Leg de eigenaarschapsregels van Rust uit en hoe deze praktische backend-keuzes bepalen, zoals klonen, lenen en het delen van applicatiestatus.
Rust kent elke waarde één eigenaar toe. Het verplaatsen van een niet-`Copy`-waarde draagt het eigenaarschap over, en de waarde wordt verwijderd (`dropped`) wanneer de eigenaar buiten zijn bereik (`scope`) valt. Code kan data onveranderlijk lenen via gedeelde referenties of veranderlijk via een exclusieve muteerbare referentie. In backend-code bepaalt dit of een functie het eigenaarschap neemt, tijdelijk leent, kloont om een onafhankelijke eigen waarde te verkrijgen, of langdurige status deelt via 'handles' zoals `Arc`, met synchronisatie of andere concurrency-primitieven wanneer gedeelde mutatie nodig is.
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;
}
2Wat is het verschil tussen lenen met `&T` en `&mut T`, en hoe beïnvloeden deze regels API- en handler-signaturen?
`&T` is een gedeelde referentie: het maakt alleen-lezen toegang mogelijk en veel gedeelde referenties naar dezelfde waarde kunnen tegelijkertijd actief zijn. `&mut T` is een exclusieve muteerbare referentie: het maakt mutatie mogelijk, maar terwijl het actief is, mogen geen andere gedeelde of muteerbare referenties naar dezelfde waarde worden gebruikt. Deze regels bepalen de vorm van API's door read-only functies `&T` of specifiekere geleende typen zoals `&str` te laten accepteren, terwijl muterende functies `&mut T` accepteren, eigenaarschap overnemen, of interne muteerbaarheid/synchronisatie gebruiken. Backend-handlers vermijden meestal directe `&mut`-toegang tot gedeelde applicatiestatus bij gelijktijdige verzoeken en gebruiken in plaats daarvan gedeelde handles plus synchronisatie of andere eigenaarschapspatronen.
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
}
3Hoe werkt `Box`, en welke problemen lost heap-allocatie via `Box` op?
`Box<T>` is een smart pointer met eigenaarschap waarvan `T` op de heap is opgeslagen, terwijl de `Box`-handle een aanwijzer-achtige waarde met vaste grootte is. Het heeft enkelvoudig eigenaarschap en verwijdert/vrijwaart de heap-allocatie wanneer het buiten scope valt. `Box` zorgt voor indirectie, wat helpt bij grote waarden, recursieve typen die een bekende grootte nodig hebben, dynamisch formaat waarden, en trait-objecten zoals `Box<dyn Trait>`. Het verplaatsen van een `Box` draagt het eigenaarschap van de allocatie over zonder de op de heap opgeslagen waarde zelf te verplaatsen of te kopiëren.
enum List {
Cons(i32, Box<List>),
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
4Beschrijf `Result` en `Option` en de voornaamste idiomatische manieren om te werken met afwezigheid en foutgevoelige bewerkingen.
`Option<T>` vertegenwoordigt ofwel `Some(T)` of `None` en wordt gebruikt voor de verwachte afwezigheid van een waarde. `Result<T, E>` vertegenwoordigt ofwel `Ok(T)` of `Err(E)` en wordt gebruikt voor foutgevoelige bewerkingen waarbij foutinformatie van belang is. Idiomatische afhandeling omvat `match`, `if let`/`let else`, combinators zoals `map`, `and_then`, `ok_or`, `unwrap_or`, en `unwrap_or_else`, en de `?`-operator voor vroege propagatie vanuit functies die compatibele `Option`- of `Result`-typen retourneren. `unwrap` en `expect` kunnen het beste worden gereserveerd voor invarianten, tests, prototypes of onherstelbare situaties, niet voor normale herstelbare backend-fouten.
5Hoe werkt de `?`-operator, en wat maakt conversie tussen verschillende fouttypen mogelijk?
De `?`-operator is een afkorting voor het propageren van falen. Bij `Result` geeft `expr?` de `Ok`-waarde terug en gaat verder, of retourneert vroegtijdig uit de huidige functie met een `Err`; bij `Option` geeft het de `Some`-waarde terug of retourneert `None`. De omschakelende functie moet een compatibel type retourneren. Voor `Result` kunnen verschillende fouttypen worden samengesteld omdat de bronfout wordt geconverteerd naar het fouttype van de functie met behulp van `From`/`Into`-stijl conversie, vaak handmatig geïmplementeerd of afgeleid met behulp van `helper crates` zoals `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)
}
6Leg uit hoe patroonherkenning (`pattern matching`) werkt op opsommingstypen (`enums`) en hoe uitputtende overeenkomsten (`exhaustive matching`) de domein- en API-correctheid verbeteren.
Opsommingstypen (`enums`) modelleren een waarde die één van een vaste set varianten kan zijn, en varianten kunnen data bevatten. De `match`-expressie vertakt op basis van de variant en kan de interne data deconstrueren.
Rust controleert of `match`-expressies op opsommingstypen uitputtend zijn, tenzij een `wildcard` of `catch-all` patroon wordt gebruikt. Dit verbetert de domein- en API (Application Programming Interface)-correctheid, want wanneer een nieuwe status- of respons-variant wordt toegevoegd, kan de compiler code die daarop matcht dwingen om te beslissen hoe deze nieuwe case moet worden afgehandeld, in plaats van deze stilzwijgend te negeren.
7Wat zijn newtype wrappers, en hoe verbeteren ze de typeveiligheid voor ID's, geld, tokens en geheimen?
Een newtype wrapper is een distinctief Rust-type, vaak een `tuple struct` met één veld, rond een bestaande representatie, zoals `struct UserId(Uuid);` of `struct AccessToken(String);`. Het verbetert de typeveiligheid omdat de compiler semantisch verschillende waarden die hetzelfde onderliggende type delen, niet zal verwarren, zoals `UserId` versus `OrderId`, centen versus dollars, of ruwe strings versus gevalideerde tokens. Newtypes kunnen ook constructie- en validatieregels centraliseren en `traits` zoals `Display`, `Debug`, `Serialize`, of het redactiegedrag voor geheimen controleren.
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
}
8Hoe werkt de `Drop`-eigenschap, inclusief de drop-volgorde, RAII-opruiming en beperkingen rond asynchrone opruiming?
`Drop` is Rust's deterministische opruimingshaak. Wanneer een waarde in eigendom wordt vernietigd, bijvoorbeeld wanneer deze buiten bereik valt, roept Rust automatisch de `drop(&mut self)`-methode aan als deze `Drop` implementeert, en dropt vervolgens de velden. Dit ondersteunt RAII (Resource Acquisition Is Initialization): bronnen zoals bestanden, sockets, locks, transacties, permits of buffers zijn gekoppeld aan eigendomswaarden/guards en worden vrijgegeven wanneer die waarden worden gedropt. Lokale variabelen worden gedropt in omgekeerde volgorde van creatie; voor een struct met `Drop` wordt de `drop`-methode van de struct uitgevoerd voordat de velden worden gedropt, en velden worden gedropt in declaratievolgorde. `Drop::drop` is synchroon en kan niet `async` zijn of `awaited` worden, dus gracieuze asynchrone opruiming vereist meestal een expliciete asynchrone close-/shutdown-/flush-methode of een ander ontwerp; `Drop` kan alleen synchrone of 'best effort'-opruiming uitvoeren.
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");
}
9Leg lifetimes uit en waarom expliciete lifetime-annotaties soms vereist zijn in Rust API's (Application Programming Interfaces).
Een lifetime beschrijft hoe lang een referentie geldig is, waardoor de compiler "dangling references" (verwijzingen naar niet meer bestaande data) kan afwijzen. Lifetime-annotaties zijn compile-time beperkingen die relaties tussen referenties uitdrukken; ze verlengen de levensduur van de onderliggende data niet en voegen geen runtimegedrag toe. Veel eenvoudige signatures worden afgehandeld door lifetime elision (het weglaten van lifetime-specificaties), maar expliciete annotaties zijn nodig wanneer de compiler niet kan afleiden hoe input- en outputreferenties zich tot elkaar verhouden, wanneer typen referenties opslaan, of wanneer een API generieke 'borrowing' beperkingen moet uitdrukken.
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);
}
10Wat betekent de `'static` levensduur, en hoe werkt deze samen met gespawnde taken en backend API-ontwerp?
De `'static` op een referentie betekent dat de gerefereerde data geldig is gedurende het hele programma, zoals bij string literals of `static` items. Een 'bound' zoals `T: 'static` betekent dat waarden van type `T` geen niet-`'static` geleende referenties bevatten, zodat ze voor een willekeurige levensduur kunnen worden vastgehouden; het betekent niet dat de waarde zelf voor altijd moet leven of niet gedropt kan worden. Gespawnde taken vereisen vaak `'static` futures omdat de uitvoerder ze kan blijven uitvoeren nadat het aanroepende stack frame is teruggekeerd. In backend-code betekent dit meestal het verplaatsen van 'owned data' naar de taak met `async move`, het gebruik van 'owned' types, of het klonen van gedeelde handgrepen zoals `Arc`, in plaats van het lenen van lokale variabelen. API's (Application Programming Interfaces) moeten `'static` bounds gebruiken voor opgeslagen callbacks, achtergrondtaken en langdurige status, maar onnodige `'static` bounds vermijden voor kortstondige geleende operaties.
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);
});
}
11Beschrijf hoe Rust traits gebruikt voor polymorfisme, en vergelijk `impl Trait`, generics en `dyn Trait`.
Rust gebruikt traits om gedeeld gedrag uit te drukken. Generieke beperkingen zoals `fn f<T: Trait>(x: T)` gebruiken gewoonlijk statische dispatch via monomorfisatie. `impl Trait` in argumentpositie is meestal een afkorting voor een anonieme generieke parameter. `impl Trait` in return-positie verbergt één concreet returntype dat door de functie is gekozen. `dyn Trait` is een trait-object dat achter een pointer wordt gebruikt, zoals `&dyn Trait` of `Box<dyn Trait>`; het gebruikt dynamische dispatch via een vtable en ondersteunt runtime type-erasure/heterogene waarden, onderhevig aan object-safety beperkingen.
12Wat zijn geassocieerde typen bij traits, en wanneer zijn ze te verkiezen boven generieke typeparameters?
Geassocieerde typen zijn benoemde typeaanduidingen die binnen een trait zijn gedeclareerd en door elke implementatie worden gespecificeerd, bijvoorbeeld `Iterator::Item`. Ze zijn te verkiezen wanneer het type deel uitmaakt van het contract van de trait en elke implementeerder één natuurlijke keuze heeft. Generieke typeparameters zijn te verkiezen wanneer de aanroeper het type moet kiezen of wanneer dezelfde implementeerder meerdere implementaties nodig heeft voor verschillende typekeuzes.
13Hoe zou u het Transactional Outbox-patroon implementeren in een Rust-service?
Om het Transactional Outbox-patroon in een Rust-service te implementeren, schrijft u updates van domeingegevens en uitgaande gebeurtenispayloads atomair binnen één database-transactie (bijv. met behulp van `sqlx::Transaction`). Een `outbox`-tabel slaat het doeltopic, de payload en de status of aanmaaktijdstempel op. Een asynchrone achtergrondworker (of een Change Data Capture-proces zoals Debezium) pollt of streamt vervolgens in afwachting zijnde gebeurtenissen en publiceert deze naar de message broker (zoals Kafka, RabbitMQ of NATS). Bij gebruik van een polling-worker in Rust (bijv. in een `tokio::spawn`-lus) maakt het gebruik van `SELECT ... FOR UPDATE SKIP LOCKED` het mogelijk voor meerdere service-instanties om outbox-rijen concurrerend op te halen en te verzenden zonder vergrendelingsconflicten. Zodra de levering door de broker is bevestigd, werkt de worker de status van de outbox-record bij of verwijdert de rij. Omdat netwerkherpogingen kunnen leiden tot minimaal-één-keer-levering, moeten downstream-afnemers zijn ontworpen om berichten idempotent af te handelen.
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;
}
}
14Hoe zou u een meerstapsoperatie ontwerpen die een database bijwerkt en een externe API aanroept?
Het ontwerpen van een meerstapsoperatie die een databasede schrijfactie en een externe API-aanroep omvat, vereist het beheren van gedeeltelijke storingen en netwerkpartities zonder te vertrouwen op two-phase commit (2PC). 1. **Eerst Status / Intentie Persisteren**: Leg een initiële status (zoals `PENDING` of `INITIATED`) en de vereiste payload vast binnen de lokale databasetransactie. Commit deze transactie voordat de externe netwerkaanroep wordt gestart om te voorkomen dat databaselock en connection pool-slots worden vastgehouden tijdens langzame netwerk-I/O. 2. **Idempotente Externe Aanvraag**: Roep de externe API aan met behulp van een deterministische idempotentiesleutel (bijv. afgeleid van de UUID (Universally Unique Identifier) van de operatie). Dit zorgt ervoor dat herhaalde pogingen geen dubbele reële neveneffecten veroorzaken (zoals tweemaal in rekening brengen). 3. **Statusovergang & Time-outs Afhandelen**: Als de API-aanroep een succesvolle respons retourneert, werk dan de database record bij naar `COMPLETED`. Als deze permanent mislukt, markeer het dan als `FAILED` en voer compenserende acties uit. Als de aanroep een time-out krijgt of een netwerkfout retourneert, markeer de record dan als `INDETERMINATE` / `REQUIRES_RECONCILIATION` en laat een background worker de status reconciliëren door het status-endpoint van de provider te bevragen of opnieuw te proberen met dezelfde idempotentiesleutel.
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(())
}
15Hoe zou u een multi-tenant Rust-backend ontwerpen met strikte data-isolatie en dynamische routering?
Het ontwerpen van een multi-tenant Rust-backend omvat drie belangrijke gebieden: tenant-identificatie, data-isolatiestrategie en dynamische routering.
1. **Tenant-extractie & Contextpropagatie**: Een HTTP-middleware (bijv. in Axum of Actix-web) lost de tenant-identiteit op uit JWT (JSON Web Token)-claims, subdomeinen of headers. Het construeert een sterk getypeerde `TenantContext` die wordt opgeslagen in request-extensies. Handlers gebruiken Rust-type-extractors om af te dwingen dat bedrijfslogica niet kan worden uitgevoerd zonder een geldige tenant-context.
2. **Data-isolatiestrategie**:
* *Gedeelde Database met Kolom-/Rij-niveau Beveiliging (RLS - Row-Level Security)*: Alle tenants delen tabellen met een `tenant_id`-kolom. Isolatie wordt afgedwongen via PostgreSQL RLS met behulp van sessievariabelen op connectieniveau (bijv. `SET LOCAL app.tenant_id = $1`) of query builder-wrappers.
* *Schema-per-Tenant*: Tenants delen een database-instantie, maar hebben geïsoleerde schema's; routering past het zoekpad per verbinding aan.
* *Database-per-Tenant*: Tenants hebben aparte databases. De dienst onderhoudt een pool-register (bijv. `Arc<DashMap<TenantId, PgPool>>` of een LRU (Least Recently Used)-pool-cache) om dynamisch verbindingspools op te lossen.
3. **Voorkomen van Lekkage**: Om cross-tenant besmetting in asynchrone Tokio-applicaties te voorkomen, moet de tenant-context expliciet over taakgrenzen heen worden doorgegeven, in plaats van te worden opgeslagen in globale toestand of thread-lokale opslag (`thread_local!`), wat problemen veroorzaakt bij migraties tussen asynchrone worker threads.
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 })
}
}