What a plugin is
A `Plugin` is a self-contained, swappable unit that bundles models, routes, and logic into one builder call.
A Plugin is umbral's unit of composition: a self-contained, swappable bundle of everything one slice of an app contributes, registered with a single builder call.
One trait carries every contribution. The Plugin trait gathers models, routes, system checks, an on_ready startup hook, middleware via wrap_router, management commands via commands(), template directories, and embedded static files - all through one trait, into one .plugin(...) line. The built-in umbral-auth plugin is the canonical example: it ships a User model, password hashing helpers, a Plugin impl that registers the model under the "auth" namespace, and lives in its own crate under plugins/umbral-auth/. Structurally it's identical to anything you'd write yourself - there's no special-casing between built-in and third-party.
What a plugin contributes
A single Plugin impl can register any subset of the following surface:
| You declare | Through |
|---|---|
| The plugin itself | App::builder().plugin(MyPlugin) |
| Models (become migrations) | pub struct ... with #[derive(Model)], returned from Plugin::models() |
| Migration files | plugins/<my-app>/migrations/<my-app>/ |
| Routes | Plugin::routes() returning an axum Router |
| Startup work | Plugin::on_ready(&ctx) |
| CLI subcommands | Plugin::commands(); umbral-auth contributes createsuperuser this way (see the Plugin trait page) |
| Table names | Snake-case of the struct by default; #[umbral(table = "...")] overrides it |
Where plugins live
The convention is plugins/<name>/ at the repo root, as its own Cargo
project. plugins/umbral-auth/ is the live example. The pattern matches
what every built-in plugin will look like (sessions, tasks, admin, REST),
and what a third-party cargo add umbral-blog would look like - there's
no special-casing between "built-in" and "third-party".
A small project that doesn't yet need its own crate can register the
Plugin impl right next to main.rs. Both shapes work; the crate split
matters when you want the plugin to be reusable.
Why "plugin"
The name says what the unit is: a self-contained, swappable piece that extends the framework. Every capability - whether it ships with umbral or you wrote it - plugs in the same way, through the same trait, with the same .plugin(...) call. The plugin contract spec (docs/specs/02-plugin-contract.md) makes the requirement explicit from the architecture side: built-ins and third-parties have to be structurally identical, and "plugin" names that contract directly.
A complete plugin
Here's the whole shape in one file - a blog plugin that owns its own
Post model, exposes a couple of routes that hit the ORM from the
plugin code, customises REST via a ResourceConfig, and runs a one-shot
seed on startup via
on_ready. Drop this in plugins/blog/src/lib.rs and add a single
.plugin(blog::BlogPlugin) line to your App::builder() chain.
//! The blog app. Owns the `Post` model + a couple of HTML pages. use serde::{Deserialize, Serialize};use serde_json::json;use umbral::plugin::{AppContext, Plugin, PluginError};use umbral::prelude::*;use umbral::web::{Html, Json, Path, Router, StatusCode, get}; // --- Model ----------------------------------------------------------------- /// The plugin owns its model. `App::builder()` auto-discovers it/// through the `Plugin::models()` hook below - no `.model::<Post>()`/// line on the builder.#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, umbral::orm::Model)]pub struct Post { pub id: i64, pub title: String, pub body: String, pub author_email: String,} // --- Route handlers -- accessing the ORM from inside the plugin ------------ /// HTML list view. `Post::objects()` reaches the ambient pool that/// `App::build()` published - no `&Pool` arg through the handler chain,/// no `State<DbPool>` extractor, no global look-up at the call site.async fn list_posts() -> Result<Html<String>, (StatusCode, String)> { let posts = Post::objects() .order_by(post::ID.desc()) .limit(50) .fetch() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let mut html = String::from("<h1>Posts</h1><ul>"); for p in &posts { html.push_str(&format!("<li>{} - {}</li>", p.id, p.title)); } html.push_str("</ul>"); Ok(Html(html))} /// JSON detail view. Same ORM, different terminal - `.first()` rather than/// `.fetch()` because we want one row or 404.async fn post_detail(Path(id): Path<i64>) -> Result<Json<Post>, (StatusCode, String)> { let found = Post::objects() .filter(post::ID.eq(id)) .first() .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; found .map(Json) .ok_or((StatusCode::NOT_FOUND, format!("no post with id {id}")))} // --- REST customisation, bundled in the plugin ---------------------------- /// What the plugin's REST endpoints should look like. Lives WITH the/// plugin so main.rs doesn't have to thread customisation per-table./// Register from main.rs via `.plugin(RestPlugin::default()/// .resource(blog::rest_resource()))`.pub fn rest_resource() -> umbral_rest::ResourceConfig { umbral_rest::ResourceConfig::new("post") // Hide the raw author email; show the domain only. .transform("author_email", |v| { let s = v.as_str().unwrap_or(""); match s.split_once('@') { Some((_, d)) => json!(format!("***@{d}")), None => v.clone(), } }) // Add a `summary` field derived from the body's first line. .computed("summary", |row| { let body = row.get("body").and_then(|v| v.as_str()).unwrap_or(""); let first_line = body.lines().next().unwrap_or(""); json!(first_line.chars().take(120).collect::<String>()) })} // --- The Plugin trait ------------------------------------------------------ pub struct BlogPlugin; impl Plugin for BlogPlugin { fn name(&self) -> &'static str { "blog" } /// Auto-registration: every model this plugin owns goes here. /// `App::builder()` walks `Plugin::models()` on every registered /// plugin and feeds the results into the migration registry - /// you do NOT also call `.model::<Post>()` from main.rs. fn models(&self) -> Vec<umbral::migrate::ModelMeta> { vec![umbral::migrate::ModelMeta::for_::<Post>()] } fn routes(&self) -> Router { Router::new() .route("/blog", get(list_posts)) .route("/blog/{id}", get(post_detail)) } /// Runs after every other plugin has booted and the ambient pool /// + model registry are live. The classic "seed on first run" /// idiom - bulk_create is idempotent here because we check count /// first; in production you'd guard this behind a feature flag /// or move it to a separate `seed` management command. fn on_ready(&self, _ctx: &AppContext) -> Result<(), PluginError> { umbral::plugin::block_on_ready(async { let count = Post::objects().count().await?; if count == 0 { Post::objects() .bulk_create(vec![Post { id: 0, title: "Hello".into(), body: "First post.".into(), author_email: "alice@example.com".into(), }]) .await?; } Ok::<_, Box<dyn std::error::Error + Send + Sync>>(()) }) .map_err(|e| -> PluginError { Box::new(std::io::Error::other(e.to_string())) })?; Ok(()) }}Wire it in main.rs:
let app = App::builder() .settings(settings) .database("default", pool) .plugin(blog::BlogPlugin) // model + routes + on_ready .plugin( umbral_rest::RestPlugin::default() .resource(blog::rest_resource()), // REST customisation ) .build()?;Four things that matter in this shape:
- One
.plugin(...)line is enough. The plugin's models flow into the migration registry viaPlugin::models(); you don't also write.model::<Post>().App::builder()walks every registered plugin and collects its models in dependency order..model::<T>()is for models that live inmain.rsitself (no owning plugin) - when in doubt, put the model on the plugin. - The ORM is ambient.
Post::objects().fetch()works inside a route handler with no plumbing. Same call works insideon_ready, inside another plugin's handler, inside a CLI subcommand. The pool was published byApp::build(); everything reads through the sameOnceLock. - REST customisation lives with the plugin.
rest_resource()is a free function the plugin exposes. Adding the second plugin's REST customisation is one more line inmain.rs, not a 30-line builder chain. on_readybridges sync to async. The trait method is sync; sqlx is async;umbral::plugin::block_on_ready(...)is the canonical bridge. It works under a multi-thread#[tokio::main]runtime, under#[tokio::test]'s current-thread runtime, and with no ambient runtime at all - without panicking (a rawHandle::current().block_onwould panic on the current-thread path). Theumbral-rlsplugin uses the same helper.
Next
- The trait surface in detail: The Plugin trait.
- The canonical built-in plugin: Users and passwords (umbral-auth).
- The dependency-inversion rationale:
arch.md §3.