AppContext and dependency injection
Rust doesn’t let you reach for a mutable global app instance the way Rails or Django can — there’s no ambient current_app to mutate from anywhere. Loco’s answer to “how does a handler get at the database, the cache, the mailer, my own service client?” is a single, cheaply-cloneable struct threaded through the whole app: AppContext. This page explains why that struct has the shape it does, and how SharedStore extends it to things Loco itself doesn’t know about.
AppContext as the one shared-state object
Every handler, background worker, task, and scheduled job in a Loco app receives the same AppContext value — assembled once at boot (see Architecture) and cloned cheaply wherever it’s needed, because most of its fields are already Arc<...>-wrapped or otherwise cheap to clone. It carries eight fields:
#[derive(Clone, FromRef)]pub struct AppContext { pub environment: Environment, #[cfg(feature = "with-db")] pub db: DatabaseConnection, pub queue_provider: Option<Arc<bgworker::Queue>>, pub config: Config, pub mailer: Option<EmailSender>, pub storage: Arc<Storage>, pub cache: Arc<cache::Cache>, pub shared_store: Arc<SharedStore>,}The full field-by-field reference — types, feature gates, purpose — lives in AppContext & prelude. What’s worth explaining here is the design, not the field list:
- One struct, not seven services. Rather than injecting the DB pool, the cache, the mailer, storage, and the queue as five separate pieces of Axum state, Loco bundles them into one
AppContextand derivesFromRefon it. That derive is what lets a handler ask for exactly the piece it needs —State<DatabaseConnection>orState<Arc<cache::Cache>>— while a background worker or the boot sequence can still ask for the whole thing. You get the ergonomics of narrow, single-purpose extraction without the boilerplate of hand-writingFromRefimpls for every field. dbis the only field that’s compiled away, not just empty. Every other field degrades gracefully when unconfigured (Noneformailer/queue_provider, aNulldriver forcache/storage) — an app with no mailer configured still has amailer: Option<EmailSender>field, just set toNone.dbis different: with thewith-dbfeature off, the field doesn’t exist on the struct at all, which is a compile-time way of saying “this deployment shape genuinely has no database,” rather than a runtimeOptiona caller could forget to check.- It’s the same value everywhere. Because
create_contextbuildsAppContextexactly once at boot and every subsystem downstream — routing, middleware, handlers,connect_workers, tasks, the scheduler — receives that same value, there’s no risk of a handler and a background job disagreeing about which DB pool or cache instance is “the real one.” This is also whyHooks::after_context(ctx: AppContext) -> Result<AppContext>(which runs immediately after assembly, before routes are built) is the one hook that can rewrite the context itself — it’s your last and only chance to add something to it before it’s handed out everywhere.
The gap AppContext’s fixed fields can’t fill
AppContext’s eight fields cover what every Loco app needs. They obviously can’t cover what your app needs — a third-party API client, a feature-flag SDK handle, an app-specific cache of precomputed data. Two options exist, and they aren’t in tension, they’re the same design carried into two different lifecycles:
Initializer(see Add middleware) is the install-time extension point: a trait withbefore_run,after_routes, andcheckhooks, used to wire a whole piece of infrastructure into the app (register an AxumExtension, mount a session layer, install a doctor health check).SharedStoreis the storage extension point: a place to actually hold a value of a type Loco has never heard of, so it can be read back out in a handler, a worker, or anywhere elseAppContextreaches.
In practice they compose: you typically construct the value you want to share and call ctx.shared_store.insert(..) from inside Hooks::after_context (the same hook that runs once, right after the context is built), then read it back with the extractor below.
SharedStore: a type-keyed DI container
AppContext.shared_store: Arc<SharedStore> is a small, concurrent, heterogeneous store — internally a DashMap keyed by TypeId, so it can hold one value of any number of distinct 'static + Send + Sync types at once. Its API is deliberately minimal:
| Method | What it does |
|---|---|
insert<T>(&self, val: T) | Store (or overwrite) the value for type T. |
get<T: Clone>(&self) -> Option<T> | Fetch a cloned copy of T, if present. |
get_ref<T>(&self) -> Option<RefGuard<'_, T>> | Fetch a borrowed Deref<Target = T> guard — for types that aren’t Clone, or when a clone would be wasteful. |
remove<T>(&self) -> Option<T> | Take the value back out. |
contains<T>(&self) -> bool | Check presence without touching the value. |
Reading it back: two paths, and a naming hazard to watch for
There are two distinct SharedStore types reachable from loco_rs::prelude, and confusing them is the single easiest mistake to make with this feature:
loco_rs::app::SharedStore— the container type above, held asctx.shared_store.loco_rs::controller::extractor::shared_store::SharedStore<T>(pub T)— an AxumFromRequestParts<AppContext>extractor, also re-exported asSharedStorefrom the prelude, that reaches intoctx.shared_store, clones out aT, and hands it to your handler as a plain argument:
#[debug_handler]pub async fn index( SharedStore(service): SharedStore<MyClonableService>,) -> impl IntoResponse { tracing::info!("api key: {}", service.api_key); format::empty()}If T was never inserted, the extractor rejects the request with Error::InternalServerError — which is the right failure mode for “the app forgot to wire something up,” as opposed to a client-facing 4xx. For a type that isn’t Clone, skip the extractor and reach for ctx.shared_store.get_ref::<T>() directly off an ordinary State<AppContext> extraction instead — you get a reference-counted guard rather than a copy.
Why this shape, instead of a global registry
The alternative designs are familiar from other ecosystems — a service locator singleton, or a compile-time DI container that resolves a dependency graph. Loco deliberately avoids both:
- A global mutable singleton isn’t something safe Rust gives you for free, and reaching for
unsafe/OnceCell-style globals to fake one would undermine the exact guarantee (no data races, no invisible mutation from anywhere) that makes Rust worth using for a server in the first place. - A compile-time DI framework (resolving constructor graphs, macro-generated wiring) adds a second configuration language on top of Rust itself, for a problem
AppContextplusSharedStorealready solves at the cost of oneinsert/getpair.
SharedStore is intentionally closer to “a typed, thread-safe HashMap<TypeId, Box<dyn Any>> you’re handed for free” than a general DI framework — it doesn’t manage lifecycles, doesn’t resolve dependencies between the things you store, and doesn’t enforce a registration order. That’s the trade: less power, but no new mental model to learn, and it composes with ordinary Rust ownership rather than working around it. For most apps, “stash a client in after_context, extract it with SharedStore<T>” is the entire pattern.
See the AppContext & prelude reference for the exhaustive field/method signatures, and Add middleware for a related worked example (a custom MiddlewareLayer/Initializer-style extension wired into the app).