Skip to content
shmyPublic

About

A modular application composer for Rust.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

modrun

crates.io docs.rs

English | 简体中文

A modular application composer for Rust.

[dependencies]
modrun = "1.0"

Build large Tokio services as domain-oriented modules with constructor injection, explicit module boundaries, and coordinated lifecycle — wired once at the composition root.

Requires Rust 1.85 or newer (edition 2024). This is not a general-purpose DI container: there are no string qualifiers, no request-scoped objects, no annotations, no auto-scanning, and no get<T>() after the graph has been built. Two of the same type use newtypes; swap test doubles with supply at the composition root. API stable since 1.0.0 — see CONTRIBUTING.md for stability and API boundaries.

If you have written a main that builds a config, then a pool, then a repo, then a service, then a server — and a shutdown path that has to unwind all of it in the right order — modrun is that main, written once.

use modrun::{Hook, Lifecycle, Modrun};

#[derive(Clone)]
struct Config {
    port: u16,
}

#[derive(Clone)]
struct Server {
    cfg: Config,
}

impl Hook for Server {
    async fn on_start(&mut self) -> modrun::Result<()> {
        println!("listening on {}", self.cfg.port);
        Ok(())
    }

    async fn on_stop(&mut self) -> modrun::Result<()> {
        println!("goodbye");
        Ok(())
    }
}

fn new_config() -> Config {
    Config { port: 8080 }
}

fn new_server(cfg: Config) -> Server {
    Server { cfg }
}

fn boot(lc: Lifecycle, server: Server) -> modrun::Result<()> {
    lc.append(server)
}

#[tokio::main]
async fn main() -> modrun::Result<()> {
    Modrun::builder()
        .provide(new_config)
        .provide(new_server)
        .invoke(boot)
        .run()
        .await
}

run() builds the graph, runs every OnStart hook, waits for an OS signal or Shutdowner, then runs every OnStop hook in reverse. With the default signal feature, handlers are installed at the start of run() on Unix (Ctrl-C / SIGTERM) and Windows (Ctrl-C / Ctrl-Break / Ctrl-Close / Ctrl-Shutdown); on other targets only Shutdowner unblocks run(). A shutdown request during build or start cancels that phase and unwinds hooks that already started, plus any stop-only hooks already registered (even if OnStart never ran). A timeout or hook failure still returns an error; a concurrent shutdown does not turn that into Ok(()). Disable the default signal feature if you only use start() or wait on Shutdowner::wait yourself.

Concepts

Five wiring verbs

Most applications only need five registration concepts. Pick the method that matches your function signature (sync / async / fallible) — see the API matrix below.

Entry points — both are fluent wiring builders:

Level Start with Then
Composition root Modrun::builder() .module(...), .invoke(boot), .run() / .start()
Domain module Module::builder("name") .provide*, .invoke(...), return via .module(...)
Verb Role
provide Register a constructor; dependencies come from function parameters
supply Hand in a value you already have; skip the constructor
invoke Pull the graph; invoker parameters are the roots of what gets built
module Group domain wiring under a name with a private scope
provide_group Contribute one member to a Group<T> aggregate

API matrix

How to pick a registration method (application code only; no *_dyn):

Sync Result Async Async Result
Singleton provide provide provide_result provide_async provide_result_async
Singleton, module-private provide_private provide_result_private provide_async_private provide_result_async_private
Group member provide_group provide_group provide_group_result provide_group_async provide_group_result_async
Pre-built value supply / supply_private — — —
Graph root invoke invoke (also accepts Result) (same) invoke_async (same)

invoke does not use _result suffixes: a fallible invoker returns Result<(), E> from plain invoke / invoke_async. Only constructors split provide vs provide_result at compile time.

Only ModrunBuilder: init_group, require_group, supply_group. When at least one module calls provide_group, an invoker that depends on Group<T> usually does not need init_group — use init_group / require_group only for empty groups or when you must fail the build if no member registered.

Common patterns

Three shapes cover most apps. Each maps to an example:

Pattern When Example
Domain module Hide internal types; expose only services basic — provide_private + public provide + invoke
Group Many modules contribute the same element type handlers — provide_group → inject Group<T> at root
Wrapper Metrics, naming, timeouts without a decorate API wrap — compose in one ctor, or provide_private + newtype

Domain module — private repo, public service, module-local invoker:

fn user_domain() -> Module {
    Module::builder("user")
        .provide_private(new_user_repo)
        .provide(new_user_service)
        .invoke(register_user_hooks)
}

Modrun::builder().module(user_domain()).invoke(boot).run().await

Group — plugins, routes, handlers from many modules:

Modrun::builder()
    .module(Module::builder("user").provide_group(user_routes))
    .module(Module::builder("order").provide_group(order_routes))
    .invoke(|routes: Group<Route>| mount(routes))

Wrapper — see Cross-cutting concerns and wrap.rs.

Replaceable dependencies and test doubles belong at the composition root with supply, not inside a module — see swap.

Provide variants

Pick the method that matches your constructor signature — not a separate DI concept:

Method Constructor
provide fn(...) -> T
provide_result fn(...) -> Result<T, E>
provide_async async fn(...) -> T
provide_result_async async fn(...) -> Result<T, E>

Returning Result from plain provide is a compile error (modrun points you at provide_result). Group members follow the same four-way split with a provide_group_* prefix. Type-erased provide_dyn / invoke_dyn / provide_group_dyn live under modrun::__wiring for wrapper libraries only (#[doc(hidden)], not shown in application docs).

Cross-cutting concerns (wrapper constructors)

modrun will not add a decorate API. Horizontal concerns (metrics, named loggers, timeouts) are ordinary Rust functions. Each type can only be provided once — compose wrappers inside one constructor, or return a newtype / service struct from a module (provide_private for the raw value, public provide for the wrapped type):

use modrun::{Modrun, Module};

#[derive(Clone, PartialEq, Eq)]
struct Logger { name: &'static str }

#[derive(Clone, PartialEq, Eq)]
struct AppLogger(Logger); // public binding ≠ private raw type

fn new_logger() -> Logger { Logger { name: "default" } }
fn named(log: Logger) -> Logger { /* ... */ log }
fn new_app_logger(log: Logger) -> AppLogger { AppLogger(named(log)) }

fn logging() -> Module {
    Module::builder("logging")
        .provide_private(new_logger)
        .provide(new_app_logger)
        .invoke(|log: AppLogger| { /* ... */ })
}

Modrun::builder().module(logging());

At the composition root, one composed constructor is enough: provide(|| named(new_logger())) or a named fn app_logger() -> Logger { ... }. See examples/wrap.rs.

Provide

provide registers a constructor. Nothing is built until something asks for it, and each type is built at most once. Constructors that return Result<T, E> must use provide_result (or provide_result_async); handing one to plain provide is a compile error. provide_async and provide_result_async take async fns. When several independent constructors are needed at once, modrun builds them by DAG layer: async constructors in the same layer are polled concurrently on one task; sync constructors run inside construct() and defer creation of later futures in that layer. Shared dependencies still run first, then dependents continue in dependency order.

Supply

supply hands the container a value you already have, skipping the constructor.

Invoke

invoke pulls the graph. An invoker's parameters are the roots of what gets built, so a type nobody invokes (directly or transitively) is never constructed. A migrator or background consumer that is only provided will stay inert until something invokes it (or a type that depends on it). Invokers run once, during build, and are where you normally register lifecycle hooks.

Lifecycle collects start/stop hooks. It is injected automatically, so any constructor or invoker can take it as a parameter. OnStart hooks run in registration order; OnStop hooks run in reverse. If a start hook fails or start is cancelled, hooks that already finished OnStart are stopped again before the error propagates — and any OnStop failures are retained in the returned error. A start hook that fails or is cancelled mid-flight does not run its own OnStop. A stop-only hook (one without OnStart) is considered active immediately and is included in unwind. Register hooks during invoke (or from an OnStart factory); append returns an error if start has already finished or stop has begun. An invoker may itself return modrun::Result<()>, so give boot that return type and hand the append result straight back instead of unwrapping it. Implement Hook on a struct when start and stop share state (&mut self). For a struct that only implements OnStop, override has_start to return false so trailing activation still runs it after a failed start. For one-off closures, use hook(); OnStop callbacks must be repeatable (Fn) when they capture shared state — clone an Arc inside the closure on each call. Hook and constructor errors use Error (thiserror); hooks should return Error::hook or Error::io so the original failure stays on std::error::Error::source. There is no From<std::io::Error>; wrap I/O with Error::io (bind(addr).await.map_err(|e| Error::io(format!("bind {addr}"), e))?).

Override Hook::name from the default "unnamed" for clearer logs and errors. Constructors and invokers accept at most eight parameters; group extra dependencies in a struct rather than stretching arity.

Hook futures must be cancellation-safe. A start/stop timeout drops the in-progress future, but it cannot cancel detached tasks created with tokio::spawn. Prefer task() for workers, or task_with() when bind/listen must finish during OnStart (see the axum example). Both signal Stopped on OnStop, join, and abort if the hook is dropped mid-flight. If that background work returns Err or panics after start has succeeded, shutdown is requested automatically so run() does not wait forever for a signal. Custom tasks spawned with tokio::spawn must still call Shutdowner::shutdown. Hook panics are treated as fatal programming errors and may bypass lifecycle unwind (logged as panicked).

Shutdowner is also injected automatically. Calling shutdown() unblocks run() from inside the app, which is how you shut down in response to something other than a signal. During build/start that cancellation is cooperative (next .await).

The container is dropped when build finishes. Singletons stay alive only through values you capture in hooks (or other Clone handles taken during invoke). modrun wires startup; it is not a live service locator. Build is not transactional: if a later invoker fails, earlier constructors may already have run their side effects for that failed build call.

Dependencies must be Clone to inject by value, because singletons are cached by type and cloned on inject. Prefer Arc<T> when a type is large, shared across many constructors, or injected more than once — Arc<T> is registered as an alias, so a constructor that returns T can be injected as Arc<T> without T: Clone. Put shared mutable state behind an Arc inside your type:

use std::sync::Arc;

struct DbInner;

#[derive(Clone)]
struct Db(Arc<DbInner>);

Since the cache key is the type, a type can only be provided once. To wire two of the same thing — a primary and a replica pool, say — give each its own newtype:

# use std::sync::Arc;
# struct PgPool;
#[derive(Clone)]
struct PrimaryDb(Arc<PgPool>);
#[derive(Clone)]
struct ReplicaDb(Arc<PgPool>);
# let _ = std::any::type_name::<(PrimaryDb, ReplicaDb)>();

Connect pools with provide_result_async at the composition root (so tests can supply a fake). Do not open connections inside OnStart unless you want pool failure to look like a start-hook failure rather than a constructor error.

Performance

modrun cost is almost entirely cold start: validate the graph, run constructors and invokers once, then run lifecycle hooks. There is no service locator or background wiring after start() — the container is dropped when build finishes.

Typical overhead is small compared to opening databases, binding sockets, or loading config. To keep it that way:

  • Prefer Arc<T> for large or widely shared singletons — injection clones cached values; Arc<T> is a cheap pointer clone (see above).
  • Prefer Arc<Group<T>> or Group<Arc<T>> when group members are heavy — aggregating Group<T> may clone each member (T: Clone is required).
  • Keep modules shallow when possible — private bindings walk the scope ancestor chain on each resolve (usually a handful of lookups).
  • Disable framework logging in production — default-features = false skips the optional subscriber; without a subscriber, tracing events are no-ops. Use .no_banner() in tests and benchmarks.
  • Do not enable dot_graph() on every start — DOT export validates and renders the full graph; use it for debugging or CI, not hot paths.

Independent async constructors in the same wave run concurrently on the current task (no tokio::spawn storm). Run cargo bench --bench build locally to compare group sizes and module depth.

Modules

A Module groups related wiring under a name and gives it a private scope. provide_private keeps a type invisible outside the module, so two domains can each have their own Repo without colliding:

use modrun::{Modrun, Module};

# #[derive(Clone)] struct UserRepo;
# #[derive(Clone)] struct UserService;
# fn new_user_repo() -> UserRepo { UserRepo }
# fn new_user_service(_r: UserRepo) -> UserService { UserService }
# fn boot_user(_s: UserService) {}
fn user_domain() -> Module {
    Module::builder("user")
        .provide_private(new_user_repo)
        .provide(new_user_service)
        .invoke(boot_user)
}

# #[tokio::main]
# async fn main() -> modrun::Result<()> {
Modrun::builder()
    .module(user_domain())
    .start()
    .await?
    .stop()
    .await
# }

Private types are visible to the module that declared them and to its nested modules. Everything registered with plain provide or supply is visible everywhere, wherever it was declared. provide_private / supply_private exist only on Module, not on the root builder.

Groups

Multiple modules can each contribute one value of the same type; a consumer receives them as Group<T> (not Vec<T>). Register members with provide_group (and provide_group_async / provide_group_result / provide_group_result_async when needed). Members are aggregated in registration order and do not occupy the singleton slot for T, so they can coexist with a separate provide of the same type.

use modrun::{Group, Modrun, Module};

# #[derive(Clone, PartialEq, Eq)] struct Handler(&'static str);
# fn user_handler() -> Handler { Handler("user") }
# fn order_handler() -> Handler { Handler("order") }
# fn boot(_: Group<Handler>) {}

Modrun::builder()
    .module(Module::builder("user").provide_group(user_handler))
    .module(Module::builder("order").provide_group(order_handler))
    .invoke(boot)
# ;

Inject Group<T> (or Arc<Group<T>>) in an invoker or constructor; iterate with for item in group. Members and injected groups require T: Clone; prefer Arc<Group<T>> when several consumers need the same collection, or return Arc<T> / Arc<dyn Trait> from group member constructors when values are heavy. When one or more modules already provide_group, root invokers that take Group<T> aggregate members automatically — no init_group call required (see handlers). With no members, register the empty group with init_group or require_group (require_group also fails the build if the group stays empty; only the composition root can call it). T is inferred from the constructor return type (no provide_group::<T> turbofish); for a trait-object group, return Arc<dyn Trait>. A module-private provide_private of Group<T> shadows the aggregated group inside that module — use provide_group to contribute members instead. For two groups of the same element type, use newtypes (same as duplicate singletons).

Method matrix (group members mirror the four provide_* variants above):

Register Use when
provide_group sync infallible member ctor
provide_group_result sync fallible (Result)
provide_group_async async infallible
provide_group_result_async async fallible
supply_group pre-built member value
init_group / require_group composition root only; empty vs non-empty policy

Dependency graph

Export the wiring graph as Graphviz DOT for documentation or debugging. Validation runs first (missing providers and cycles surface as errors), but no constructor or invoker runs.

use modrun::Modrun;

# #[derive(Clone)] struct Config;
# #[derive(Clone)] struct Server;
# fn new_config() -> Config { Config }
# fn new_server(_: Config) -> Server { Server }
# fn boot(_: Server) {}

// Return DOT as a string (no file I/O)
let dot = Modrun::builder()
    .provide(new_config)
    .provide(new_server)
    .invoke(boot)
    .render_dot()?;
# Ok::<(), modrun::Error>(())

To write a file before graph construction when starting the app, chain .dot_graph("modrun.dot") on the builder passed to run.

Nodes show the type, constructor name, and module scope (subgraph per module). Solid arrows are constructor / invoker dependencies; dotted edges link group members to their Group<T> aggregate. Built-in Lifecycle and Shutdowner nodes are omitted for clarity. See docs/graph-sample.dot for sample output. Render with dot -Tpng modrun.dot -o modrun.png.

Logging

Framework events (provide / supply / invoke / construct / OnStart / OnStop) are emitted through tracing with target modrun, using uber/fx-style console lines such as [modrun] PROVIDE my::Type <= my::new. The same events carry structured fields (constructor, module, elapsed, error, …) so a JSON subscriber can filter them in production.

modrun::logging::init() is for examples and local binaries (enabled by default via the logging feature). It writes to stderr, enables ANSI only when stderr is a TTY, and is a no-op if a subscriber is already installed (it will not panic). Production services should install their own subscriber and skip this helper. Without a subscriber the events are cheap no-ops:

RUST_LOG / per-target filtering comes from the env-filter feature (on by default, pulls regex-automata / matchers). For console output without those regex crates, use default-features = false with features = ["logging"]; the helper then installs a global INFO max level instead.

fn main() {
    #[cfg(feature = "logging")]
    modrun::logging::init();
}

Set RUST_LOG=modrun=info with your own subscriber. Cancelled hooks and constructors emit ERROR lines. A successful stop emits STOPPED. Leak warnings on RunningApp go through tracing; debug builds also print to stderr.

Startup banner

ModrunBuilder::run and start print a modrun ASCII banner to stderr before wiring (Spring Boot style), and only when stderr is a TTY. Custom text (or include_str!("banner.txt") in your crate) is always printed to stderr:

# use modrun::Modrun;
Modrun::builder()
    .banner("my service")
    // ...
# ;

Disable with .no_banner(). Piped processes and daemons without a TTY skip the default banner automatically. Tests that run in a terminal should still call .no_banner() so captured stderr stays quiet.

Failure modes

The graph is checked before anything is constructed, so these are build-time errors rather than surprises later:

  • a provider whose dependency nothing provides, even if nobody uses that provider
  • a dependency cycle
  • the same type provided twice

At runtime, build_timeout, start_timeout, and stop_timeout (15s by default) bound graph construction, OnStart, and OnStop respectively. Timeouts are cooperative: work that yields at .await is cancelled when the budget expires. Synchronous blocking (for example std::thread::sleep in a sync invoker, constructor, or hook) cannot be preempted, but an over-budget success is still reported as a timeout error rather than Ok. The cancellation timer follows Tokio's clock; the over-budget Ok check uses wall-clock Instant, so tokio::time::pause in tests can make those two disagree. If a timeout is set more than once on the builder, the last value wins. no_build_timeout / no_start_timeout / no_stop_timeout disable the budget. no_start_timeout only disables OnStart; stop_timeout still budgets unwind after a failed or cancelled start. When the budget expires, remaining OnStop hooks are abandoned and the timeout is reported as an error rather than hanging — connection pools may not get a clean close. For production start that runs migrations or cache warm-up, set an explicit start_timeout (and stop_timeout) instead of relying on the 15s default.

run() treats Ctrl-C / SIGTERM / Shutdowner during build or start as a graceful stop: it unwinds hooks that already started and any registered stop-only hooks, and returns Ok(()) if cleanup succeeds. A background task that fails or panics during start is not treated as graceful — run() returns the join error (or background task failed during start if unwind reported success). If unwind then times out, both are retained on Error::CleanupAfterFailure. If that phase had already failed, run() still returns the failure. Shutdown and OS signals are cooperative in the same way as timeouts: they take effect at the next .await, so a shutdown() from a synchronous OnStart does not skip later hooks that have not yet yielded. After RUNNING, run() waits until a signal or Shutdowner::shutdown(); a background task that fails or panics requests shutdown on its own. Work spawned with tokio::spawn must still call shutdown() or run() waits forever. A panic in a constructor, invoker, or hook is not converted into Error and may skip lifecycle unwind (tracing records it as panicked).

Testing

start() builds and starts without waiting for a signal, returning a RunningApp you can stop() yourself. A background task that fails after its OnStart has returned does not fail start() or skip later hooks; wait on Shutdowner or call stop() to observe it. Use run when a failed worker should tear the process down:

# use std::sync::Arc;
# use std::sync::atomic::{AtomicUsize, Ordering};
# use modrun::{hook, Lifecycle, Modrun};
# #[derive(Clone)] struct Hits(Arc<AtomicUsize>);
# fn boot(lc: Lifecycle, hits: Hits) {
#     let n = Arc::clone(&hits.0);
#     lc.append(hook().on_start(move || async move {
#         n.fetch_add(1, Ordering::SeqCst);
#         Ok(())
#     })).unwrap();
# }
# #[tokio::main]
# async fn main() -> modrun::Result<()> {
let hits = Arc::new(AtomicUsize::new(0));
let app = Modrun::builder()
    .no_banner()
    .supply(Hits(Arc::clone(&hits)))
    .invoke(boot)
    .start()
    .await?;
assert_eq!(hits.load(Ordering::SeqCst), 1);
app.stop().await
# }

Replaceable types belong at the composition root (provide / supply), not inside the domain Module. If the module also provides Repo, a test supply(FakeRepo) will hit already provided. Runnable version: cargo run --example swap.

# use modrun::{Modrun, Module};
# #[derive(Clone)] struct Repo;
# #[derive(Clone)] struct Service;
# fn connect_repo() -> Repo { Repo }
# fn fake_repo() -> Repo { Repo }
# fn new_service(_: Repo) -> Service { Service }
# fn boot(_: Service) {}
fn user_domain() -> Module {
    Module::builder("user")
        .provide(new_service)
        .invoke(boot)
}

# #[tokio::main]
# async fn main() -> modrun::Result<()> {
// production
Modrun::builder()
    .no_banner()
    .provide(connect_repo)
    .module(user_domain())
    .start()
    .await?
    .stop()
    .await?;

// test: same module, fake at the root
Modrun::builder()
    .no_banner()
    .supply(fake_repo())
    .module(user_domain())
    .start()
    .await?
    .stop()
    .await
# }

Tests that sleep in hooks should set an explicit timeout (or no_start_timeout); the default budget is 15s. Prefer .no_banner() so stderr stays quiet when tests run in a terminal.

Errors

Graph problems fail before constructors run. Typical Display text:

  • type already provided: my::Config
  • invoker in module '<root>' needs a dependency nothing provides: my::Db
  • provider for my::Svc in module 'user' needs a dependency nothing provides: my::Repo
  • dependency cycle detected involving: A -> B -> A
  • application start timed out after 15s
  • application stop timed out after 15s while unwinding
  • invoker my::boot failed: …
  • hook 'http.serve' failed: …
  • background task failed during start
  • required group is empty: modrun::Group<my::Route>
  • provide_group_dyn type mismatch: expected my::Route, got alloc::string::String
  • invoker in module '<root>' needs a dependency nothing provides: modrun::Group<my::Route>; register the group with init_group, provide_group, or require_group

Constructor and hook failures keep the original error on std::error::Error::source. Every hook has a Hook::name in logs and errors (default "unnamed"; task sets its own). Several OnStop failures become MultipleStopError. If unwind fails after an earlier phase error, both are retained on Error::CleanupAfterFailure.

When not to use this

  • Request-scoped objects (one instance per HTTP request)
  • Looking up a type from a live container after start()
  • String-named bindings ("primary" vs "replica") — use newtypes
  • Runtime-agnostic libraries; modrun targets Tokio

Examples

cargo run --example basic    # domain modules, private deps, an async constructor
cargo run --example handlers # multiple modules contribute to a Group
cargo run --example worker   # newtype pools + task that selects on Stopped
cargo run --example swap     # supply a fake at the composition root (tests)
cargo run --example wrap     # wrapper constructors for cross-cutting concerns
cargo run --example axum     # HTTP server: task_with binds in OnStart, then serve

License

MIT

About

A modular application composer for Rust.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages