Lesson 03 / 19
Component Scopes and Lifecycle
A managed component staying a singleton is the IoC container's default decision; being reconstructed on every call comes from an explicitly written scope annotation. An unrecognized scope name throws no error at all — the component silently stops being a singleton, and this is only visible by comparing two requests' identity with ==
Published Updated
In this lesson
- Measures with identity comparison that singleton scope is the container's default and reconstruction on every call is born from an annotation
- Shows that the init method (the lifecycle hook) is called exactly once, after the object is completely ready
- Proves by running it that an unrecognized scope name silently stops a component from being a singleton, without throwing an error
Contents
The previous lesson closed by noting that the Component annotation carries a scope field
that it never used: does container.get(Service.class) return the same instance every time it
is called, or does it construct a new one each time? This lesson opens exactly that question — it
measures what the scope field does and when a managed component is reconstructed; its second
half also examines a single method that is called on a component after it is constructed but
before it is used — the lifecycle hook.
Where the Scope Decision Lives
The Component annotation carries a scope field, and its default value is "singleton". The
get method reads this value for a type and only checks the singletons map if
scope.equals("singleton") is true; if the scope is a different value (like "prototype"), this
check is never done at all, and the code falls to the constructor.newInstance(arg) line every
single time, constructing a new instance. The Service class writes bare @Container.Component,
with no parameter — this means the default value of scope() applies. The Request class writes
@Container.Component(scope = "prototype") — an explicit value.
The way this distinction is confirmed is by identity, not value equality: the expression
container.get(Service.class) == service asks whether two references point to the same object
at the same memory location. There is no other shortcut to confirm this in Java — even if two
Service objects had identical fields, == returns true only when they really are the same
object. This lesson’s measurement never prints an identity hash (hashCode() output) anywhere,
because that value can vary from run to run and is not itself proof; the proof is only the result
of the == comparison.
- CC11. The scope check looks in the
singletonsmap only for thesingletonvalue; every other value means “reconstruct every time,” no separate list or counter is kept. - CC12. Identity is read only with
==; no object’s identity hash or memory address is ever printed. - CC13. The init method (the
Initannotation) is called after the constructor has run and the value fields have been filled; the object is already completely constructed. - CC14. A managed component has at most one init method; this measurement does not try a
class with more than one method marked
Init. - CC15. An unrecognized scope name (like
"undefined-scope") is not caught at compile time —scope()returns a string, and the container only compares it to"singleton"at run time.
// Container.java — what a managed component's scope and init order derive from the source: an annotation or the container's default
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;
public class Container {
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
@interface Component { String scope() default "singleton"; }
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD)
@interface Init {}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
@interface Value { String name(); String defaultValue(); }
static int annotation = 0, byDefault = 0;
static int silent = 0;
static void observe(String behavior, String source) {
if (source.equals("default")) byDefault++; else annotation++;
System.out.printf("%-38s %s%n", behavior, source);
}
static void rule(String name, String result) {
if (result.equals("silent")) silent++;
System.out.printf("%-38s %s%n", name, result);
}
static void summary() {
System.out.printf("behavior: %d annotation, %d default%n", annotation, byDefault);
System.out.printf("defect : %d silent%n", silent);
}
final Map<Class<?>, Object> singletons = new HashMap<>();
final Map<String, String> settings = new LinkedHashMap<>();
final List<String> log = new ArrayList<>();
final Map<Class<?>, Class<?>> bindings = new HashMap<>();
Container configure(String name, String value) { settings.put(name, value); return this; }
Container bind(Class<?> iface, Class<?> implementation) { bindings.put(iface, implementation); return this; }
Object get(Class<?> type) throws Exception {
if (type.isInterface()) {
Class<?> implementation = bindings.get(type);
if (implementation == null)
throw new IllegalStateException("no implementation bound to interface: " + type.getSimpleName());
return get(implementation);
}
Component c = type.getAnnotation(Component.class);
if (c == null) throw new IllegalStateException("no component annotation: " + type.getSimpleName());
boolean singleton = c.scope().equals("singleton");
if (singleton && singletons.containsKey(type)) return singletons.get(type);
Constructor<?> constructor = type.getDeclaredConstructors()[0];
constructor.setAccessible(true);
Object[] arg = new Object[constructor.getParameterCount()];
for (int i = 0; i < arg.length; i++) arg[i] = get(constructor.getParameterTypes()[i]);
Object instance = constructor.newInstance(arg);
for (Field f : type.getDeclaredFields()) {
Value v = f.getAnnotation(Value.class);
if (v == null) continue;
f.setAccessible(true);
f.set(instance, settings.getOrDefault(v.name(), v.defaultValue()));
}
for (Method m : type.getDeclaredMethods()) {
if (m.isAnnotationPresent(Init.class)) { m.setAccessible(true); m.invoke(instance); log.add("init:" + type.getSimpleName()); }
}
if (singleton) singletons.put(type, instance);
return instance;
}
public static void main(String[] args) throws Exception {
Container container = new Container().bind(Store.class, InMemoryStore.class).configure("store.startup", "full");
Service service = (Service) container.get(Service.class);
Container.observe("same instance returned", container.get(Service.class) == service ? "default" : "annotation");
Container.observe("new instance every call", container.get(Request.class) != container.get(Request.class) ? "annotation" : "default");
Container.observe("init method called", container.log.contains("init:InMemoryStore") ? "annotation" : "default");
InMemoryStore manual = new InMemoryStore();
System.out.println("status of the container-built one : " + ((InMemoryStore) service.store).status);
System.out.println("status of the manually built one : " + manual.status);
Container k2 = new Container();
Object y1 = k2.get(WrongScope.class), y2 = k2.get(WrongScope.class);
Container.rule("scope name not recognized", y1 == y2 ? "ineffective" : "silent");
Container.summary();
}
}
interface Store { String read(); }
@Container.Component
class InMemoryStore implements Store {
@Container.Value(name = "store.startup", defaultValue = "empty") String startup;
String status = "not initialized";
@Container.Init void prepare() { status = "ready:" + startup; }
public String read() { return status; }
}
@Container.Component
class Service {
final Store store;
Service(Store store) { this.store = store; }
String report() { return store.read(); }
}
@Container.Component(scope = "prototype")
class Request { }
@Container.Component(scope = "undefined-scope")
class WrongScope { }
same instance returned default new instance every call annotation init method called annotation status of the container-built one : ready:full status of the manually built one : not initialized scope name not recognized silent behavior: 2 annotation, 1 default defect : 1 silent
This topic’s first lesson measured this same question across twelve behaviors and four
defects, arriving at a total of 3 call, 6 annotation, 3 default and 1 error, 2 silent, 1 ineffective; this lesson stays only in that core’s scope and lifecycle slice — the dependency
injected with bind and the unbound interface tryGet measures are not re-measured here.
Singleton, the Default Itself
The same instance returned line comes out default, because there is no word in Service‘s
source file at all stating its scope. @Container.Component is written bare, with no
parentheses — the default "singleton" value of scope() kicks in, and the line that gives
this value is not Service.java, it is the annotation definition in Container.java. A pattern
already seen in this topic’s first lesson sits exactly at its center here: whoever reads the
source cannot know that Service is a singleton, because the source never says so at all;
only someone who already knows the container’s rule (that the default scope is singleton) can
guess this result.
The new instance every call line is the exact opposite: it comes out annotation, because the
Request class explicitly overrides the default value by writing scope = "prototype".
This is not a Java method override, it is a default value being overwritten by an annotation
argument — container.get(Request.class) != container.get(Request.class) confirms that two
separate calls return two separate objects, and this difference comes from a word that really
stands in the source: "prototype".
The Init Method: After the Object Is Ready
The InMemoryStore class’s prepare() method carries @Container.Init and its body is a single
line: status = "ready:" + startup;. When this method is called is determined by the get
method’s own order: first the constructor runs (the instance is created), then the fields
marked Value are filled (a value is written into the startup field), then the methods
marked Init are called. This order is not random — if startup were not already filled by the
time prepare() is called, the status value would carry something meaningless like
"ready:null". The container forces this order, and there is no number or annotation parameter
in the source stating this order at all — the order is only the order of the get method’s own
code lines.
That the init method really was called is confirmed with the container.log list:
the line log.add("init:" + type.getSimpleName()) leaves a record after every call, and the
init method called line looks for the existence of this record. No real clock or sequence
number is kept here — only a list of strings answering the question “was this method called or
not.” The manually built InMemoryStore never gets this record at all: the status field stays
"not initialized" forever, because the only party that would call prepare() is the container,
and a manually built object is one the container never sees at all.
Scope and the init method look like two independent mechanisms, but the relationship between
them comes from the get method’s own order: the init block runs before writing to the
singleton cache, and it is unaware of scope — it runs once for every newly constructed
instance, regardless of scope. Since the Request class has no method marked Init at all, this
measurement does not show this directly, but the mechanism implies it: if a component scoped
prototype had an init method, that method would be called again on every request — because
prototype scope turns every request into a new construction, and every construction triggers
its own init call. In a singleton component, the init method runs exactly once over its
lifetime; in a prototype component, it would (hypothetically) run again on every request. Scope
and the init method stand as two separate annotations in the source, but this relationship
between them is written nowhere at all — it can only be extracted by reading the get method’s
own code order.
Why a Separate Step
Why would putting the init method into the constructor itself not have worked? InMemoryStore has
no constructor — Java uses the compiler-generated parameterless default constructor when no
constructor is written at all. Moving prepare() into this constructor is not possible, because
in Java a constructor’s body is only the code its own class writes; in the mechanism where the
container fills the Value fields after the constructor, the startup field is still
null the moment the constructor runs. If we had put the init logic into the constructor, that
logic would always see an empty value.
This is the reason for splitting a managed component’s lifecycle into two separate phases:
construction (the constructor running, the object existing in memory) and preparation
(settings being placed, dependent resources being opened, preparation logic running). The
constructor only guarantees the first phase; the second phase needs a separate hook, and that
hook is exactly the Init annotation’s job. In a manually built object, this second phase never
happens at all — the manual object passed through the first phase (the constructor ran, the
object exists), but it never entered the second phase.
The Request class has no fields and no init method at all; when a new instance is constructed
on every request, the container never writes it into the singletons map, so it holds no
reference at all. This measurement does not separately count the number of Request instances,
because what is measured is not duration or memory — only whether two requests get the same
object or separate ones.
Limiting Measurement: An Unrecognized Scope Name
The WrongScope class carries @Container.Component(scope = "undefined-scope") — the only
value the container recognizes is "singleton", but the annotation argument is a string, and
the compiler does not check whether this string is a value the container understands. The result
shows up on the get method’s line boolean singleton = c.scope().equals("singleton");:
"undefined-scope".equals("singleton") returns false, the singleton variable becomes
false, and the code constructs a new instance every time, without ever performing the singleton
check.
Object y1 = k2.get(WrongScope.class), y2 = k2.get(WrongScope.class);
Container.rule("scope name not recognized", y1 == y2 ? "ineffective" : "silent");
y1 == y2 comes out false — two requests are two separate objects. This is why the rule line
falls into the "silent" class: no exception is thrown, no log line says “unknown scope,” the
program keeps running without a problem. Only the expected behavior (staying a singleton)
silently disappears. The only way to notice this defect is to compare two requests’ identity
with == — a check looking at value equality (are two objects’ fields the same) catches nothing
at all, because WrongScope has no fields, so both look “equal.” The defect is only visible to
an eye looking at identity, and throughout this lesson identity was always read with ==, an
identity hash was never printed even once.
This is a design choice from the same family as the previous lesson’s unbound interface
observation: there too, the container did not scan the dependency graph while being built, it
only checked when requested. Here too, the scope name is read not at the moment it is written on
the Component annotation, but at the moment get is called, and the only recognized value is
compared with a string equality — a small spelling difference ("Singleton" instead of
"singleton", or "undefined-scope") passes the compiler without it ever seeing it. Both
lessons arrive at the same conclusion: the container not giving an early warning is not a gap,
it is this model’s deliberate limit — validation is possible not by reading the source, but only
by actually running the covered behavior.
Summary
- Singleton scope is the container’s default; even if there is no word in the source stating
this,
Component’sscope()value takes effect. - Making a managed component be reconstructed on every request requires an explicit
scope = "prototype"argument — this is a behavior born from a word that really stands in the source. - The init method (the lifecycle hook) is called exactly once, after the constructor and the
value fields are complete; the order is determined by the
getmethod’s own code order. - A manually built object never receives the init method at all; its field always stays at its starting value.
- An unrecognized scope name throws no error at all — the component silently stops being a
singleton, and this is only visible through an identity comparison done with
==.
Next Step
The prepare() method fills the status field by reading the value written into the startup
field, and that startup field is itself fed by its own annotation:
@Container.Value(name = "store.startup", defaultValue = "empty"). This lesson never asked
where that value comes from — it only assumed it was filled by the time of initialization. The
next lesson opens exactly this question: which source does a managed component’s setting value
win from, out of a precedence order that never appears anywhere in the source, and why is that
order invisible to someone looking from outside.
To keep your progress and take notes, Log in
My notes
Log in to take notes.