On this page
- 1. · Concept & Definition
- 2. · Why It Matters
- 3. · How It Works
- Deep Dive — Where Control Actually Changes Hands
- 4. · Java / Jakarta EE Code Examples
- 5. · Configuration, Defaults & Diagnostics
- 6. · Production Usage Patterns
- 7. · Common Pitfalls & Misconceptions
- 8. · Best Practices
- 9. · Architect's Perspective & Trade-offs
- 10. · Interview Q&A
1· Concept & Definition
Inversion of Control (IoC) is the principle that a framework, not application code, controls the flow of execution: the framework creates objects, calls their methods and manages their lifetime, while the application supplies components that plug into it. Dependency injection is one form of IoC (for obtaining dependencies); others include event callbacks, template methods and servlet containers calling service().

2· Why It Matters
- Explains why enterprise code consists of annotated components rather than a
main()that wires everything. - Defines the division of responsibility between developer and container.
- Lifecycle management by the container is the basis for scopes, pooling and resource cleanup.
IoC also changes what a developer is responsible for. In a plain Java program the author decides when objects are built, in what order and when they are discarded; in a Jakarta EE application those decisions belong to the container, and the developer's job is to describe components accurately enough for it to make them. That shift is why annotations such as @ApplicationScoped, @Inject and @Observes carry so much meaning, and why defects that look like coding mistakes, a null field, a transaction that never started or an observer that never fires, are so often caused by an object the container did not create. Knowing where control sits is the quickest way to explain them.
3· How It Works
IoC
| IoC form | What is inverted | Jakarta EE example |
|---|---|---|
| Dependency injection | Obtaining collaborators | @Inject |
| Callbacks / events | Who calls whom on events | @Observes, servlet listeners |
| Lifecycle management | Creation and destruction | @PostConstruct, @PreDestroy |
| Framework dispatch | Request handling flow | FacesServlet, JAX-RS resource methods |
| Template method | Algorithm skeleton | HttpServlet.service() → doGet() |
| Alternative | How dependencies are obtained | Drawback |
|---|---|---|
| Direct construction | new Impl() | Tight coupling |
| Service locator | Locator.lookup(Service.class) / JNDI lookup | Hidden dependencies, runtime failures |
| Dependency injection | Supplied by container | Requires container or manual composition root |
Container responsibility

| Responsibility | In Jakarta CDI |
|---|---|
| Discovery | Scans archives according to beans.xml discovery mode |
| Validation | Checks all injection points at deployment |
| Instantiation & injection | Creates instances lazily in their context, injects dependencies |
| Lifecycle | Calls callbacks, destroys instances when contexts end |
| Enrichment | Client proxies, interceptors, decorators |
| Events | Delivers events to observer methods |
Lifecycle
Because the container creates objects, it also controls their lifetime: which instance is shared with whom (scope), when initialisation happens (@PostConstruct) and when resources are released (@PreDestroy). Application code declares the desired lifecycle with annotations instead of managing it manually. Chapter 3 covers the CDI lifecycle in depth.
Deep Dive — Where Control Actually Changes Hands
A practical way to reason about IoC is to ask, for every object, who called new. If the container created it, the object is managed: it receives injection, its lifecycle callbacks run, and calls reach it through a proxy that can apply interceptors, transactions and security. If application code created it, it is an ordinary object and none of those services exist for it, even when its class carries exactly the same annotations.
The second hand-over point is the call itself. A call from one bean to another passes through the container's proxy, but a call from one method to another on the same instance does not, so an interceptor binding or @Transactional on the inner method is silently ignored. The third is time: the container decides when a bean is created, so normal-scoped beans are instantiated lazily on the first call through their proxy, and a bean whose @PostConstruct must run at start-up has to observe the Startup event or be touched explicitly.
@ApplicationScoped
public class InvoiceService {
@Inject AuditLog audit; // injected: managed
@Transactional
public void issue(Invoice i) {
audit.record(i); // via proxy: interceptors apply
this.notifyCustomer(i); // self-call: no interception
}
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void notifyCustomer(Invoice i) {
// NOT a new transaction when called from issue()
}
}
// Created with new: no injection, no callbacks, no transactions
InvoiceService plain = new InvoiceService(); // plain.audit == null4· Java / Jakarta EE Code Examples
// lifecycle: one instance per application
@ApplicationScoped
public class ExchangeRateCache {
@Inject RateClient client; // DI: supplied by container
private final Map<String, BigDecimal> rates = new ConcurrentHashMap<>();
@PostConstruct void warmUp() { // callback: container decides when
rates.putAll(client.fetchAll());
}
// event IoC
void onRatesChanged(@Observes RatesChanged e) { rates.putAll(e.rates()); }
@PreDestroy void shutdown() { rates.clear(); }
}// Service locator (for contrast) — the component pulls its dependency
public class LegacyService {
public void run() throws NamingException {
DataSource ds = (DataSource) new
InitialContext().lookup("java:comp/env/jdbc/app");
// hidden dependency, failure only at runtime, hard to test
}
}// Bootstrapping a CDI container in Java SE (CDI Full / Weld SE)
try (SeContainer container = SeContainerInitializer.newInstance().initialize()) {
ReportService svc = container.select(ReportService.class).get();
svc.sendDailyReport();
}5· Configuration, Defaults & Diagnostics
| Item | Note |
|---|---|
| Container implementations | Weld (reference), Apache OpenWebBeans, Quarkus ArC (CDI Lite, build time) |
| Startup validation | All injection points resolved at deployment |
| Startup/shutdown events | @Observes Startup / Shutdown (CDI 4.0+) |
| Java SE bootstrap | SeContainerInitializer (CDI Full) |
Because a CDI container validates the whole graph at deployment, wiring mistakes surface as start-up failures with precise messages, and reading them is the main diagnostic skill. An unsatisfied dependency means no bean matches the injection point's type and qualifiers, usually because the implementation sits in an archive without bean discovery, lacks a bean-defining annotation in annotated mode, or was vetoed. An ambiguous dependency means two or more beans match, and a qualifier or a prioritised alternative is needed. Weld Probe in development mode and the Quarkus Dev UI list every discovered bean, which quickly shows whether the expected implementation was found at all.
WELD-001408: Unsatisfied dependencies for type RateClient
with qualifiers @Default
at injection point [BackedAnnotatedField] @Inject ExchangeRateCache.client
# fix: give the implementation a bean-defining annotation
# (e.g. @ApplicationScoped) or use bean-discovery-mode="all"
WELD-001409: Ambiguous dependencies for type RateClient
Possible dependencies: [EcbRateClient, CachedRateClient]
# fix: add a qualifier, or mark one @Alternative @Priority(1)6· Production Usage Patterns
- All wiring delegated to the container; no
main()-style composition in Jakarta EE apps. - Build-time containers (Quarkus ArC) resolving the graph at build time for fast startup.
- Startup observers initialising resources instead of static initialisers.
7· Common Pitfalls & Misconceptions
- Mixing JNDI lookups and injection inconsistently.
- Static state that bypasses the container — invisible to lifecycle and tests.
- Heavy work in constructors instead of
@PostConstruct.
8· Best Practices
- Let the container create and wire all managed components.
- Declare lifecycle with scopes and callbacks, not manual singletons.
- Use startup events for initialisation logic.
9· Architect's Perspective & Trade-offs
| Container style | Resolution time | Strength | Trade-off |
|---|---|---|---|
| Runtime reflection (Weld, Spring) | Deployment/startup | Full dynamism, portable extensions | Startup time, reflection |
| Build-time (Quarkus ArC, Micronaut, Dagger) | Build | Fast startup, native-image friendly | Less runtime dynamism (CDI Lite) |
10· Interview Q&A
What is Inversion of Control?
Inversion of Control means the framework, not your code, drives execution: it creates components, decides when their methods are called and ends their lifetime, while the application supplies pieces that plug into it. The usual summary is the Hollywood principle, don't call us, we'll call you. A servlet never runs its own loop; the container calls service() when a request arrives. A CDI bean never constructs its collaborators; the container injects them and then invokes @PostConstruct. Because the framework owns creation and invocation, it can wrap every call with transactions, security and observability, which is why IoC underpins every enterprise Java platform, from Jakarta EE to Spring.
How does dependency injection relate to IoC?
Dependency injection is one specific form of IoC, the one that inverts how a component obtains its collaborators. Without it, a class creates them with new or looks them up; with it, the class declares what it needs, through a constructor, field or initializer method marked @Inject, and the container supplies a suitable instance. IoC is broader: event observers, lifecycle callbacks, template methods such as HttpServlet.service() calling doGet(), and framework dispatch of Jakarta REST resource methods all invert control without injecting anything. So every DI container is an IoC container, but not every IoC mechanism is dependency injection, and treating the terms as synonyms is a common sign of shallow understanding.
What is the difference between DI and a service locator?
Both decouple a component from concrete implementations, but they differ in who acts. With a service locator, such as a JNDI lookup or a static registry, the component pulls its dependencies, so they are hidden inside method bodies, a missing binding fails only when that code path runs, and tests must prepare the global registry. With DI the dependencies are pushed in from outside and visible in the constructor or fields, the container validates them at deployment, and tests simply pass mocks. A locator still has a place for genuinely dynamic choices, such as selecting a plugin by name at run time, and in CDI the idiomatic equivalent is Instance<T> with qualifiers.
What responsibilities does a CDI container take over?
A CDI container takes over the whole life of a managed bean. At deployment it discovers beans according to the bean discovery mode, builds the bean graph and validates every injection point, failing with an unsatisfied or ambiguous dependency error before any request arrives. At run time it creates instances lazily within their scope, injects dependencies, calls @PostConstruct, and destroys instances with @PreDestroy when the context ends. For normal-scoped beans it injects client proxies, so a request-scoped bean can safely be used from an application-scoped one. It also applies interceptors and decorators, delivers events to observer methods, and lets portable or build compatible extensions modify the bean model.
What is the advantage of build-time dependency resolution?
The container's analysis moves from start-up to build. Quarkus ArC, Micronaut and Dagger scan annotations, resolve injection points and generate plain wiring code during compilation, so the running application performs no classpath scanning and little reflection. Start-up drops from seconds to tens of milliseconds, memory falls because no metadata model is retained, and wiring errors fail the build rather than the deployment. It also makes GraalVM native images practical, since there is little runtime reflection to register. The trade-off is dynamism: CDI Lite has no portable extensions, beans cannot be added at run time, and reflection-heavy libraries need extra configuration. For containerised and serverless workloads the trade is usually worth it.
Why is IoC essential for enterprise platforms?
Because a platform can provide services uniformly only to code whose creation and invocation it controls. When the container instantiates a bean and hands out a proxy, it can begin and commit transactions, check roles, propagate security and trace context, apply retries or metrics, and release resources when a scope ends, all without the business code calling anything. Teams get consistent behaviour across hundreds of services, and platform teams can upgrade those concerns centrally. Code that bypasses the container through static singletons or new silently loses the guarantees: a method on an object created with new runs without a transaction. IoC is therefore the contract between application code and the platform.