When a scope has no provider for a requested key, it builds the value by constructor injection. This page covers the rules, the parameter shapes you can inject, and how lazy injection breaks cycles.
An injectable class must have exactly one public constructor. The container inspects that constructor, resolves each parameter through the scope, and calls it.
public class Service {
private final Repository repository;
public Service(Repository repository) {
this.repository = repository;
}
}Records work naturally — their canonical constructor is that single public constructor:
public record Service(Repository repository) {}Each constructor parameter may take any of these forms:
TypeX value // resolve a single TypeX
Provider<TypeX> lazyValue // resolve lazily, on first get()
Provider<Iterable<Provider<TypeX>>> all // resolve all providers of TypeX
@Named("main") TypeX namedValue // resolve a qualified TypeX
@Named("main") Provider<TypeX> namedLazyValue // qualified + lazyProvider<T>— see Lazy injection below.Provider<Iterable<Provider<T>>>— see Qualifiers & collections.@Named(...)— see Qualifiers & collections.
The container will refuse (with UnsupportedInjectionException or
a resolution error) to build:
- local or anonymous classes;
- non-static inner classes;
- non-concrete generic types such as
Provider<List<T>>; - a class without a single public constructor;
- a direct, non-lazy cycle (see below).
A Provider<T> parameter is resolved lazily: the dependency is not created until
provider.get() is called.
record Controller(Provider<Service> service) {
void handle() {
service.get().run(); // Service is built here, not at Controller construction
}
}Use it to defer an expensive construction, or to break a cycle.
A direct cycle cannot be constructed — neither side can be built first:
record C1(C2 c2) {}
record C2(C1 c1) {}
scope.get(C1.class); // BeanResolutionExceptionA lazy cycle through Provider<T> is allowed, because the provider defers
construction of the other side:
record A1(Provider<A2> a2) {}
record A2(Provider<A1> a1) {}
A1 a1 = scope.get(A1.class);
A2 a2 = a1.a2().get();
assert a2.a1().get() == a1; // same scope singletonsThe rule of thumb: if two types must reference each other, put a Provider<> on at
least one side.
This is the part people miss. When you call get(T) / provider(T) and no
provider for T exists anywhere in the visible graph — you never seed,
provide or bind it — the container builds T by constructor injection and
registers it in the current scope: the exact scope you called get on. The
same goes for any of T's dependencies that are also unregistered — each missing
one is created locally too. Dependencies that do exist in a parent are reused from
there.
record RootScope() {}
record Config(String value) {}
record Service(Config config) {}
Scope<RootScope> root = new Scope<>(new RootScope());
root.seed(Config.class, new Config("prod"));
Service service = root.get(Service.class); // Service is registered nowhereMental model — Service materializes inside the scope that asked for it:
root.get(Service.class)
┌─ root : RootScope ─────────────────────────────────────────────────────────────┐
│ │
│ Config = Config("prod") (seeded) │
│ │
│ Service = Service(config) ◄── created HERE the scope that called get() │
│ └─ needs Config ─┐ │
│ └─► Config above (reused, already local) │
│ │
└────────────────────────────────────────────────────────────────────────────────┘
The bean is owned by the scope that created it, so it dies when that scope closes — even if all of its dependencies came from a parent. Resolve from a child and the bean lives and dies with the child:
Scope<RootScope> root = new Scope<>(new RootScope());
root.seed(Config.class, new Config("prod"));
Scope<Player> child = new Scope<>(new Player("Ada"));
child.ownedBy(root);
Service s = child.get(Service.class); // Service unregistered; Config lives in root┌─ root ────────────────────────────┐
│ │
│ Config = Config("prod") │ ← Config stays in root
│ │
└───────────────────────────────────┘
▲
│ visible (ownedBy)
│ reuses Config
│
┌─ child : Player ──────────────────┐
│ │
│ Service = Service(config) │ ← created in CHILD (the caller),
│ └─ Config from root │ owned by child, gone on child.close()
│ │
└───────────────────────────────────┘
So where you call get from decides the bean's lifetime. If a type should be a
singleton at the root, resolve it from the root (or bind/provide it there) — not
from a short-lived child. This is the same shadowing model as everything else: a
bean's dependencies see its scope first, then visible parents
(Scopes & lifecycle).
Next: Qualifiers & collections.