Skip to content

Commit 74ffa96

Browse files
authored
feat(dependency-injection): lifecycle — @PreDestroy, custom scopes, scope violation detection (#33)
1 parent 37156f2 commit 74ffa96

12 files changed

Lines changed: 889 additions & 12 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,5 @@ target/
55
# IntelliJ IDEA
66
.idea/
77
*.iml
8+
9+
.build/

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
* @since Oct-2024
5151
*/
5252
public interface Context
53-
extends Injector, Binder {
53+
extends Injector, Binder, AutoCloseable {
5454

5555
/**
5656
* Creates a {@link BindingBuilder} pre-loaded with the specified instance value, enabling
@@ -142,6 +142,14 @@ <T> T create(Dependency dependency)
142142
*/
143143
Context initializeEagerSingletons();
144144

145+
/**
146+
* Closes this {@link Context}, invoking any {@link PreDestroy} lifecycle methods on
147+
* instantiated singleton instances in reverse dependency order (dependents are destroyed
148+
* before their dependencies).
149+
*/
150+
@Override
151+
void close();
152+
145153
/**
146154
* Creates a new {@link Context} with the this {@link Context} as a parent solver.
147155
*
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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.lang.annotation.Annotation;
24+
import java.util.Objects;
25+
26+
/**
27+
* A {@link ClassBinding} whose instance lifecycle is managed by a custom {@link Scope} (i.e. any
28+
* scope other than {@link jakarta.inject.Singleton} or prototype). Wraps the {@link ValueBinding}
29+
* returned by {@link Scope#scope} and exposes the concrete class and scope annotation for
30+
* introspection by {@link Context#validate()} and {@link BindingGraphContributor}.
31+
*
32+
* <p>Used by {@link InjectionContext} when {@link InjectionFramework#findScopeEntry} matches a
33+
* registered custom scope on the concrete class. Instances are resolved via the delegate, which
34+
* applies the scope's caching or sharing strategy.
35+
*
36+
* @param <T> the type produced by this binding
37+
* @author reed.vonredwitz
38+
* @see Scope
39+
* @see ScopeAnnotation
40+
* @since Apr-2026
41+
*/
42+
class CustomScopedClassBinding<T>
43+
extends AbstractBinding<T>
44+
implements ClassBinding<T>, ValueBinding<T> {
45+
46+
/**
47+
* The concrete class to be instantiated by the scope.
48+
*/
49+
private final Class<? extends T> concreteClass;
50+
51+
/**
52+
* The scope annotation that governs this binding's lifecycle.
53+
*/
54+
private final Class<? extends Annotation> scopeAnnotation;
55+
56+
/**
57+
* The scoped {@link ValueBinding} returned by {@link Scope#scope}. Applies the scope's
58+
* caching or sharing strategy on each {@link #value()} call.
59+
*/
60+
private final ValueBinding<T> delegate;
61+
62+
/**
63+
* Constructs a {@link CustomScopedClassBinding}.
64+
*
65+
* @param dependency the {@link Dependency} this binding satisfies
66+
* @param concreteClass the concrete class whose instances the scope manages
67+
* @param scopeAnnotation the scope annotation that governs the lifecycle
68+
* @param scopedBinding the {@link Binding} returned by {@link Scope#scope}; must implement
69+
* {@link ValueBinding}
70+
* @throws InjectionException if {@code scopedBinding} does not implement {@link ValueBinding}
71+
*/
72+
@SuppressWarnings("unchecked")
73+
CustomScopedClassBinding(final Dependency dependency,
74+
final Class<? extends T> concreteClass,
75+
final Class<? extends Annotation> scopeAnnotation,
76+
final Binding<?> scopedBinding) {
77+
78+
super(dependency);
79+
this.concreteClass = Objects.requireNonNull(concreteClass, "The concrete class must not be null");
80+
this.scopeAnnotation = Objects.requireNonNull(scopeAnnotation, "The scope annotation must not be null");
81+
82+
if (!(scopedBinding instanceof ValueBinding)) {
83+
throw new InjectionException(
84+
"Scope.scope() must return a ValueBinding for dependency [" + dependency + "]; "
85+
+ "got: " + (scopedBinding == null ? "null" : scopedBinding.getClass().getName()));
86+
}
87+
this.delegate = (ValueBinding<T>) scopedBinding;
88+
}
89+
90+
@Override
91+
public Class<? extends T> concreteClass() {
92+
return this.concreteClass;
93+
}
94+
95+
/**
96+
* The scope annotation that governs this binding's instance lifecycle.
97+
*
98+
* @return the scope annotation class
99+
*/
100+
Class<? extends Annotation> scopeAnnotation() {
101+
return this.scopeAnnotation;
102+
}
103+
104+
@Override
105+
public T value() {
106+
return this.delegate.value();
107+
}
108+
}

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

Lines changed: 26 additions & 5 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.
@@ -54,16 +54,23 @@ public class InjectableDescriptor
5454
*/
5555
private final ArrayList<MethodDescriptor> postInjectionMethods;
5656

57+
/**
58+
* The {@link PreDestroy} {@link MethodDescriptor}s.
59+
*/
60+
private final ArrayList<MethodDescriptor> preDestroyMethods;
61+
5762
/**
5863
* Constructs a new {@link InjectableDescriptor} for the specified {@link JDKTypeDescriptor}.
5964
*
6065
* @param typeDescriptor the {@link JDKTypeDescriptor}
6166
* @param injectionPoints the {@link InjectionPoint}s
6267
* @param postInjectionMethods the {@link PostInject} annotated {@link MethodDescriptor}s
68+
* @param preDestroyMethods the {@link PreDestroy} annotated {@link MethodDescriptor}s
6369
*/
6470
private InjectableDescriptor(final JDKTypeDescriptor typeDescriptor,
6571
final Stream<InjectionPoint> injectionPoints,
66-
final Stream<MethodDescriptor> postInjectionMethods) {
72+
final Stream<MethodDescriptor> postInjectionMethods,
73+
final Stream<MethodDescriptor> preDestroyMethods) {
6774

6875
this.typeDescriptor = Objects.requireNonNull(typeDescriptor, "The TypeDescriptor must not be null");
6976
this.injectionPoints = injectionPoints == null
@@ -72,6 +79,9 @@ private InjectableDescriptor(final JDKTypeDescriptor typeDescriptor,
7279
this.postInjectionMethods = postInjectionMethods == null
7380
? new ArrayList<>()
7481
: postInjectionMethods.collect(Collectors.toCollection(ArrayList::new));
82+
this.preDestroyMethods = preDestroyMethods == null
83+
? new ArrayList<>()
84+
: preDestroyMethods.collect(Collectors.toCollection(ArrayList::new));
7585
}
7686

7787
/**
@@ -110,17 +120,28 @@ public Stream<MethodDescriptor> postInjectionMethods() {
110120
return this.postInjectionMethods.stream();
111121
}
112122

123+
/**
124+
* Obtains the {@link PreDestroy} annotated {@link MethodDescriptor}s defined for the {@link JDKTypeDescriptor}.
125+
*
126+
* @return the {@link Stream} of {@link PreDestroy} {@link MethodDescriptor}s
127+
*/
128+
public Stream<MethodDescriptor> preDestroyMethods() {
129+
return this.preDestroyMethods.stream();
130+
}
131+
113132
/**
114133
* Creates a new {@link InjectableDescriptor} for the specified {@link JDKTypeDescriptor}.
115134
*
116135
* @param typeDescriptor the {@link JDKTypeDescriptor}
117136
* @param injectionPoints the {@link InjectionPoint}s
118137
* @param postInjectionMethods the {@link PostInject} annotated {@link MethodDescriptor}s
138+
* @param preDestroyMethods the {@link PreDestroy} annotated {@link MethodDescriptor}s
119139
*/
120140
public static InjectableDescriptor of(final JDKTypeDescriptor typeDescriptor,
121141
final Stream<InjectionPoint> injectionPoints,
122-
final Stream<MethodDescriptor> postInjectionMethods) {
142+
final Stream<MethodDescriptor> postInjectionMethods,
143+
final Stream<MethodDescriptor> preDestroyMethods) {
123144

124-
return new InjectableDescriptor(typeDescriptor, injectionPoints, postInjectionMethods);
145+
return new InjectableDescriptor(typeDescriptor, injectionPoints, postInjectionMethods, preDestroyMethods);
125146
}
126147
}

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

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
import java.lang.reflect.InvocationTargetException;
3434
import java.util.ArrayList;
3535
import java.util.Collection;
36+
import java.util.Collections;
3637
import java.util.IdentityHashMap;
3738
import java.util.LinkedHashSet;
3839
import java.util.List;
@@ -134,6 +135,7 @@ public Binding<T> to(final T value) {
134135
}
135136

136137
@Override
138+
@SuppressWarnings("unchecked")
137139
public Binding<T> to(final Class<? extends T> concreteClass) {
138140
Objects.requireNonNull(concreteClass, "The Binding Value Class must not be null");
139141

@@ -145,6 +147,16 @@ public Binding<T> to(final Class<? extends T> concreteClass) {
145147
.orElseThrow(() -> new IllegalArgumentException(
146148
"Could not resolve a TypeDescriptor for " + concreteClass));
147149

150+
// check for a registered custom scope annotation on the concrete class
151+
final var scopeEntry = this.injectionFramework.findScopeEntry(typeDescriptor).orElse(null);
152+
if (scopeEntry != null) {
153+
final ValueBinding<T> factory = new SupplierBinding<>(
154+
dependency, () -> (T) InjectionContext.this.createUnscoped(concreteClass));
155+
final Binding<?> scoped = scopeEntry.getValue().scope(factory);
156+
return addBinding(dependency,
157+
new CustomScopedClassBinding<>(dependency, concreteClass, scopeEntry.getKey(), scoped));
158+
}
159+
148160
return addBinding(dependency, this.injectionFramework.isSingleton(typeDescriptor)
149161
? new LazySingletonClassBinding<>(dependency, concreteClass)
150162
: new NonSingletonClassBinding<T>(dependency, concreteClass));
@@ -462,13 +474,17 @@ public Context validate() {
462474
+ " required by [" + edge.from() + "]");
463475
}
464476

465-
// Scope violation: @Singleton depends on NonSingletonClassBinding (prototype)
477+
// Scope violation: @Singleton depends on a narrower-scoped binding
466478
final var fromBinding = this.bindingsByDependency.get(edge.from());
467479
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 + "]");
480+
if (fromBinding instanceof LazySingletonClassBinding) {
481+
if (toBinding instanceof NonSingletonClassBinding) {
482+
problems.add("Scope violation: singleton [" + edge.from()
483+
+ "] depends on prototype-scoped [" + dep + "]");
484+
} else if (toBinding instanceof CustomScopedClassBinding<?> csb) {
485+
problems.add("Scope violation: singleton [" + edge.from()
486+
+ "] depends on @" + csb.scopeAnnotation().getSimpleName() + "-scoped [" + dep + "]");
487+
}
472488
}
473489
});
474490

@@ -540,6 +556,90 @@ public Context initializeEagerSingletons() {
540556
return this;
541557
}
542558

559+
@Override
560+
@SuppressWarnings("unchecked")
561+
public void close() {
562+
// Collect all instantiated singleton bindings
563+
final var instantiatedSingletons = this.bindingsByDependency.values().stream()
564+
.filter(LazySingletonClassBinding.class::isInstance)
565+
.map(b -> (LazySingletonClassBinding<Object>) b)
566+
.filter(b -> b.value().optional().isPresent())
567+
.collect(Collectors.toList());
568+
569+
if (instantiatedSingletons.isEmpty()) {
570+
return;
571+
}
572+
573+
// Build a dependency graph over instantiated singletons only
574+
final var singletonDeps = instantiatedSingletons.stream()
575+
.map(Binding::dependency)
576+
.collect(Collectors.toSet());
577+
578+
final var graphBuilder = Graph.<Dependency>directed();
579+
instantiatedSingletons.forEach(b -> {
580+
final var bindingDep = b.dependency();
581+
graphBuilder.addVertex(bindingDep);
582+
this.injectionFramework.getInjectableDescriptor(b.concreteClass())
583+
.injectionPoints()
584+
.flatMap(InjectionPoint::dependencies)
585+
.filter(singletonDeps::contains)
586+
.forEach(dep -> graphBuilder.addEdge(bindingDep, dep));
587+
});
588+
589+
final var graph = graphBuilder.build();
590+
591+
// topologicalSort returns [leaf-first, root-last]; reverse for destruction (dependents first)
592+
final var destroyOrder = new ArrayList<>(Graphs.topologicalSort(graph));
593+
Collections.reverse(destroyOrder);
594+
595+
// Index bindings by dependency for lookup during destruction
596+
final var depToBinding = instantiatedSingletons.stream()
597+
.collect(Collectors.toMap(Binding::dependency, b -> b));
598+
599+
// Invoke @PreDestroy methods on each singleton in destruction order
600+
destroyOrder.forEach(dep -> {
601+
final var binding = depToBinding.get(dep);
602+
if (binding == null) {
603+
return;
604+
}
605+
final var instance = binding.value().optional().orElse(null);
606+
if (instance == null) {
607+
return;
608+
}
609+
this.injectionFramework.getInjectableDescriptor(binding.concreteClass())
610+
.preDestroyMethods()
611+
.map(md -> md.getTrait(MethodType.class).orElse(null))
612+
.filter(Objects::nonNull)
613+
.map(MethodType::method)
614+
.forEach(method -> {
615+
try {
616+
method.setAccessible(true);
617+
method.invoke(instance);
618+
} catch (final IllegalAccessException | InvocationTargetException e) {
619+
throw new InjectionException(
620+
"Invoking @PreDestroy method " + method + " on " + instance.getClass(), e);
621+
}
622+
});
623+
});
624+
}
625+
626+
/**
627+
* Creates an instance of the specified class bypassing any registered {@link Binding}s by
628+
* directly instantiating via {@link ResolvableClass}. Used by scope implementations to produce
629+
* a fresh unscoped instance without recursing into the scoped binding.
630+
*
631+
* @param <T> the type
632+
* @param concreteClass the class to instantiate
633+
* @return a new injected instance
634+
*/
635+
@SuppressWarnings("unchecked")
636+
private <T> T createUnscoped(final Class<? extends T> concreteClass) {
637+
final var codeModel = this.injectionFramework.codeModel();
638+
final var typeUsage = codeModel.getTypeUsage(concreteClass);
639+
final var dependency = IndependentDependency.of(typeUsage, this.injectionFramework::getQualifierAnnotationTypes);
640+
return (T) new ResolvableClass<>(Optional.empty(), dependency, concreteClass).resolve();
641+
}
642+
543643
@Override
544644
public Context newContext() {
545645
return this.injectionFramework.newContext(this.resolver());

0 commit comments

Comments
 (0)