Skip to content

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.

  • Small route files — one surface per file.
  • Service providers that do one thing.
  • Named migrations (create_users_table, not migration_3).
  • Single-binary deploys.
  • Clean cargo build before committing.

Avoid

  • Re-reading config inside hot paths.
  • Using unwrap() where a match would surface a 4xx response.
  • Putting HTTP types into domain code (keep oxide-http::Request out of app/domain/).
  • Hand-writing handlers that duplicate #[api_resource]. If you are writing index / store / show / update / destroy by hand, use the macro.
  • Large pull requests that touch framework and app in a single pass. Keep the seam visible.