The container is where Suprnova holds your application's services -
the DB connection pool, the mail driver, your Arc<MyService>. You
bind values into it at boot time and resolve them in handlers and
workers. It's the Suprnova equivalent of Laravel's service container,
with one important difference: lookup is task-local first, so tests
running concurrently don't see each other's bindings.
The two pieces
| Type | Role |
|---|---|
Container |
The underlying registry: holds bindings, factories, and singletons |
App |
The global facade you actually call - App::bind, App::get, etc. |
You almost always call App::* rather than constructing a
Container directly. The container is plumbing; the App facade
is the API.
Lookup order
Every App::get / App::make call checks three layers in order:
task-local
│
▼ (miss)
thread-local
│
▼ (miss)
global
│
▼ (miss)
None
The first two layers hold test overrides. The third holds your application's bindings. This matters because:
- Tests use task-local or thread-local -
let _g = TestContainer::fake();followed byTestContainer::bind(...)binds inside one thread without touching the global container, so parallel tests don't bleed services into each other. The guard clears the test container when it drops.TestContainer::scopedoes the same for one async task, and survives a move between worker threads. - App-wide services go through global - bound once at boot,
resolved everywhere.
App::bind,App::singleton,App::factoryandApp::scopedall write to this layer. - A test override wins - if a test binds a type in the first two layers, that binding answers, whatever the global layer holds for the same type.
A scoped binding sits in the global layer, but its value does not. The container builds the value inside the scope of the current unit of work. See Scoped bindings.
You rarely think about which layer a binding lives in - App::get
finds it wherever it lives. The model only matters when something
behaves unexpectedly under concurrency, and then the
Testing chapter has the detail.
Binding a value
These are the ways to put something into the container, depending on what you have:
App::singleton(value) - owned, cloned at lookup
For any T: Any + Send + Sync + 'static value that should live
forever. The Clone bound is on the getter (App::get), not the
binding - the value is stored once inside an Arc and cloned out of
that Arc on each get:
use App;
singleton;
let cfg = .expect;
println!;
The value is stored once; App::get::<MyConfig>() returns a clone.
Use this for plain config-shaped data that's cheap to clone.
App::bind(Arc<T>) - for traits and shared services
For trait objects or anything you want behind an Arc:
use Arc;
use App;
let store: = new;
bind;
let store = .expect;
store.put.await?;
App::make::<T>() returns the Arc<T> clone (cheap atomic refcount
bump). Use this for any service shared across threads, especially
trait objects.
App::factory(|| { … }) - built on demand
When constructing the value should happen at first use (or every time):
factory;
App::factory registers a concrete-type factory (Fn() -> T);
App::bind_factory registers a trait-object factory
(Fn() -> Arc<T>). Neither closure returns Result - handle
construction failure inside the closure (panic at boot, or build a
sentinel value) or use a regular App::singleton / App::bind after
constructing the value yourself with ?. Both invoke the closure
outside any container lock, so a factory that re-enters the container
won't deadlock and an expensive constructor won't block other bindings.
App::scoped and App::bind_scoped - one value per unit of work
Some services belong to one request, and the next request must not see them: the database handle of the current tenant, or an API client bound to the caller. A singleton is one value for the whole process, so it would leak between requests. A factory builds a new value on every resolution, so it can't share one handle across a request.
A scoped binding sits between the two. You register a factory once. The container runs it at most once in a scope, at the first resolution, and returns the same value on every later resolution in that scope. When the scope ends, the container drops the value. If nothing resolves the binding in a scope, the factory never runs.
App::scoped registers a concrete-type factory (Fn() -> T). Resolve
it with App::get or App::resolve, which clone the value, so
register an Arc<T> when callers must share one instance.
App::bind_scoped registers a trait-object factory (Fn() -> Arc<T>).
Resolve it with App::make or App::resolve_make:
use Arc;
use ;
// In bootstrap: the factory reads the tenant from the request's context.
scoped;
// Trait-object form, resolved with `App::make`:
;
// In a handler or a service it calls: built on the first call, reused
// on the next, dropped when the request ends.
async
The factory runs with no container lock held, so it can resolve other bindings, scoped ones included. If two tasks that share a scope resolve the same binding at the same moment, the factory runs once. The second task waits for the value on its own thread, which blocks that thread. A factory must therefore be quick and must not block on I/O.
A cycle of scoped factories is an error. A factory that resolves its own type, directly or through other scoped factories, gets an error instead of recursing or waiting without end. This holds in one task and across the tasks of one scope.
Which units of work get a scope
The framework opens a scope for each unit of work that it runs. A unit of work has values of its own, so a job never sees the values of the request that dispatched it.
| Unit of work | Scope |
|---|---|
| HTTP request, including a WebSocket upgrade request | One per request |
| WebSocket session | One per session, separate from the upgrade request |
| Attempt of a queued job | One per attempt, so a retry starts fresh |
| Queued event listener | One per attempt of the listener |
| Scheduled task run | One per run |
| Workflow run | One per claimed run. The steps of the run share it |
| Supervisor run | One per run, so a restart starts fresh |
| Console command | One per command |
Three kinds of work belong to a request but run after it. They share the scope of the request, and the scope ends after they finish:
- An after-commit callback (see Database).
- A hook that runs after the response, such as a terminable middleware (see Middleware).
- The body of a streamed response, such as
HttpResponse::sse,stream_bytesorstream_json. The stream resolves the scoped values of its request, and the body keeps the scope until it ends or is dropped.
Scopes in your own code
A task that you start with a bare tokio::spawn has no scope, even when
you spawn it from a request. Three helpers on App cover the cases:
App::run_scoped(future)runs a future in a new scope of its own and returns its output. Use it for work the framework does not run, such as your own worker loop or a test. Inside another scope it opens a nested scope that does not see the outer values.App::in_current_scope(future)wraps a future so that it runs in the scope of the caller. The container captures the scope when you call it, so you can pass the result to any spawn helper.App::spawn_scoped(future)isApp::in_current_scopefollowed bytokio::spawn.
A spawned task shares the values of the scope, so a binding that the task
resolves first is the value the request sees afterward. The scope ends
when the last future that holds it ends. Outside a scope, in_current_scope
and spawn_scoped behave like the plain future and tokio::spawn.
use App;
// Inside a handler: the task sees the request's `dyn AuditLog`.
let handle = spawn_scoped;
// Only the container scope follows the task. To carry the request id too,
// hand the wrapped future to `spawn_with_request_id`:
let handle = spawn_with_request_id;
Outside a scope
A scoped binding never builds a value that lives for the process. If you resolve one where no scope is active:
App::resolveandApp::resolve_makereturn an error. Its message names the type and says that the binding is scoped.App::getandApp::makelog a warning and returnNone.
Order of resolution
The container looks up a type in this order:
- A test override in the task-local layer.
- A test override in the thread-local layer.
- The global layer. A scoped binding resolves in the current scope.
A test override wins over a scoped binding of the same type. A type has
one registration in the global layer, so the last call wins: a
singleton, factory or scoped call for a type replaces an earlier
one for the same type.
App::*_if_absent(value) - boot-order-friendly registration
Sometimes a default service is registered by a service crate, and the
app wants to override it only when present. The _if_absent variants
let you register a default that won't clobber an existing binding:
// Inside a starter or library crate:
singleton_if_absent;
// In your app's bootstrap.rs:
singleton; // wins because it ran later
bind_if_absent, singleton_if_absent, and the factory variants all
return bool - true if they actually inserted, false if there
was already a binding.
Resolving a value
Two read methods, plus their Result-returning siblings:
// Clone the bound value out:
let cfg: MyConfig = .expect;
// Clone the Arc:
let store: = make.expect;
// Same but Result, for the `?` idiom in fallible paths:
let cfg = ?;
let store = ?;
resolve and resolve_make return
Result<_, FrameworkError> (specifically the ServiceNotFound
variant when the lookup misses) - useful in handler paths where a
missing service should surface as a 500 with a proper log, not a panic.
A scoped binding
that cannot resolve is a different error: resolve returns
FrameworkError::Internal, and get and make log a warning and
return None.
Membership checks (rarely needed):
if
if
Where binding happens
The standard place is src/bootstrap.rs - one function that runs
once at boot:
use Arc;
use App;
use crate;
pub async
The function name register matches the scaffold default (src/bootstrap.rs::register); the return type is (), not Result. Bind errors that happen during boot (e.g. driver connect failures) should propagate via the driver/service constructor, not from register itself - see Application Bootstrap for the full boot wiring.
The framework also calls into the container itself during boot:
App::init()runs first, initialising the registryApp::boot_services()resolves boot-time dependencies (drivers, encryption keys, etc.) - your services see a fully-booted framework- Your
bootstrap_fnruns after that, so it can rely on the framework's services being available
See Application Bootstrap for the full boot order.
Inertia shared data
The container is also where Inertia shared data lives. Three convenience APIs make that explicit:
use App;
// Eager value - serialised once and reused for every Inertia response.
inertia_share;
// Lazy value - resolver runs per response. Use for per-request data
// that needs async work.
inertia_share_lazy;
// Push a single flash entry onto the per-request flash bag.
flash;
These read from Container::inertia() which returns
&Arc<InertiaRegistry> - you can interact with it directly if you
need lower-level access. See Inertia / Frontend for
how the shared data ends up in the page response.
Why three layers?
The task-local → thread-local → global cascade exists for one reason: isolation under concurrency. Two things benefit:
Per-test isolation. A test that binds a fake mail driver should
not see a fake bound by a sibling test. TestContainer::fake()
returns a thread-local guard, and TestContainer::bind /
TestContainer::singleton route writes into the active scope.
Parallel tests stay hermetic:
use Arc;
use TestContainer;
use suprnova_test;
async
For multi-thread tokio runtimes - where the future may migrate between
worker threads - use TestContainer::scope(async { ... }) instead;
that installs a task-local override that survives the migration.
Override-at-boot. Application code can override defaults registered
by library crates. The _if_absent variants and the layered lookup
combine to give library crates clean default-registration without
fighting application overrides.
The layers do not isolate requests from each other. A binding in the global layer is one value for the whole process. To keep a value inside one request, use a scoped binding.
Common patterns
Bind a struct holding the DB pool
You almost never do this directly - the framework binds the DB pool itself. But if you have your own subsystem with an expensive shared resource:
let pool = connect.await?;
bind;
// later:
let pool = ?;
let conn = pool.checkout.await?;
App::make returns Option<Arc<T>> and pairs with .expect(...); App::resolve_make returns Result<Arc<T>, FrameworkError::ServiceNotFound> and pairs with ? in fallible code. Use the one that matches your caller's error story.
Swap a default for a fake in tests
use Arc;
use TestContainer;
use suprnova_test;
async
Lazy expensive construction
// Builds the embedding model on first request, not at boot.
;
For fallible construction that needs to surface a structured error to
the operator, build the value yourself in bootstrap() with ? and
call App::bind(...) once it's ready.
Why Suprnova diverges
Laravel's container has one global scope - bindings are global, and
isolating between tests requires setUp / tearDown discipline plus
the framework's per-test database transaction. PHP's request-per-process
model makes this safe-by-accident: a fresh process per request means
the container is reset every time.
Rust's process model is the opposite - one process serves many concurrent requests on many threads. A global-only container would mean a test in one thread can see a fake bound by another, or a request could see another request's per-request data. Suprnova answers both problems:
- The three-layer cascade keeps tests apart: a task-local layer and a thread-local layer hold test overrides, and the global layer holds app-wide services.
- Scoped bindings keep requests apart.
App::scopedis the Suprnova form of Laravel Octane'sscoped()binding: the container builds the value once for one unit of work and drops it when that unit ends.
The container API is the same as Laravel's; the lookup machinery is different because the runtime is different.
Next
- Application Bootstrap - where the binding code goes
- Configuration - typed config registration alongside services
- Testing -
TestContainer::fakeand#[suprnova_test] - Lock Policy - why poisoned-lock recovery matters in a container-backed application
