Skip to content

Commit 37156f2

Browse files
authored
feat(dependency-injection): startup validation, eager singleton initialization (#32)
1 parent a0fbb03 commit 37156f2

7 files changed

Lines changed: 468 additions & 3 deletions

File tree

dependency-injection/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,12 @@
3434
<version>${base.version}</version>
3535
</dependency>
3636

37+
<dependency>
38+
<groupId>build.base</groupId>
39+
<artifactId>base-graph</artifactId>
40+
<version>${base.version}</version>
41+
</dependency>
42+
3743
<dependency>
3844
<groupId>build.codemodel</groupId>
3945
<artifactId>codemodel-foundation</artifactId>

dependency-injection/src/main/java/build/codemodel/injection/Context.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,37 @@ <T> T create(TypeUsage typeUsage)
111111
<T> T create(Dependency dependency)
112112
throws InjectionException;
113113

114+
/**
115+
* Validates the current set of registered bindings before any objects are created. Performs three checks:
116+
* <ol>
117+
* <li><strong>Cycle detection</strong> — throws {@link CyclicDependencyException} with the full cycle
118+
* path if a dependency cycle is found among class bindings.</li>
119+
* <li><strong>Unsatisfied dependency detection</strong> — collects every class binding whose injected
120+
* dependencies have no corresponding binding and throws {@link ValidationException} listing them
121+
* all at once.</li>
122+
* <li><strong>Scope violation detection</strong> — flags edges where a wider-scoped binding (e.g.
123+
* {@link jakarta.inject.Singleton}) depends on a narrower-scoped one (e.g. prototype).</li>
124+
* </ol>
125+
*
126+
* @return this {@link Context} for fluent chaining (e.g. {@code context.validate().initializeEagerSingletons()})
127+
* @throws CyclicDependencyException if a dependency cycle is detected
128+
* @throws ValidationException if unsatisfied dependencies or scope violations are found
129+
*/
130+
Context validate();
131+
132+
/**
133+
* Pre-creates all {@link jakarta.inject.Singleton}-scoped class bindings in dependency order, using
134+
* {@link build.base.graph.Graphs#parallelizableGroups} to initialize independent groups in parallel.
135+
*
136+
* <p>Typically called immediately after {@link #validate()}:
137+
* <pre>{@code
138+
* context.validate().initializeEagerSingletons();
139+
* }</pre>
140+
*
141+
* @return this {@link Context} for fluent chaining
142+
*/
143+
Context initializeEagerSingletons();
144+
114145
/**
115146
* Creates a new {@link Context} with the this {@link Context} as a parent solver.
116147
*

dependency-injection/src/main/java/build/codemodel/injection/CyclicDependencyException.java

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@
99
* Licensed under the Apache License, Version 2.0 (the "License");
1010
* you may not use this file except in compliance with the License.
1111
* You may obtain a copy of the License at
12-
*
12+
*
1313
* http://www.apache.org/licenses/LICENSE-2.0
14-
*
14+
*
1515
* Unless required by applicable law or agreed to in writing, software
1616
* distributed under the License is distributed on an "AS IS" BASIS,
1717
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@@ -20,12 +20,16 @@
2020
* #L%
2121
*/
2222

23+
import java.util.List;
24+
import java.util.stream.Collectors;
25+
2326
/**
2427
* Thrown when a cyclic dependency has been detected between {@link Class}es.
2528
*
2629
* @author brian.oliver
2730
* @since Jun-2018
2831
*/
32+
@SuppressWarnings("java:S2166")
2933
public class CyclicDependencyException
3034
extends InjectionException {
3135

@@ -39,6 +43,11 @@ public class CyclicDependencyException
3943
*/
4044
private final Dependency causedByDependency;
4145

46+
/**
47+
* The full cycle path produced by static graph analysis, or {@code null} when detected at runtime.
48+
*/
49+
private final List<Dependency> cyclePath;
50+
4251
/**
4352
* Constructs a {@link CyclicDependencyException} based on a {@link Dependency} that has a {@link Dependency} on
4453
* itself.
@@ -58,14 +67,35 @@ public CyclicDependencyException(final Dependency dependency) {
5867
public CyclicDependencyException(final Dependency detectedInDependency, final Dependency causedByDependency) {
5968
this.detectedInDependency = detectedInDependency;
6069
this.causedByDependency = causedByDependency;
70+
this.cyclePath = null;
71+
}
72+
73+
/**
74+
* Constructs a {@link CyclicDependencyException} with the full cycle path discovered by static graph
75+
* analysis (e.g. from {@link Context#validate()}). The path starts and ends with the same
76+
* {@link Dependency} (e.g. {@code [A, B, C, A]}).
77+
*
78+
* @param cyclePath the full cycle path including the repeated start/end node
79+
*/
80+
public CyclicDependencyException(final List<Dependency> cyclePath) {
81+
this.cyclePath = List.copyOf(cyclePath);
82+
// for backwards-compat fields: use first/last unique nodes in path
83+
this.detectedInDependency = cyclePath.isEmpty() ? null : cyclePath.getFirst();
84+
this.causedByDependency = cyclePath.size() > 1 ? cyclePath.get(cyclePath.size() - 2) : this.detectedInDependency;
6185
}
6286

6387
@Override
6488
public String getMessage() {
89+
if (this.cyclePath != null) {
90+
return "Cyclic dependency detected: "
91+
+ this.cyclePath.stream()
92+
.map(Object::toString)
93+
.collect(Collectors.joining(" \u2192 "));
94+
}
6595
return "Detected a Cyclic Dependency. " + this.detectedInDependency + " defines a "
6696
+ (this.detectedInDependency == this.causedByDependency
6797
? "dependency on itself."
6898
: "(transitive) dependency on " + this.causedByDependency + ", which defines a dependency on "
69-
+ this.detectedInDependency);
99+
+ this.detectedInDependency);
70100
}
71101
}

dependency-injection/src/main/java/build/codemodel/injection/InjectionContext.java

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
*/
2222

2323
import build.base.foundation.Lazy;
24+
import build.base.graph.Graph;
25+
import build.base.graph.Graphs;
2426
import build.codemodel.foundation.usage.GenericTypeUsage;
2527
import build.codemodel.foundation.usage.NamedTypeUsage;
2628
import build.codemodel.foundation.usage.TypeUsage;
@@ -422,6 +424,122 @@ public <T> T create(final Dependency dependency)
422424
.orElseThrow(() -> new UnsatisfiedDependencyException(dependency));
423425
}
424426

427+
@Override
428+
public Context validate() {
429+
// Build a directed dependency graph over all ClassBindings.
430+
// Edge: binding.dependency() → each of its injected dependencies.
431+
final var graphBuilder = Graph.<Dependency>directed();
432+
433+
this.bindingsByDependency.values().stream()
434+
.filter(ClassBinding.class::isInstance)
435+
.map(b -> (ClassBinding<?>) b)
436+
.forEach(classBinding -> {
437+
final var bindingDep = classBinding.dependency();
438+
graphBuilder.addVertex(bindingDep);
439+
this.injectionFramework.getInjectableDescriptor(classBinding.concreteClass())
440+
.injectionPoints()
441+
.flatMap(InjectionPoint::dependencies)
442+
.forEach(dep -> graphBuilder.addEdge(bindingDep, dep));
443+
});
444+
445+
final var graph = graphBuilder.build();
446+
447+
// 1. Cycle detection
448+
Graphs.findCycle(graph)
449+
.ifPresent(cycle -> {
450+
throw new CyclicDependencyException(cycle);
451+
});
452+
453+
// 2. Unsatisfied dependency and 3. Scope violation detection
454+
final var problems = new ArrayList<String>();
455+
456+
graph.edges().forEach(edge -> {
457+
final var dep = edge.to();
458+
459+
// Unsatisfied: no explicit binding and not auto-resolvable
460+
if (!isResolvable(dep)) {
461+
problems.add("Unsatisfied dependency: [" + dep + "]"
462+
+ " required by [" + edge.from() + "]");
463+
}
464+
465+
// Scope violation: @Singleton depends on NonSingletonClassBinding (prototype)
466+
final var fromBinding = this.bindingsByDependency.get(edge.from());
467+
final var toBinding = this.bindingsByDependency.get(dep);
468+
if (fromBinding instanceof LazySingletonClassBinding
469+
&& toBinding instanceof NonSingletonClassBinding) {
470+
problems.add("Scope violation: singleton [" + edge.from()
471+
+ "] depends on prototype-scoped [" + dep + "]");
472+
}
473+
});
474+
475+
if (!problems.isEmpty()) {
476+
throw new ValidationException(problems);
477+
}
478+
479+
return this;
480+
}
481+
482+
/**
483+
* Returns {@code true} if the given {@link Dependency} can be satisfied — either by an explicit or
484+
* multibinding already registered, or by an auto-bindable {@link jakarta.inject.Singleton} class.
485+
*/
486+
private boolean isResolvable(final Dependency dependency) {
487+
if (resolver().resolve(dependency).isPresent()) {
488+
return true;
489+
}
490+
// auto-singleton: a @Singleton class that can be bound on-demand
491+
if (dependency.typeUsage() instanceof NamedTypeUsage namedTypeUsage) {
492+
try {
493+
final var clazz = Class.forName(namedTypeUsage.typeName().canonicalName());
494+
return this.injectionFramework.codeModel()
495+
.getJDKTypeDescriptor(clazz)
496+
.map(this.injectionFramework::isSingleton)
497+
.orElse(false);
498+
} catch (final ClassNotFoundException ignored) {
499+
return false;
500+
}
501+
}
502+
return false;
503+
}
504+
505+
@Override
506+
@SuppressWarnings("unchecked")
507+
public Context initializeEagerSingletons() {
508+
// Collect all explicitly-bound singleton class bindings
509+
final var singletonBindings = this.bindingsByDependency.values().stream()
510+
.filter(LazySingletonClassBinding.class::isInstance)
511+
.map(b -> (LazySingletonClassBinding<Object>) b)
512+
.collect(Collectors.toList());
513+
514+
if (singletonBindings.isEmpty()) {
515+
return this;
516+
}
517+
518+
// Build a dependency graph limited to singleton vertices so we can find initialization order
519+
final var singletonDeps = singletonBindings.stream()
520+
.map(Binding::dependency)
521+
.collect(Collectors.toSet());
522+
523+
final var graphBuilder = Graph.<Dependency>directed();
524+
singletonBindings.forEach(b -> {
525+
final var bindingDep = b.dependency();
526+
graphBuilder.addVertex(bindingDep);
527+
this.injectionFramework.getInjectableDescriptor(b.concreteClass())
528+
.injectionPoints()
529+
.flatMap(InjectionPoint::dependencies)
530+
.filter(singletonDeps::contains)
531+
.forEach(dep -> graphBuilder.addEdge(bindingDep, dep));
532+
});
533+
534+
final var graph = graphBuilder.build();
535+
536+
// Initialize each parallelizable layer; within a layer, initialize concurrently
537+
Graphs.parallelizableGroups(graph).forEach(layer ->
538+
layer.parallelStream().forEach(dep -> create(dep)));
539+
540+
return this;
541+
}
542+
425543
@Override
426544
public Context newContext() {
427545
return this.injectionFramework.newContext(this.resolver());
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package build.codemodel.injection;
2+
3+
/*-
4+
* #%L
5+
* Dependency Injection
6+
* %%
7+
* Copyright (C) 2026 Workday, Inc.
8+
* %%
9+
* Licensed under the Apache License, Version 2.0 (the "License");
10+
* you may not use this file except in compliance with the License.
11+
* You may obtain a copy of the License at
12+
*
13+
* http://www.apache.org/licenses/LICENSE-2.0
14+
*
15+
* Unless required by applicable law or agreed to in writing, software
16+
* distributed under the License is distributed on an "AS IS" BASIS,
17+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18+
* See the License for the specific language governing permissions and
19+
* limitations under the License.
20+
* #L%
21+
*/
22+
23+
import java.util.List;
24+
import java.util.stream.Collectors;
25+
26+
/**
27+
* Thrown by {@link Context#validate()} when one or more validation problems are found. Unlike exceptions
28+
* thrown during injection, this collects <em>all</em> detected problems and reports them together so the
29+
* caller can fix multiple issues in a single iteration.
30+
*
31+
* @author reed.vonredwitz
32+
* @see Context#validate()
33+
* @since Apr-2026
34+
*/
35+
public class ValidationException
36+
extends InjectionException {
37+
38+
/**
39+
* The list of human-readable problem descriptions detected during validation.
40+
*/
41+
private final List<String> problems;
42+
43+
/**
44+
* Constructs a {@link ValidationException} with the specified list of problems.
45+
*
46+
* @param problems the list of problem descriptions; must not be empty
47+
*/
48+
public ValidationException(final List<String> problems) {
49+
this.problems = List.copyOf(problems);
50+
}
51+
52+
/**
53+
* Returns the list of problems detected during validation.
54+
*
55+
* @return an unmodifiable list of problem descriptions
56+
*/
57+
public List<String> problems() {
58+
return this.problems;
59+
}
60+
61+
@Override
62+
public String getMessage() {
63+
return "Validation failed with " + this.problems.size() + " problem(s):\n"
64+
+ this.problems.stream()
65+
.map(p -> " - " + p)
66+
.collect(Collectors.joining("\n"));
67+
}
68+
}

dependency-injection/src/main/java/module-info.java

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
requires transitive build.base.transport;
3333
requires transitive build.base.telemetry;
3434
requires build.base.configuration;
35+
requires build.base.graph;
3536

3637
requires transitive build.codemodel.foundation;
3738
requires build.codemodel.expression;

0 commit comments

Comments
 (0)