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
This matters because:
- Per-request state goes through task-local - Inertia shared data, flash bag, request id. Each request gets its own layer, transparently.
- Tests use 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. - App-wide services go through global - bound once at boot, resolved everywhere.
You rarely think about which layer a binding lives in - App::bind
puts it where it makes sense, and 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
Five 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::*_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.
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. Three things benefit:
Per-request isolation. Inertia's flash bag is bound per-request via the task-local layer. Two concurrent requests don't see each other's flash because their task-local containers don't overlap. The binding evaporates when the request's task ends.
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.
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. That's why Suprnova has the three-layer cascade: task-local for per-request, thread-local for per-test, global for app-wide.
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
