This version is in beta. Some features may change before release.

Idioms — stop hand-rolling these

Things capable umbral developers keep re-implementing that the framework already does. Each one is a real example from a production app.

Idioms — stop hand-rolling these

Every item below was found in a real production umbral app, written by a competent developer, where the framework already shipped the thing being re-implemented. None of them are bugs — the hand-rolled version usually works. They're here because "it works" and "it's right" diverge in exactly these three places, and because if one good developer missed them, so will you.

1. Gating on staff: use the extractor

Hand-rolled — this appeared three times, once per plugin:

Code
rust
fn require_staff(identity: &Identity) -> Result<i64, (StatusCode, Json<Value>)> {
if !identity.is_staff {
return Err((StatusCode::FORBIDDEN, Json(json!({"error": "forbidden"}))));
}
identity.user_id.parse().map_err(|_| /* ... */)
}
 
async fn delete_team(identity: Identity, ...) -> Result<...> {
let user_id = require_staff(&identity)?;
// ...
}

The framework ships it as an extractor, so the gate is in the signature — a handler that forgot it doesn't compile into existence:

Code
rust
use umbral_auth::RequireStaff;
 
async fn delete_team(RequireStaff(user_id): RequireStaff, ...) -> Result<...> {
// if we're here, the caller is staff. There is no path where we aren't.
}

The difference that matters isn't the line count. A helper you must remember to call is a gate you can forget; an extractor is a gate you cannot express a handler without.

2. Multi-model writes: use a transaction

Hand-rolled — deleting a match across three models:

Code
rust
MatchdayTeam::objects().filter(...).delete().await?;
Selection::objects().filter(...).delete().await?; // ← if this fails...
Goal::objects().filter(...).delete().await?; // ← ...these never run

Three sequential awaits, no transaction. A failure in the middle leaves the first delete committed and the rest not — orphaned rows, silently, and the request returns a 500 that tells you nothing about the half-applied state you're now in.

The framework:

Code
rust
umbral::db::transaction(|tx| Box::pin(async move {
MatchdayTeam::objects().filter(...).on_tx(tx).delete().await?;
Selection::objects().filter(...).on_tx(tx).delete().await?;
Goal::objects().filter(...).on_tx(tx).delete().await?;
Ok(())
})).await?;

All three commit, or none do. See Transactions.

Warning

The tell for this bug is two or more writes in one handler. If a handler writes to more than one table and there's no transaction(...) in it, you have a partial-failure state that nobody has thought about.

3. Normalising fields: declare it on the model

Hand-rolled at the app's create-member boundary:

Code
rust
let email = payload.email.trim().to_lowercase();

The framework — declare it once, on the field, and every write path normalises: REST, the admin, forms, your own code:

Code
rust
#[derive(Model)]
struct AuthUser {
#[umbral(trim, lowercase)]
email: String,
}

The hand-rolled version normalises at the boundary you remembered. A second endpoint, an admin edit, or a data import writes " Ada@Example.COM " straight through — and now alice@x.com and Alice@X.com are two different users, which is a support ticket and possibly a security one. See Normalized fields.

4. Errors in a handler: return ApiError

Hand-rolled — this was in the framework's own examples, and in the startproject scaffold, which means every new umbral app started life with it:

Code
rust
fn internal_error<E: std::fmt::Display>(err: E) -> (StatusCode, String) {
(StatusCode::INTERNAL_SERVER_ERROR, err.to_string())
}
 
async fn home() -> Result<Html<String>, (StatusCode, String)> {
let count = Post::objects().count().await.map_err(internal_error)?;
let body = render("home.html", &context!(count)).map_err(internal_error)?;
Ok(Html(body))
}

Look at what err.to_string() does on the failure path: it sends the database's error text to the browser. no such table: shop_product. A column name. A constraint. Whoever asked for the page gets a free look at your schema.

The framework:

Code
rust
async fn home() -> Result<Html<String>, ApiError> {
let count = Post::objects().count().await?;
let body = render("home.html", &context!(count))?;
Ok(Html(body))
}

ApiError converts from sqlx::Error, WriteError and TemplateError, so ? just works. A 500 logs the real cause server-side and returns an opaque message. A WriteError that's a validation failure becomes a 400 with the per-field error map. There is no helper to write.

Warning
The tell is `.map_err(...)` on an ORM or template call. If you wrote a `fn internal_error` / `fn err500` at the top of a file, you are almost certainly leaking internals on the failure path — and it only shows up when something is already going wrong.

The pattern behind all three

Each hand-rolled version is correct at the call site that wrote it and wrong at the next one. The framework version moves the guarantee from "a developer remembered" to "the type system / the schema / the transaction enforced it" — which is the entire reason to use a framework rather than a pile of libraries.

If you're writing something that feels like plumbing, check whether it's already here. It usually is:

You're about to writeReach for
A permission check at the top of a handlerRequireStaff / RequireAuth, or REST permissions
fn internal_error / .map_err(err500)ApiError — and stop leaking the DB error to the browser
identity.user_id.parse::<i64>()Identity::pk::<i64>(), or better, the RequireAuth<T> extractor
Two or more writes in one handlerdb::transaction
.trim() / .to_lowercase() on input#[umbral(trim, lowercase)]
Checking a JSON request body by handValid<T> + #[derive(Validate)]
A rule only your app knows (banned words, reserved names)register_cleaner
A report too slow to compute per request#[umbral(view = "...")]
"who created this row"#[umbral(auto_user_add)]
"keep the row but hide it"#[umbral(soft_delete)]
A change log#[umbral(audited)]
Fetching a related model in a loopselect_related / prefetch_related
A hand-written TypeScript API clientumbral gen-client
A cron-ish "run this every hour"Schedule::cron
Resizing an uploaded imagethumbnails(...)
idiomsdxpatterns