Skip to content
academia.sh

Lesson 02 / 19

Dependency Injection

When an IoC container supplies a dependency through the constructor, it is born from an annotation; the same dependency given by hand is born from a call standing in the source — and the same class works with both. When no implementation is bound to an interface, the container throws an exception immediately, but not while the container is being built — at the first request.

Published Updated

In this lesson

  • Shows with reflection that constructor-based injection supplies a dependency without any assignment at all in the source
  • Proves by running it that both the container-built and the manually built form of the same class work
  • Measures when and how an unbound interface produces an exception in the container
Contents

The previous lesson closed by saying that the new Service(manual) line was hand-imitating a mechanism inside the object container: giving a dependency to a constructor. The Service class’s single constructor takes a Store parameter, and the container resolves this parameter on its own while constructing Service — without ever writing new InMemoryStore() in the source. This lesson’s question is: is this resolution really an injection, or is it just another name for construction? And more importantly: does the same Service class also work when its dependency is given by hand?

Dependency injection itself is not a new idea in this course — the Design Patterns course established it as a pattern that provides the separation between principle and detail, the cornerstone of testable design. This lesson does not re-grind that pattern; it measures which mechanism the container applies it with and what it leaves in the source. The question asked is the same: did this behavior come from a call in the source, an annotation, or neither?

The Container Fills the Constructor’s Inside

The container’s get method, when a type is requested, first finds that type’s constructor, then walks through the constructor’s parameter types one by one and, for each one, calls itself again: arg[i] = get(constructor.getParameterTypes()[i]);. Since Service’s only parameter is Store, this loop runs once and calls get(Store.class); since Store is an interface, the container looks it up in the bindings map, constructs the InMemoryStore it finds, and returns it. In the end, when constructor.newInstance(arg) is called, arg[0] is already a ready InMemoryStore instance — there is no line in the source that fills this value at all, filling it happens entirely inside this recursive loop.

This is the definition of constructor-based injection: a dependency is resolved before the object is constructed and passed to the constructor as an argument. Injection through a field (the object first being constructed and then a value being written to its field through reflection) is not built at all in this topic — the container only fills constructor parameters, because Service‘s store field is final and is assigned in the constructor. This constraint is deliberate: a final field cannot be changed after it is constructed, so it is impossible for Service to exist without its dependency — the compiler itself enforces this, not the container’s courtesy.

The constructor.setAccessible(true) line is worth a separate observation. Service‘s constructor carries no access modifier at all, meaning it has package visibility; an ordinary new Service(store) call would work with this visibility anyway, since it would come from the same package too. But the container itself constructs Service through a general-purpose Class<?> object, without knowing in advance which package it came from; reflection’s normal access check steps in in this case, and without the setAccessible(true) call it would throw an IllegalAccessException. This line is the source of the container’s guarantee that it can “construct every managed component regardless of its visibility” — and this guarantee, too, is a decision that never shows up in the source, it only stands in the container’s own code.

The container’s constructor selection also carries an assumption of its own: the get method makes no selection at all, it says directly type.getDeclaredConstructors()[0] — it assumes from the start that the class has exactly one constructor, and that even if there is more than one, the first is correct. Since both Service and InMemoryStore have a single constructor, this measurement never stresses this assumption, but the container itself never checks it anywhere. If a second constructor had been added — an overload like Service(Store store, String label) — the order of the array returned by getDeclaredConstructors() is not guaranteed by the Java language specification; the container could not know in advance which constructor it would choose, and if the chosen constructor’s parameter carried a type that could not be resolved as a managed component (like String), the recursive get call would throw an exception. This is a fragility this lesson does not measure but the model carries: constructor-based injection rests on the assumption that a class has a single way to be constructed, and this assumption is not written explicitly in the source at all — it can only be understood by looking at how many constructors the class declares.

The injection measured by this topic is also, at the same time, a narrow injection: the constructor can only request another managed component or an interface, not a bare value like String or int. Filling a value-typed dependency requires a separate mechanism — the Value annotation — and this measurement does not build that at all; this is exactly why InMemoryStore returns its own data as a fixed value.

  • CC6. Constructor parameter resolution is depth-first: if a parameter has its own dependency, that too is resolved first, through the same get call.
  • CC7. Service’s store field is final; this guarantees at compile time that the dependency cannot be assigned through any path in the source other than the constructor.
  • CC8. The same InMemoryStore instance is constructed both through the container and by hand, and called through the same interface; the measured difference is the construction style, not the call contract.
  • CC9. The bindings map holds only interface–implementation pairs; the decision of which implementation is selected sits not in the source, but in the bind call itself.
  • CC10. When an unbound interface is requested, the container throws an exception naming which type is missing; this exception is not caught, it surfaces to the party making the first request.
// Container.java — did a call in the source produce this dependency, or an annotation
import java.lang.annotation.*;
import java.lang.reflect.*;
import java.util.*;

public class Container {

    @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.TYPE)
    @interface Component {}

    static int call = 0, annotation = 0;

    static void observe(String behavior, String source) {
        if (source.equals("call")) call++; else annotation++;
        System.out.printf("%-38s %s%n", behavior, source);
    }

    static void summary() {
        System.out.printf("behavior: %d call, %d annotation%n", call, annotation);
    }

    final Map<Class<?>, Class<?>> bindings = new HashMap<>();

    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());

        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]);
        return constructor.newInstance(arg);
    }

    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);

        Service service = (Service) container.get(Service.class);
        Container.observe("dependency supplied", service.store != null ? "annotation" : "call");

        InMemoryStore manual = new InMemoryStore();
        Container.observe("direct method call", "call");
        Container.observe("dependency given by hand", "call");

        Service manualService = new Service(manual);
        System.out.println("value returned through the container : " + service.report());
        System.out.println("value returned through manual wiring  : " + manualService.report());

        Container.summary();
    }
}

interface Store { String read(); }

@Container.Component
class InMemoryStore implements Store {
    public String read() { return "data"; }
}

@Container.Component
class Service {
    final Store store;
    Service(Store store) { this.store = store; }
    String report() { return store.read(); }
}
dependency supplied                    annotation
direct method call                     call
dependency given by hand               call
value returned through the container : data
value returned through manual wiring  : data
behavior: 2 call, 1 annotation

This topic’s first lesson measured this same question across twelve behaviors and four defects, and arrived 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 dependency-supplying slice.

Two Paths, the Same Contract

The dependency supplied line comes out annotation because the service.store field fills with no source line at all — the container gave it to the constructor. The dependency given by hand line comes out call because the same field fills through an argument that really stands in the source, in the new Service(manual) call. The only difference is where it came from; Service itself does not know which path constructed it, and it does not need to — the store field looks at the same Store interface in both cases.

This connects directly to testability: service.report() and manualService.report() both work, both return the same "data" string, and the direct method call line proves this separately by calling manual.read() directly, with the container never stepping in at all — Service can be built by hand and tested. The principle the Design Patterns course established becomes concrete right here: Service depends on an interface, not on a specific implementation, which is why passing a fake Store implementation in place of the real InMemoryStore during a test is possible without changing a single line of Service. The container does not apply this principle — Service itself was already written according to this principle; the container only records which implementation corresponds to which interface, through the bind call, and hands it back at the right moment.

The bindings map itself is also a measurement point: the method Container bind(Class<?> iface, Class<?> implementation) is explicitly called in the source — new Container().bind(Store.class, InMemoryStore.class) is a call, not an annotation. The decision of which implementation is chosen sits not on the classes, but inside the code that builds the container. This is the lesson’s second observation: even though injection itself is born from an annotation, the decision of which implementation gets injected comes from a line that really stands in the source, one that writes it.

The importance of this distinction becomes visible once a second Store implementation is put in InMemoryStore’s place. The Service class only mentions the Store interface — in the constructor signature, in the report() body, InMemoryStore’s name appears nowhere at all. If the line bind(Store.class, AnotherStore.class) replaced bind(Store.class, InMemoryStore.class), not a single character in Service’s source file would change; the only thing that changes is the line that builds the container. The principle and the detail the Design Patterns course separated stand exactly at this boundary: the principle is the Store interface, the detail is the InMemoryStore class, and the decision that connects them is written not inside Service but outside the container. What this topic measures is that this decision, too, leaves a trace in the source — just not inside Service.

A small side note: since bindings is a map, calling bind twice for the same interface silently overwrites the first with the second — no exception, no warning. This lesson does not measure this separately, but it is an observation from the same family as the third lesson’s scope measurement: writing to a map carries a different rule than adding to a list.

Limiting Measurement: An Unbound Interface

In the setup above, Store was always bound to InMemoryStore. What happens if this binding is skipped?

// InterfaceError.java — the container throws an error if no implementation is bound to the interface, and exactly at the first request
public class InterfaceError {
    public static void main(String[] args) throws Exception {
        Container unbound = new Container();
        System.out.println("container built, no request made yet");
        System.out.println("number of bound implementations       : " + unbound.bindings.size());
        String result = unbound.tryGet(Service.class);
        System.out.println("exception thrown when Service requested : " + result);
    }
}
container built, no request made yet
number of bound implementations       : 0
exception thrown when Service requested : IllegalStateException

The new Container() line performs no check at all — even if bind was never called, the container is constructed without a problem, the bindings map stays empty, and no warning comes out. The defect falls when Service is really requested: get(Service.class), while trying to resolve its constructor parameter, enters get(Store.class), sees that Store is an interface, bindings.get(Store.class) returns null, and the container throws an IllegalStateException explicitly naming the type. This is the only noisy one of this course’s four defects — the remaining three (a misspelled setting name, an unrecognized scope name, a wrap marker placed on an interfaceless class) stay silent or ineffective, but an unbound interface does not leave the container silent.

Timing matters here: the error falls not while the container is built, but at the first request. A program can execute new Container().bind(...) lines and move on without getting any error, then crash minutes later the first time Service is requested. The moment the container is built and the moment the dependency graph is actually validated are not the same moment — this distinction determines when you will find out if there is a missing link between managed components.

The Container in this measurement was deliberately left in its simplest form: the get method notices a missing binding only when that binding is actually requested, it does not scan and validate the entire dependency graph in advance the moment the container is built. As a result, a missing binding can go unnoticed for as long as the program never runs that path — unlike a type mismatch the compiler could catch, this defect only surfaces once the relevant code path is actually run.

This choice carries a trade-off: early validation (scanning the whole graph while the container is built and finding missing bindings in advance) risks crashing right at the program’s start but catches the error before it reaches production; late validation (the path this measurement chose) keeps the container’s construction fast and simple, but a missing link can go unnoticed for as long as that code path is never run. This lesson models the second path, because the container itself is nothing more than a decision that does not show up in the source either — when the get method validates is, just like where a dependency comes from, a choice that can only be understood by reading the container’s own code.

Summary

  • Constructor-based injection is the mechanism that resolves a dependency before the object is constructed and gives it to the constructor as an argument; there is no assignment anywhere in the source that fills this value.
  • The same class can be constructed both through the container and by hand, and both work — as long as the dependency is bound to an interface, the container steps in without breaking testability.
  • The decision of which implementation is injected into which interface comes from a bind call that really stands in the source; this is a behavior born from a call, not an annotation.
  • When an unbound interface is requested, the container throws an exception immediately and by name — this is the only noisy one of the four defects.
  • This error falls not on the line where the container is built, but at the first moment the dependency is really requested; construction and validation do not happen at the same time.

Next Step

This lesson measured where Service‘s dependency came from, but it never asked when Service itself is reconstructed by the container — does container.get(Service.class) return the same instance every time it is called, or does it construct a new one each time? The Component annotation has a scope field, and this lesson never used it at all; the first lesson’s Service staying a singleton, and Request‘s reconstruction on every call, both came from exactly this field’s value. The next lesson opens this field: when does a managed component stay the same, when is it reconstructed, and at exactly what moment is the init method called?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close