The bass line for your services.
In baroque music, the basso continuo is the part that plays through the
whole piece: soloists enter and leave, the continuo never stops. This crate
is that part for your application — the typed registry and lifecycle that
carries every service from the first note (boot) to the last (finalize).
The dependency injection core for multi-service Rust applications — every framework ships one buried inside it; continuo is that core, shipped alone. Tokio-native lifecycle and service composition, no framework attached.
continuo gives your app one place to register services, boot them,
reload them, run their background tasks, resolve them by Rust type, and shut
them down gracefully:
- register typed services once
- boot / validate / reload / finalize them in deterministic dependency order
- resolve them later by concrete Rust type
- remove the need for ad-hoc runtime injection
- spawn long-running async runnable providers
- drain accepted work through graceful shutdown gates
- publish typed in-process lifecycle events with the
eventsfeature
This is the whole application:
let state = new;
// The registry — the score: who plays, in what order.
// It owns the provider hooks: validate / boot / reload / finalize.
let registry = state.registry_ref;
registry
.insert
.insert
.insert; // even Ctrl+C is a service
registry.validate_all?;
registry.boot_all.await?; // dependency-ordered
// The runtime — the performance: the live runnable tasks.
let mut runtime = default;
runtime.spawn_all;
runtime.wait_until_shutdown.await?;
runtime.drain.await?; // runnables end themselves
// Back to the registry for the last note.
registry.finalize_all.await?; // release named resources
Three inserts — everything else follows from the lifecycle contract; main
composes providers and walks the lifecycle, nothing more. The full working
version is examples/counter.rs.
The core idea:
Registry<AppState> — the score: hooks in dependency order
├─ CounterService -> Provider + Reloadable + Finalizable
├─ HttpService -> Provider + Runnable
└─ SignalService -> Provider + Runnable
Runtime<AppState> — the performance: live runnable tasks
└─ spawns every provider that exposes Runnable
Features
Default features are:
= ["registry"]
= ["registry", "support", "events"]
registryaddsregistry_ref()toSharedStatefor states that carryRegistry<Self>. TheRegistrytype itself is always available.eventsenablesLifecycleBus, addsevents()toSharedState, and enables thedashmapdependency.supportenablesGate,Permit,GuardGroup, andGuard.
Your State
The crate does not own your app state. Your state only needs to implement
SharedState. If you want registry.reload_all(&state).await, implement
ReloadState too.
use async_trait;
use Arc;
use CancellationToken;
use LifecycleBus;
use ;
;
The Registry
Registry<S> is the main piece. It stores Arc<T> values by TypeId, while
also treating them as lifecycle providers.
That means one object can be all of these at once:
- a concrete service you can resolve later:
registry.resolve::<DbService>() - a lifecycle participant:
boot,validate - a hot-reload participant:
Reloadable - a long-running background task:
Runnable - an end-of-life resource releaser:
Finalizable
use Arc;
let state = new;
state
.registry_ref
.insert
.insert;
state.registry_ref.validate_all?;
state.registry_ref.boot_all.await?;
let db = state.registry_ref..expect;
Register once. Boot, reload, run, and resolve by type.
The magic is boring: Any + TypeId
There is no string table, no interface token, no reflection, and no macro behind the registry. The whole trick is two of the most unglamorous items in the standard library:
use ;
- The key of a service is its type.
insert(Arc<C>)stores underTypeId::of::<C>()— an identifier the compiler mints, globally unique, impossible to typo, impossible to collide. There is no naming convention because there are no names. - One allocation, two views. The same
Arcis stored type-erased twice: asArc<dyn Any + Send + Sync>for typed recovery and asArc<dyn Provider<S>>for the lifecycle walk. No copies — both views share the original allocation. resolve::<T>()isArc::downcast, not reflection. One integer comparison. It can only ever return the exact type that was inserted, orNone. A mis-typed handle cannot exist, so the "wrong thing under this key, cast explodes at a distance" class of container bugs is deleted, not handled.- Asking for a non-service type does not compile.
resolveis bounded byT: Provider<S>— the request itself is type-checked.
Dynamic containers usually buy their flexibility with ambiguity:
string keys that drift, tokens that need registering, casts that fail
far from their cause. Rust's famously strict, famously boring type
system turns out to be the fun part — it is the service catalog,
and std::any is all the runtime it needs.
Providers
A provider is any service that wants to join the application lifecycle.
use async_trait;
use ;
;
Lifecycle Ordering
Lifecycle order is deterministic and type-aware. Providers can express concrete
dependencies with ProviderOrder::before::<T>() / ProviderOrder::after::<T>()
instead of relying on registration order or magic priority numbers.
The same lifecycle plan is used for:
validate_allboot_allreload_allfinalize_all, in reverse order (only providers exposingFinalizable)
Reload skips providers that are not Reloadable, but it keeps the same relative
dependency order as boot. If a dependency cycle is introduced, the registry
returns an error before running the lifecycle phase.
use ;
;
;
Registration order can now stay ergonomic:
use Arc;
state.registry_ref.insert;
state.registry_ref.insert;
let names = state.registry_ref.lifecycle_names?;
println!;
state.registry_ref.boot_all.await?;
// DbService boots before CacheService because CacheService says:
// ProviderOrder::new().after::<DbService>()
boot_priority() and Reloadable::priority() are still available as coarse
tie-breakers among otherwise-ready providers. Prefer ProviderOrder for real
dependencies. run_priority() controls only runtime task spawn order.
Runnable Providers
Long-running loops live in Runnable::run(), not in boot().
Runnable::run() is an async trait method and receives self: Arc<Self>, so
providers can be spawned without returning a boxed task future.
Graceful teardown belongs inside the same future: observe the shutdown signal, drain your in-flight work, and return — do not split the stop path into a separate hook.
use Arc;
use async_trait;
use ;
Then the runtime starts every runnable provider:
use ;
let state = new;
let mut runtime = default;
runtime.spawn_all;
state.initiate_shutdown;
runtime.wait_until_shutdown.await?;
runtime.drain.await?;
# Ok::
Reloadable Providers
Reload is a capability, not a second registry.
use async_trait;
use ;
Use registry.reload_one("db", &state).await for targeted reloads or
registry.reload_all(&state).await for full reloads.
reload_all first calls ReloadState::reload() on your state, then walks the
same lifecycle order used by boot_all and calls Reloadable::reload() on
reloadable providers.
Finalizable Providers
Finalize is the last note: it releases non-running resources after the
runnable tasks have drained — externally named things whose stale presence
would break the next boot (shm segments, lock files). It is not a stop
mechanism; runnables end their own futures inside run().
use async_trait;
use ;
registry.finalize_all(&state).await walks the lifecycle plan in reverse and
calls finalize() on every provider that exposes the capability. Failures are
logged and do not stop the remaining finalizers.
Gates
The support feature enables optional runtime support tools. They are not
required by Registry or Runtime, but they are useful when implementing
servers, connection loops, and request handlers.
Gate is a graceful shutdown admission/drain tool. It is inspired by axum's
server graceful-shutdown pattern, but lifted out of HTTP.
It can:
- reject new work after graceful shutdown starts
- track accepted in-flight work with
Permit - wait until all permits drop
- force shutdown after a grace period
- optionally apply simple max-in-flight admission control
use Duration;
use Gate;
let gate = new;
let permit = gate.enter.await?;
gate.graceful_shutdown;
gate.wait_all_done.await;
# Ok::
GuardGroup is a smaller RAII in-flight counter for places where you only
need “count active work and wait until zero” without admission control.
Lifecycle Events
The events feature enables LifecycleBus, a typed process-local event bus.
Use it when services need a loose in-process signal without depending on each
other directly.
use LifecycleBus;
;
let bus = new;
let mut rx = bus.;
bus.emit;
Example
One single-file example carries the whole story —
examples/counter.rs:
# Ctrl+C — graceful drain, then finalize persists the count
One log tells the whole story (two browsers open — ports 51422 and 51423 each keep their own per-connection count; the application-scoped total is shared):
counter booted: starting fresh from 0 (no counter.txt yet)
signals: ready (pid 73942)
signals: reload with `kill -HUP 73942` or GET /reload — resets the counter
signals: stop with Ctrl+C or `kill -INT 73942` — drains, then finalizes
http listening on http://127.0.0.1:3000/hit (Ctrl+C to stop)
http: hit #1 (client port 51422 → its #1)
http: hit #2 (client port 51423 → its #1)
counter reloaded: reset to 0 ← SIGHUP or GET /reload
http: /reload — providers reloaded
http: hit #1 (client port 51423 → its #1)
http: hit #2 (client port 51422 → its #1)
http: hit #3 (client port 51423 → its #2)
^C
signals: Ctrl+C — initiating shutdown
http drained and stopped
counter finalize: persisted final value = 3 -> counter.txt
🏁 all services finished gracefully
Run it again — the count survives the process:
counter booted: starting from 3 (restored from counter.txt)
http: hit #4 (client port 51425 → its #1)
Three providers, every capability doing real work:
CounterServiceis application-scoped: one instance for the process lifetime, shared by every request.boot()restores the count fromcounter.txtwithtokio::fs,reload()resets it to 0, andFinalizable::finalize()persists the final value after the runnables drained — the count survives restarts. It also keeps a per-connection count (peer address via axum'sConnectInfo) so the scope contrast is visible: connections diverge, the total is shared.HttpServiceboots after its dependency (ProviderOrder::new().after::<CounterService>()), resolves it from the registry once, while building the router, and hands it to handlers as an axumExtension— no per-request container lookups, no lookups on the request path. The router state is theAppStateitself, so handlers like/reloadcan callreload_allstraight from a request. The server serves and drains insideRunnable::run().SignalService— even signal handling is a provider: Ctrl+C maps to shutdown, SIGHUP toreload_all.maincontains no nakedtokio::spawn; it only composes providers and walks the lifecycle.
axum is a dev-dependency only; the transport is an implementation detail.
Dependencies
The dependency set is intentionally small and canonical for Tokio-based services:
= "0.1"
= "6"
= { = "1", = ["macros", "rt", "sync", "time"] }
= "0.7"
= "0.1"
dashmap is only required when the events feature is enabled.
License
Licensed under either of:
- MIT license (LICENSE-MIT)
- Apache License, Version 2.0 (LICENSE-APACHE)