Guide / Reinhardt Reinhardt 0.4.0-alpha.13; moonc 0.10.11+6ff76a5f9

How to make a dependency injection system in your favorite language?

In this guide

We'll study how to make a dependency injection system in your favorite language. In this video, we'll use MoonBit as an example to reproduce Reinhardt's dependency resolver and scope rules. We build a small working core; the data structures and behavior checks below describe what to preserve when adapting it to your language.

Download the Rust and MoonBit experiments. Extract the ZIP and follow framework-internals/README.md. Tested with Rust 1.96.0, Moon CLI 0.1.20260827, moonc 0.10.11+6ff76a5f9, Node 22.22.1, Python 3.12 and a native C toolchain. Initial builds fetch dependencies. Both SQLite examples use separate in-memory databases; no server is needed.

python3 verify.py

Give each dependency the right lifetime

Two requests need the same settings, but each needs its own request state. Reinhardt shares the singleton and separates request values; our MoonBit reproduction matches those identities. We will read the real async registry and rebuild the rules that choose when to construct or reuse a value.

Dependency injection supplies a service's dependencies from outside that service, making the construction policy visible. The reference is Reinhardt zero point four, alpha thirteen, and the reproduced graph is deliberately small.

Separate a recipe from the object it creates

A provider is a function that knows how to create one dependency. Reinhardt's registry stores the provider together with a scope policy keyed by the requested Rust type. Our Settings provider returns shared configuration, while the Service provider resolves both Settings and Request State.

Resolving means finding the recipe or a reusable value and producing the dependency the caller asked for. The sample registers real asynchronous providers and calls the real Injection Context resolver. The registry is a construction policy, not evidence that a labeled test value is a real database connection.

For a fixed graph, explicit constructor arguments can already express the dependencies. The container becomes useful here because we want one place to apply the same reuse rules to every resolution.

Excerpt from the executable source; the complete program is in the download.

registry.register_async(DependencyScope::Singleton, |_| async {
    Ok(Settings("demo"))
});
registry.register_async(DependencyScope::Request, |_| async { Ok(RequestState) });
registry.register_async(DependencyScope::Request, |ctx| async move {
    Ok(Service {
        settings: ctx.resolve().await?,
        request: ctx.resolve().await?,
    })
});

Choose where successful values are cached

Singleton, Request and Transient are three distinct reuse policies in the pinned registry. Two contexts using the same Singleton Scope can reuse the same Settings object. Request-scoped values instead live in each request's cache, so repeating a resolution within one request reuses its object.

A second request has a separate cache and obtains a separate Request State. Transient bypasses reuse, constructing a fresh value for each resolution. These cache rules answer who shares a value; resource shutdown and references retained by callers are separate questions.

Compare identities, not just equal contents

Run the comparison and inspect the scope observations from both languages. Within one request, resolving Service twice gives the same object. Across two requests, Settings is shared while Request State is different.

Two Transient resolutions also produce distinct objects. The Rust checks compare Arc pointer identity; MoonBit checks construction IDs and shared mutation. Equal labels alone would not prove reuse, so changing one returned MoonBit object must be visible through the cached alias.

The tests also count constructions and exercise missing providers, cycles and invalid lifetime dependencies. A cache hit must avoid calling the factory again. Distinct IDs for transient values verify construction, while shared mutation verifies that repeated request resolution returns the already constructed instance.

Rust / Reinhardt

same_request_service=true cross_request_state=false
cross_request_singleton=true transient_reused=false
missing=rejected cycle=rejected captive_request=rejected
PASS reinhardt-di=0.4.0-alpha.13; async factories and real scopes

MoonBit

same_request_service=true cross_request_state=false
cross_request_singleton=true transient_reused=false
missing=rejected cycle=rejected captive_request=rejected
PASS MoonBit async factories; shared singleton and separate request caches

Rebuild the async resolver in MoonBit

Our MoonBit registry uses a closed enum of keys instead of Rust Type ID and Any. Each provider holds an asynchronous factory and one of the three scope policies. The resolver checks scope rules and the cache, then detects cycles before awaiting the factory and caching a success.

MoonBit's async calls use ordinary call syntax here; factories really yield through the pinned async library. Recursive calls share the correct caches but receive their own path, so an error does not leave a stale construction marker. This is a working scope-policy reproduction, with one instance representation rather than a full typed container.

Excerpt from the executable source; the complete program is in the download.

  let child = { ..self, path: self.path + [key], }
  let value = (provider.create)(child)
  if cache is Some(cache) {
    cache[key] = value
  }
  value
}

Reject cycles and captive request values

If A requires B and B requires A before construction finishes, no cached success can break that cycle. The active path detects the repeated key and returns an error instead of recursing indefinitely. A different invalid graph asks a Singleton provider to retain a Request-scoped value.

That would let a longer-lived object capture request-specific state, so both implementations reject the tested dependency. After those failures, resolving the earlier Service still returns its cached value. Duplicate registration is another configuration error, but the forms differ: Reinhardt panics, while our MoonBit registry raises an error.

Keep the lifetime contract testable

Start with the supplied graph, then add one dependency with an explicitly chosen scope. Predict whether it should be shared within a request, across requests, or never reused before running either implementation. Follow the linked Reinhardt resolver when the observation differs from your expectation.

Our MoonBit checks resolve sequentially and do not establish concurrent construction, full cancellation behavior or resource teardown. The closed key set also omits the full framework's typed extraction and override features. Reinhardt Press includes both sources and assertions so that a small lifetime question can become a reproducible investigation.

Take the mechanism to your language

In your language, use keys for dependencies, factory functions for construction, and maps for the chosen lifetimes. Adapt async calls and shared references to its runtime.

Preserve the observable rules: reuse within a scope, isolate request state, and reject missing dependencies and cycles before caching a successful result.

Source map and limits

PORTING.md maps the pinned framework functions to the executable MoonBit implementation and names the omitted features. The reproduction uses different runtime representations and is a bounded investigation, not a complete port. Node 22 emits an experimental warning for its SQLite API.

Reinhardt contribution guide.