Skip to content
academia.sh

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 singletons map only for the singleton value; 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 Init annotation) 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’s scope() 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 get method’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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close