continuo 0.1.0

Dependency injection core for multi-service Rust applications — typed provider registry and Tokio lifecycle runtime: dependency-ordered boot, reload, runnable tasks, graceful drain, and finalization.
Documentation

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 events feature

This is the whole application:

let state = AppState::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(Arc::new(CounterService::new()))
    .insert(Arc::new(HttpService::new(addr)))
    .insert(Arc::new(SignalService));   // even Ctrl+C is a service

registry.validate_all(&state)?;
registry.boot_all(&state).await?;       // dependency-ordered

// The runtime — the performance: the live runnable tasks.
let mut runtime = Runtime::<AppState>::default();
runtime.spawn_all(registry, state.clone());
runtime.wait_until_shutdown(&state).await?;
runtime.drain().await?;                 // runnables end themselves

// Back to the registry for the last note.
registry.finalize_all(&state).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:

default = ["registry"]
full = ["registry", "support", "events"]
  • registry adds registry_ref() to SharedState for states that carry Registry<Self>. The Registry type itself is always available.
  • events enables LifecycleBus, adds events() to SharedState, and enables the dashmap dependency.
  • support enables Gate, Permit, GuardGroup, and Guard.

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::async_trait;
use std::sync::Arc;
use tokio_util::sync::CancellationToken;
use continuo::LifecycleBus;
use continuo::{Registry, ReloadState, Result, SharedState};

#[derive(Clone)]
pub struct AppState(Arc<Inner>);

struct Inner {
    shutdown: CancellationToken,
    registry: Registry<AppState>,
    events: LifecycleBus,
}

impl Default for AppState {
    fn default() -> Self {
        Self(Arc::new(Inner {
            shutdown: CancellationToken::new(),
            registry: Registry::default(),
            events: LifecycleBus::new(),
        }))
    }
}

impl AppState {
    pub fn new() -> Self {
        Self::default()
    }

    pub fn on_shutdown(&self) -> impl std::future::Future<Output = ()> + '_ {
        self.0.shutdown.cancelled()
    }
}

impl SharedState for AppState {
    fn shutdown_token(&self) -> CancellationToken {
        self.0.shutdown.clone()
    }

    fn registry_ref(&self) -> &Registry<Self> {
        &self.0.registry
    }

    fn events(&self) -> &LifecycleBus {
        &self.0.events
    }
}

#[async_trait]
impl ReloadState for AppState {
    async fn reload(&self) -> Result<()> {
        Ok(())
    }
}

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

let state = AppState::new();
state
    .registry_ref()
    .insert(Arc::new(DbService::new()))
    .insert(Arc::new(CacheService::new()));

state.registry_ref().validate_all(&state)?;
state.registry_ref().boot_all(&state).await?;

let db = state.registry_ref().resolve::<DbService>().expect("DbService registered");

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 std::any::{Any, TypeId};
  • The key of a service is its type. insert(Arc<C>) stores under TypeId::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 Arc is stored type-erased twice: as Arc<dyn Any + Send + Sync> for typed recovery and as Arc<dyn Provider<S>> for the lifecycle walk. No copies — both views share the original allocation.
  • resolve::<T>() is Arc::downcast, not reflection. One integer comparison. It can only ever return the exact type that was inserted, or None. 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. resolve is bounded by T: 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::async_trait;
use continuo::{Provider, Result};

pub struct DbService;

#[async_trait]
impl Provider<AppState> for DbService {
    fn name(&self) -> &'static str {
        "db"
    }

    async fn boot(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Open pools, read config, build snapshots, publish ready state.
        Ok(())
    }

    fn validate(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Cheap preflight checks before boot.
        Ok(())
    }
}

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_all
  • boot_all
  • reload_all
  • finalize_all, in reverse order (only providers exposing Finalizable)

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 continuo::{Provider, ProviderOrder};

pub struct DbService;
pub struct CacheService;

impl Provider<AppState> for CacheService {
    fn order(&self) -> ProviderOrder {
        ProviderOrder::new().after::<DbService>()
    }
}

Registration order can now stay ergonomic:

use std::sync::Arc;

state.registry_ref().insert(Arc::new(CacheService));
state.registry_ref().insert(Arc::new(DbService));

let names = state.registry_ref().lifecycle_names()?;
println!("Lifecycle order: {}", names.join(" -> "));

state.registry_ref().boot_all(&state).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 std::sync::Arc;
use async_trait::async_trait;
use continuo::{Provider, Result, Runnable};

#[async_trait]
impl Runnable<AppState> for CacheService {
    async fn run(
        self: Arc<Self>,
        state: AppState
    ) -> Result<()> {
        state.on_shutdown().await;
        Ok(())
    }
}

impl Provider<AppState> for CacheService {
    fn as_runnable(self: Arc<Self>) -> Option<Arc<dyn Runnable<AppState>>> {
        Some(self)
    }
}

Then the runtime starts every runnable provider:

use continuo::{Runtime, SharedState};

let state = AppState::new();
let mut runtime = Runtime::<AppState>::default();

runtime.spawn_all(state.registry_ref(), state.clone());
state.initiate_shutdown();
runtime.wait_until_shutdown(&state).await?;
runtime.drain().await?;
# Ok::<(), continuo::Error>(())

Reloadable Providers

Reload is a capability, not a second registry.

use async_trait::async_trait;
use continuo::{Provider, Reloadable, Result};

#[async_trait]
impl Reloadable<AppState> for DbService {
    async fn reload(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Rebuild runtime snapshot and atomically publish it.
        Ok(())
    }
}

impl Provider<AppState> for DbService {
    fn as_reloadable(&self) -> Option<&dyn Reloadable<AppState>> {
        Some(self)
    }
}

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::async_trait;
use continuo::{Finalizable, Provider, Result};

#[async_trait]
impl Finalizable<AppState> for DbService {
    async fn finalize(
        &self,
        state: &AppState
    ) -> Result<()> {
        // Releases non-running resources after runnables drain.
        Ok(())
    }
}

impl Provider<AppState> for DbService {
    fn as_finalizable(&self) -> Option<&dyn Finalizable<AppState>> {
        Some(self)
    }
}

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 std::time::Duration;
use continuo::Gate;

let gate = Gate::new(Some(1024), Duration::from_millis(100));
let permit = gate.enter().await?;

gate.graceful_shutdown(Some(Duration::from_secs(30)));
gate.wait_all_done().await;
# Ok::<(), continuo::gate::Error>(())

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 continuo::LifecycleBus;

#[derive(Clone, Debug)]
struct ConfigReloaded;

let bus = LifecycleBus::new();
let mut rx = bus.subscribe::<ConfigReloaded>();
bus.emit(ConfigReloaded);

Example

One single-file example carries the whole story — examples/counter.rs:

cargo run --example counter
curl http://127.0.0.1:3000/hit        # repeat — the count grows
curl -L http://127.0.0.1:3000/reload  # reload over HTTP — resets to 0
kill -HUP <pid>                       # same reload, via signal
# 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:

  • CounterService is application-scoped: one instance for the process lifetime, shared by every request. boot() restores the count from counter.txt with tokio::fs, reload() resets it to 0, and Finalizable::finalize() persists the final value after the runnables drained — the count survives restarts. It also keeps a per-connection count (peer address via axum's ConnectInfo) so the scope contrast is visible: connections diverge, the total is shared.
  • HttpService boots after its dependency (ProviderOrder::new().after::<CounterService>()), resolves it from the registry once, while building the router, and hands it to handlers as an axum Extension — no per-request container lookups, no lookups on the request path. The router state is the AppState itself, so handlers like /reload can call reload_all straight from a request. The server serves and drains inside Runnable::run().
  • SignalService — even signal handling is a provider: Ctrl+C maps to shutdown, SIGHUP to reload_all. main contains no naked tokio::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:

async-trait = "0.1"
dashmap = "6"
tokio = { version = "1", features = ["macros", "rt", "sync", "time"] }
tokio-util = "0.7"
tracing = "0.1"

dashmap is only required when the events feature is enabled.

License

Licensed under either of: