Manual contentsArchitecture ConceptsBrowse 103 chapters
Manual 9 min read

Service Container

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 by TestContainer::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 suprnova::App;

App::singleton(MyConfig {
    timeout_secs: 30,
    retries: 3,
});

let cfg = App::get::<MyConfig>().expect("registered at boot");
println!("{}", cfg.timeout_secs);

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 std::sync::Arc;
use suprnova::App;

let store: Arc<dyn KeyValueStore> = Arc::new(RedisStore::connect(url)?);
App::bind(store);

let store = App::make::<dyn KeyValueStore>().expect("bound at boot");
store.put("hello", b"world").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):

App::factory(|| {
    HttpClient::builder()
        .timeout(Duration::from_secs(30))
        .build()
        .expect("http client config is hand-rolled and known-good")
});

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:
App::singleton_if_absent(DefaultMailDriver::new());

// In your app's bootstrap.rs:
App::singleton(MyCustomMailDriver::new());  // 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 = App::get::<MyConfig>().expect("bound at boot");

// Clone the Arc:
let store: Arc<dyn KeyValueStore> = App::make().expect("bound at boot");

// Same but Result, for the `?` idiom in fallible paths:
let cfg = App::resolve::<MyConfig>()?;
let store = App::resolve_make::<dyn KeyValueStore>()?;

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 App::has::<MyConfig>() {}
if App::has_binding::<dyn KeyValueStore>() {}

Where binding happens

The standard place is src/bootstrap.rs - one function that runs once at boot:

use std::sync::Arc;
use suprnova::App;
use crate::services::{MyService, RealEmailGateway};

pub async fn register() {
    // Plain singletons
    App::singleton(MyAppConfig {
        max_uploads_per_user: 100,
    });

    // Trait-object services
    let gateway: Arc<dyn EmailGateway> = Arc::new(RealEmailGateway::new());
    App::bind(gateway);

    // Lazy services (built on first use)
    App::bind_factory::<dyn HttpClient, _>(|| {
        Arc::new(ReqwestClient::with_timeout(30))
    });
}

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 registry
  • App::boot_services() resolves boot-time dependencies (drivers, encryption keys, etc.) - your services see a fully-booted framework
  • Your bootstrap_fn runs 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 suprnova::App;

// Eager value - serialised once and reused for every Inertia response.
App::inertia_share("appName", "Suprnova");

// Lazy value - resolver runs per response. Use for per-request data
// that needs async work.
App::inertia_share_lazy("locale", || async {
    Ok::<_, suprnova::FrameworkError>(detect_locale().await)
});

// Push a single flash entry onto the per-request flash bag.
App::flash("message", "Saved!");

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 std::sync::Arc;
use suprnova::container::testing::TestContainer;
use suprnova::suprnova_test;

#[suprnova_test]
async fn one_test_binds_a_fake() {
    let _guard = TestContainer::fake();
    TestContainer::bind::<dyn Mailer>(Arc::new(FakeMailer::new()));

    // … this test uses FakeMailer
    // a sibling test running in parallel doesn't see it
}

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 = MyResourcePool::connect(url).await?;
App::bind(Arc::new(pool));

// later:
let pool = App::resolve_make::<MyResourcePool>()?;
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 std::sync::Arc;
use suprnova::container::testing::TestContainer;
use suprnova::suprnova_test;

#[suprnova_test]
async fn order_dispatches_email() {
    let fake = Arc::new(FakeEmailGateway::new());
    let fake_for_assert = Arc::clone(&fake);

    let _guard = TestContainer::fake();
    TestContainer::bind::<dyn EmailGateway>(fake);

    place_order(123).await.expect("place_order succeeds");

    assert_eq!(fake_for_assert.sent_count(), 1);
}

Lazy expensive construction

// Builds the embedding model on first request, not at boot.
App::bind_factory::<dyn EmbeddingModel, _>(|| {
    Arc::new(
        OnnxEmbedding::load_from_disk("/models/all-mini-lm.onnx")
            .expect("embedding model must load"),
    )
});

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