Database router (swappable routing)
Take over how umbral picks the database and read/write target per request - read replicas and custom per-request routing.
Database router
Database routing routes each model to a database statically, by name. A DatabaseRouter lets you take those decisions over yourself, per request - read/write replica split, or routing by any per-request key - without touching the ORM.
The trait
pub trait DatabaseRouter: Send + Sync { fn db_for_read(&self, model: &ModelMeta, ctx: &RouteContext) -> Alias { /* default: static alias */ } fn db_for_write(&self, model: &ModelMeta, ctx: &RouteContext) -> Alias { /* default: static alias */ } fn allow_relation(&self, a: &ModelMeta, b: &ModelMeta) -> bool { /* default: same database */ } fn allow_migrate(&self, alias: &str, model: &ModelMeta) -> bool { /* default: migrate on its alias */ } fn schema_for(&self, ctx: &RouteContext) -> Option<Schema> { None }}Every method has a default that reproduces today's behavior, so you only override what you need.
Example: read/write split
use umbral::db::{Alias, DatabaseRouter, RouteContext};use umbral::migrate::ModelMeta; struct ReplicaRouter;impl DatabaseRouter for ReplicaRouter { fn db_for_read(&self, _m: &ModelMeta, _c: &RouteContext) -> Alias { Alias::new("replica") } fn db_for_write(&self, _m: &ModelMeta, _c: &RouteContext) -> Alias { Alias::new("default") }} App::builder() .database("default", primary_pool) .database("replica", replica_pool) .router(ReplicaRouter) .build()?;Reads now resolve to the replica pool, writes to default.
Every read terminal (fetch, first, count, exists, get, aggregate, …) calls db_for_read; every write terminal (create, bulk_create, update_values, delete, …) calls db_for_write. .on(&pool) is a hard override that bypasses the router entirely.
Read-after-write consistency. get_or_create and update_or_create run their existence check against the write pool, not the read replica, so they can't miss a just-written row on a lagging replica and spuriously insert a duplicate.
Per-request context
To route by a per-request value (a region, a shard key, a tenant id), populate a request-scoped [RouteContext] from each request and read it in your router:
App::builder() .route_context(|req| { let tenant = req.headers().get("X-Tenant").and_then(|v| v.to_str().ok()); match tenant { Some(t) => RouteContext::new().with_tenant(TenantKey::new(t)), None => RouteContext::new(), } }) .router(MyTenantRouter) .build()?;The context rides a task-local for the whole request, so every ORM call inside the handler sees it - no threading required. Background jobs do not inherit it: a spawned task gets the default context (no tenant) unless it opts in explicitly via umbral::db::route_context_scope(ctx, fut). This is deliberate - a worker never silently runs as the wrong tenant.
Multitenancy is not a feature yet
umbral ships no multitenancy system - no Tenant model, no per-tenant schema migrations, no tenant provisioning. Don't reach for the router expecting turnkey schema- or database-per-tenant tenancy; it gives you the low-level routing primitive (which database a query uses), not the management around it.
The trait does carry a schema_for(ctx) -> Option<Schema> hook for qualifying queries with a schema name, but it's a low-level building block, not a supported tenancy feature, and is intentionally undocumented here until the management layer (deferred) lands.
See also
- Database routing - the static, by-name side: named pools, per-model / per-plugin routing, per-database migrations, and the cross-DB FK guard.
examples/read-replica/- a working read/write split end to end.DatabaseRouterfoundation spec:docs/superpowers/specs/2026-06-16-database-router-foundation-design.md.