Lesson 01 / 19
Inversion of Control
An IoC container derives an object's construction, the return of the same instance, and a manually new-built instance receiving none of the annotations at all from three separate sources: of twelve behaviors, three come from a call in the source, six from an annotation alone, three from the container's own default.
Published Updated
In this lesson
- Distinguishes, using reflection, which expression constructs an object in the source from the case where the same instance returns without construction
- Runs an instance built by the object container side by side with one built manually with new and shows the difference
- Proves by running it that an object left outside the container receives none of the annotations at all
Contents
The closing lesson of the Build, Testing and Ecosystem course measured that the source text does
not say where a class enters the runtime class path from: the calling side only saw an
interface, it never mattered whether the class behind it had passed through a Java compiler.
This lesson carries the same gap one step further. There, what was missing was where a type
came from; here, what is missing is who constructs an object. In a program that uses an
application framework, someone searching for the new keyword most often finds nothing at
all — the objects exist, they work, but the line that constructs them is not in the source.
The mechanism that fills this gap is inversion of control: not the program itself, but an IoC container running the program decides when an object is constructed, with which dependencies, and how many times. Normally, the code that writes a program’s flow determines it — the writing side decides which class is instantiated and in what order it is called. When the container takes over this decision, control changes direction: the party deciding when a class is constructed is no longer the one who wrote that class, it is the container itself.
This lesson models that container — no real framework’s name, annotation syntax, or
behavior is used here; what is built is only a small class written with the standard library’s
own reflection (java.lang.reflect) and annotation type (java.lang.annotation) tools. The
question asked stays the same throughout the course: did a call in the source produce a
behavior, an annotation, or a default that leaves no trace in the source at all?
What the Container Models
The Container class built below recognizes four annotation types: Component, which
marks that a class falls under the container’s management and carries an optional scope;
Init, which requests that a method be called once the object is ready; Wrap, which
requests that a class be placed behind a call-counting proxy; Value, which requests that a
field be filled with a value supplied from outside. All four are the container’s own
definitions — this lesson’s vocabulary is contributed by this course, not by
docs/TERMINOLOGY.md.
The object the container manages is referred to in this course as a managed component. A
managed component is nothing more than an instance of a class carrying the Component annotation; the
container constructs it, resolves the dependencies it needs through the constructor, calls a
method marked Init if one exists, and keeps the same instance if scope is singleton. A
managed component’s construction follows these steps: find the constructor, resolve its
parameters in order (since each parameter can itself be a managed component, this step calls
itself), create the instance, fill the fields marked Value, call the method marked Init, and
if marked Wrap, place it behind a proxy. This lesson measures only the first two steps in
depth — construction and scope; Init, Value, and Wrap are the subjects of this topic’s
third, fourth, and sixth lessons respectively, and appear here only as part of the model.
- CC1. The IoC container is built here as a model; no real framework’s name, annotation
syntax, or API is used.
Component,Init,Wrap,Valueare this lesson’s own annotation types. - CC2. Identity is compared only with
==; no object’s identity hash is ever printed. - CC3. Singleton scope is the container’s default scope; every managed component is a singleton unless a different one is explicitly requested with an annotation.
- CC4. Constructor dependencies are resolved recursively by the container; supplying a dependency through a field is not built in this topic.
- CC5. The measurement contains no real network, database, or file system call; the store is a field held in memory.
Construction Invisible in the Source
The program below builds a small two-component system: Store is an interface, InMemoryStore
is the only class implementing it, and Service is a second managed component depending on
Store. Nowhere is new InMemoryStore() or new Service(...) written — these objects appear on
their own when the container’s get method is called. The second third of the program compares
this same InMemoryStore, built by hand, against the one built by the container; the third
third records where a broken annotation lands.
// Container.java — did a call in the source produce this behavior, an annotation, or the container's own 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.TYPE)
@interface Wrap {}
@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.FIELD)
@interface Value { String name(); String defaultValue(); }
static int call = 0, annotation = 0, byDefault = 0;
static int error = 0, silent = 0, ineffective = 0;
static void observe(String behavior, String source) {
switch (source) {
case "call" -> call++;
case "annotation" -> annotation++;
default -> byDefault++;
}
System.out.printf("%-38s %s%n", behavior, source);
}
static void rule(String name, String result) {
switch (result) {
case "error" -> error++;
case "silent" -> silent++;
default -> ineffective++;
}
System.out.printf("%-38s %s%n", name, result);
}
static void summary() {
System.out.printf("behavior: %d call, %d annotation, %d default%n",
call, annotation, byDefault);
System.out.printf("defect : %d error, %d silent, %d ineffective%n", error, silent, ineffective);
}
final Map<Class<?>, Object> singletons = new HashMap<>();
final Map<String, String> settings = new LinkedHashMap<>();
final List<String> log = new ArrayList<>();
int wrapCalls = 0;
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 (type.isAnnotationPresent(Wrap.class) && type.getInterfaces().length == 1) {
Object target = instance;
instance = Proxy.newProxyInstance(type.getClassLoader(), type.getInterfaces(),
(p, m, a) -> { wrapCalls++; return m.invoke(target, a); });
}
if (singleton) singletons.put(type, instance);
return instance;
}
String tryGet(Class<?> type) {
try { get(type); return "ok"; }
catch (Exception e) { return e.getClass().getSimpleName(); }
}
public static void main(String[] args) throws Exception {
Container container = new Container().bind(Store.class, InMemoryStore.class).configure("store.startup", "full");
System.out.println("-- what produced the behavior --");
Service service = (Service) container.get(Service.class);
Container.observe("object construction", "annotation");
Container.observe("dependency supplied", service.store != null ? "annotation" : "call");
Container.observe("same instance returned", container.get(Service.class) == service ? "default" : "call");
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" : "call");
Container empty = new Container().bind(Store.class, InMemoryStore.class);
Service defaulted = (Service) empty.get(Service.class);
Container.observe("setting's default value", defaulted.report().equals("ready:empty") ? "default" : "call");
Container.observe("setting given from outside", service.report().equals("ready:full") ? "annotation" : "default");
int previousWraps = container.wrapCalls;
service.report();
Container.observe("call wrapped", container.wrapCalls > previousWraps ? "annotation" : "default");
InMemoryStore manual = new InMemoryStore();
Container.observe("wrapping on a manually built object", Proxy.isProxyClass(manual.getClass()) ? "annotation" : "default");
Container.observe("direct method call", manual.read().equals("not initialized") ? "call" : "annotation");
Container.observe("manual construction with new", "call");
Container.observe("dependency given by hand", new Service(manual).report().equals("not initialized") ? "call" : "annotation");
System.out.println("-- where annotation defects land --");
Container.rule("unannotated type requested", container.tryGet(Unannotated.class).equals("ok") ? "silent" : "error");
Container k2 = new Container().bind(Store.class, InMemoryStore.class).configure("store.startup", "full");
WrongSetting ws = (WrongSetting) k2.get(WrongSetting.class);
Container.rule("setting name misspelled", ws.value.equals("full") ? "ineffective" : "silent");
System.out.println("expected value full, seen : " + ws.value);
Object y1 = k2.get(WrongScope.class), y2 = k2.get(WrongScope.class);
Container.rule("scope name not recognized", y1 == y2 ? "ineffective" : "silent");
Object noIface = k2.get(InterfacelessComponent.class);
Container.rule("wrap put on an interfaceless class", noIface instanceof Proxy ? "error" : "ineffective");
Container.summary();
}
}
interface Store { String read(); }
@Container.Component @Container.Wrap
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 { }
class Unannotated { }
@Container.Component @Container.Wrap
class InterfacelessComponent { String hello() { return "hello"; } }
@Container.Component(scope = "undefined-scope")
class WrongScope { }
@Container.Component
class WrongSetting {
@Container.Value(name = "store-startup", defaultValue = "empty") String value;
}
-- what produced the behavior -- object construction annotation dependency supplied annotation same instance returned default new instance every call annotation init method called annotation setting's default value default setting given from outside annotation call wrapped annotation wrapping on a manually built object default direct method call call manual construction with new call dependency given by hand call -- where annotation defects land -- unannotated type requested error setting name misspelled silent expected value full, seen : empty scope name not recognized silent wrap put on an interfaceless class ineffective behavior: 3 call, 6 annotation, 3 default defect : 1 error, 2 silent, 1 ineffective
Three Sources, Twelve Behaviors
The output’s first line was never printed by any code — the call Container.observe("object construction", "annotation") just gives a fixed string, because the service variable had
already returned from the container.get(Service.class) call, and what produced that call was
not a line in the source at all, it was the @Container.Component annotation on the Service
class. This topic’s opening claim sits exactly here: constructing an object is not an action,
it is an effect. Whoever reads the program sees the line
Service service = (Service) container.get(Service.class);, but this line does not say how
Service is to be constructed — it only asks the container for an instance. The construction
decision itself sits in the annotation on Service.class.
The second line — container.get(Service.class) == service — makes the same request twice and
gets the same object both times. This is the container’s default decision: when the
Component annotation does not specify a scope, the container chooses singleton scope, and
this choice is not written anywhere in the source. We can see the value scope = "singleton" on
the line @interface Component { String scope() default "singleton"; }, but the Service class
itself never mentions this value at all — the annotation is bare @Container.Component, with no
parameter. Knowing where the default comes from requires looking at the container’s own source
code; looking at Service.java is not enough.
The final third of the program builds the same InMemoryStore class by hand:
InMemoryStore manual = new InMemoryStore();. This line really does stand in the source as a
call — and it produces an object that differs from the one the container builds in three
ways. The output’s final call line traces back to the same root: new Service(manual) is also a
manually written dependency handoff; the container never steps in at all. The three call lines
standing in the source — a direct method call, a manual new, a manual dependency handoff — all
confirm the same observation: every line that bypasses the container is a line that really
appears in the source.
The remaining behaviors are the subject of this topic’s later lessons and are only pointed to here: “dependency supplied” and “dependency given by hand” are the second lesson’s (Dependency Injection), “new instance every call” and “init method called” are the third lesson’s (Component Scopes and Lifecycle), “setting’s default value” and “setting given from outside” are the fourth lesson’s (Configuration Sources), “call wrapped” is the sixth lesson’s (Aspect-Oriented Programming). Of the twelve behaviors, three come from a call in the source, six from an annotation, three from the container’s own default — someone reading the source from top to bottom cannot see three-quarters of these behaviors.
The Object Left Outside the Container
The manually built InMemoryStore never received three things: the prepare lifecycle hook was
never called (the status field still reads "not initialized"), no Value was placed into the
startup field (the field stayed null; the read() method never uses it so it did not show up
in the output, but this line does not observe that at all), and the object was not placed behind
a proxy — Proxy.isProxyClass(manual.getClass()) returned false. All three of these are the
container’s default, but a strange kind of default: the container never getting involved at
all, the class staying in its own bare form. The annotation keeps standing on the class —
InMemoryStore is still @Container.Component, still @Container.Wrap — but there is no one
reading it.
This topic’s rule is: an annotation is a request, not a feature; when no container exists
to fulfill the request, nothing happens at all — no error, no warning. The manual object is a
completely valid Java object, produced without throwing any exception, carrying no defect flag —
it only did not receive the three things the container was supposed to give. This is not a
separate defect from the behaviors measured through this lesson; it is the container’s own
boundary. Everything an IoC container gives exists only to the extent that container gave it.
The lesson’s second rule block shows a different defect: the Unannotated class carries no
@Container.Component at all, and the container throws an exception immediately when an
instance is requested from it — this is the only noisy one of this course’s four defects.
The defect falls not at the line where the container is built, but at the moment
container.get(Unannotated.class) is called — that is, at the first request: the
new Container() line itself performs no check at all, it does not scan in advance which
classes are annotated. The other three defects — a misspelled setting name, an unrecognized
scope name, a wrap marker placed on an interfaceless class — are measured in depth in this
topic’s later lessons; here they only appear in the summary line.
Summary
- Inversion of control is the decision of when and how an object is constructed passing from the writing side to an IoC container; the container reads this decision from annotations on the class.
- Making the same request twice gives the same instance — this is the container’s singleton scope default, and it is written nowhere in the source.
- An object built by hand with
newarises from a call that really stands in the source, and never enters the container’s management at all. - An object left outside the container receives none of the annotations on it, even though those annotations keep standing there — an annotation is a request, and if there is no one to read it, it goes unanswered.
- When a type with no annotation is requested, the container throws an exception immediately and by name; this defect falls not at the moment the container is built, but at the moment of the first request.
- Of twelve measured behaviors, three come from a call in the source, six from an annotation, three from the container’s own default.
Next Step
The manual new Service(manual) line was actually hand-imitating one of the most important
mechanisms inside the container: giving a dependency to a constructor. The container does this
by looking at Service‘s single constructor parameter, requesting that parameter’s type, and
passing in the returned object — none of these lines are in the source, but the result is the
same. The next lesson measures this mechanism itself: why the difference between a dependency
being supplied and being given by hand is not just a readability preference, but a testable
design decision.
To keep your progress and take notes, Log in
My notes
Log in to take notes.