Best Practices
Controllers
Default to the stateless pattern. A unit struct with associated
async fn(Request) -> Response methods is the simplest shape to read
and write. Use #[injectable] + #[api_resource] only when the
controller needs injected services or the full CRUD resource mapping.
Take typed request extractors in the signature instead of calling
req.validated() inside every handler:
pub async fn register(form: RegisterRequest) -> Response { // getting here means validation passed}Return Response directly from the happy path. The Result<T, E>
where both implement IntoResponse pattern is clean for fallible
branches but gets noisy when every branch builds a Response anyway.
Domain organization
Put entities at app/domain/<name>/models/<name>.rs. Re-export
named aliases from models/mod.rs:
pub mod user;
pub use user::{ ActiveModel as UserActiveModel, Column as UserColumn, Entity as User,};Call sites read User::find() / UserColumn::Email.eq(...) — the fact
that SeaORM names it Model inside user.rs stays hidden.
Middleware
Global middleware (via app.use_middleware(...) or a service
provider) for anything that applies to every route: CORS, request
logging, structured tracing headers. Per-group or per-route middleware
for anything specific: auth, abilities, feature flags.
Auth middleware goes before ability middleware. Otherwise
abilities([...]) can’t find the token in request extensions.
app.middleware([auth_middleware::<User>(), abilities(["todos:write"])]) .post("/todos", TodoController::store);One middleware = one concern. Don’t bundle logging + auth + CORS
into a single RequestWrapper. Small middleware compose; bundled
middleware leak coupling.
Configuration
Every tunable lives in a TOML file under config/ — not in
hardcoded constants. Even if the value rarely changes, putting it in a
config file gives you env-override-ability for prod.
Read config via the typed section when the struct is stable, via
config::<T>("key") when it’s a one-off lookup:
// Typed, bound at boot:let cfg = req.resolve::<AppConfig>();
// One-off:let debug: bool = config("app.debug").unwrap_or(false);Don’t re-parse TOML at request time. bind_config::<T>("app") in a
ServiceProvider does it once.
Database
Prefer conn() for the quick case, self.db.conn() when the
controller holds db: Arc<Database> for other reasons. Don’t inject
Database just to reach conn() — the global is there for that.
Every query that could fail should map errors to a 500 envelope, not
.unwrap(). Use Response::internal_error(format!("op failed: {e}"))
during development — swap for a sanitized variant before prod.
Migrations are append-only in production. Never edit an applied
migration — write a new one to alter the schema. migrate:fresh
drops every table and must only be used in development.
Auth tokens
Scope tokens as tightly as the caller needs. ["*"] is the “full
control” shape; save it for first-party clients. Third-party integrations
get named abilities like ["orders:read"] or ["webhooks:write"].
Expire tokens that issue to unknown users. Set
sanctum.expiration in config (minutes) or pass expires_at on each
create_token call. Never-expiring tokens are fine for long-lived
server-to-server integrations; they’re wrong for user sessions.
Verify abilities inline only when the gate can’t be expressed as a
middleware. Most of the time, abilities([...]) on the route is
enough. Inline token.can(...) when the rule depends on the request
body or a DB lookup.
Errors
Fail fast at boot for missing services: expect("register FooServiceProvider"). A missing provider is a programming error, not a
runtime condition.
Never .unwrap() on user input. Validation failures are normal
flow; they should become 422 envelopes, not panics.
Don’t leak internal error strings to clients in prod. Swap
format!("insert failed: {e}") for "failed to create resource" behind
an APP_DEBUG flag before shipping. Logs keep the detail; clients get
the generic message.
Recommended
- Small route files — one surface per file.
- Service providers that do one thing.
- Named migrations (
create_users_table, notmigration_3). - Single-binary deploys.
- Clean
cargo buildbefore committing.
Avoid
- Re-reading config inside hot paths.
- Using
unwrap()where amatchwould surface a 4xx response. - Putting HTTP types into domain code (keep
oxide-http::Requestout ofapp/domain/). - Hand-writing handlers that duplicate
#[api_resource]. If you are writingindex/store/show/update/destroyby hand, use the macro. - Large pull requests that touch framework and app in a single pass. Keep the seam visible.