CDI — Contexts & Dependency Injection

The Jakarta EE standard for DI — how it compares to Spring, why proxies exist, and what bean-discovery-mode controls

← Back to Index

What is CDI, and How Does it Relate to Spring DI?

CDI (Contexts and Dependency Injection) is the Jakarta EE standard specification for dependency injection and contextual lifecycle management. Like Spring IoC, it solves the same core problem: instead of your code manually constructing dependencies with new, a container creates, wires, and manages the lifecycle of your objects.

The key distinction: CDI is a specification (defined in jakarta.inject.* and jakarta.enterprise.context.*), implemented by containers like Weld (the reference implementation, used in WildFly) and OpenWebBeans (used in TomEE). Spring DI is a framework implementation — it predates CDI and inspired parts of it, but follows its own model. When you use Quarkus, you're using CDI directly (via ArC, Quarkus's CDI implementation). When you use Spring Boot, you're using Spring DI — though Spring also honours @Inject from jakarta.inject as an alternative to @Autowired.

// BEFORE: tight coupling — impossible to test without a real Database
public class OrderService {
    private PaymentService payment = new PaymentService();  // hard dependency
    private EmailService   email   = new EmailService();    // another hard dependency
}

// AFTER CDI: loose coupling — container injects; test can inject mocks
@ApplicationScoped
public class OrderService {

    private final PaymentService payment;
    private final EmailService   email;

    @Inject  // constructor injection — preferred, same as Spring
    public OrderService(PaymentService payment, EmailService email) {
        this.payment = payment;
        this.email   = email;
    }
}
CDI vs Spring DI — the real differences, not the surface ones
AspectCDI (Jakarta EE)Spring DI
Nature Specification — multiple implementations Framework — one implementation
Core inject annotation @Inject (jakarta.inject) @Autowired (Spring-specific); also supports @Inject
Scope model Contextual — beans are contextual instances tied to a lifecycle Bean definition — prototype, singleton, request, session
Disambiguation @Qualifier (custom annotation) @Qualifier (string-based) or @Primary
Proxy model Client proxies always — even for @ApplicationScoped CGLIB proxies only when AOP is involved
Used by Quarkus, WildFly, Payara, TomEE Spring Boot, Spring Framework

Injection Styles — and Why Constructor Is the Right Default

// ✅ Constructor injection — preferred in CDI and Spring alike
@ApplicationScoped
public class ReportService {
    private final UserRepository users;
    private final PdfGenerator   pdf;

    @Inject
    public ReportService(UserRepository users, PdfGenerator pdf) {
        this.users = users;
        this.pdf   = pdf;
        // fields are final — can't accidentally be null or reassigned
    }
}

// ⚠️  Field injection — tempting but problematic
@ApplicationScoped
public class ReportService {
    @Inject private UserRepository users;  // can't be final
    @Inject private PdfGenerator   pdf;    // invisible dependencies in unit tests
    // to unit test, you need a CDI container or reflection-based injection
}
Field injection makes unit testing genuinely harder

With constructor injection you can test without any container at all: new ReportService(mockUsers, mockPdf). With field injection, the fields are private and injected by the container — in a plain unit test they remain null. You either need a CDI test runner (slow), or you resort to reflection-based injection in tests (fragile). Constructor injection is not just style; it's a testability constraint. This is identical to the argument Spring makes for constructor injection over @Autowired on fields.

// Setter injection — for optional dependencies only
@ApplicationScoped
public class NotificationService {
    private SmsGateway smsGateway;  // optional — may not be configured in all envs

    @Inject
    public void setSmsGateway(@Any Instance<SmsGateway> gateway) {
        if (gateway.isResolvable()) {
            this.smsGateway = gateway.get();
        }
    }
}

CDI Scopes — How Long a Bean Lives

@RequestScoped      // one instance per HTTP request — destroyed when the request ends
@SessionScoped      // one instance per HTTP session — must implement Serializable
@ApplicationScoped  // one instance for the app lifetime — effectively a singleton
@ConversationScoped // developer-controlled: spans multiple requests when explicitly kept alive
@Dependent          // default: one instance per injection point — no shared state
// @RequestScoped — data that belongs to exactly one request
@RequestScoped
public class RequestContext {
    private String correlationId = UUID.randomUUID().toString();
    private Instant requestStart = Instant.now();
    // getters, no setters — this data doesn't change within one request
}

// @SessionScoped — user state across multiple requests
@SessionScoped
public class UserSession implements Serializable {  // Serializable required for clustering
    private Long userId;
    private String username;
    private Set<String> roles = new HashSet<>();
    // keep small — sessions are replicated across cluster nodes
}

// @ApplicationScoped — shared state or heavyweight resources, initialised once
@ApplicationScoped
public class ConfigService {
    private Map<String, String> config;

    @PostConstruct
    public void load() {
        config = loadConfigFromDatabase();  // runs once — result cached for app lifetime
    }
}
Scope mismatch — the #1 CDI runtime bug

Injecting a shorter-lived bean into a longer-lived one is a scope mismatch. The most common case: injecting @RequestScoped directly into @ApplicationScoped. The application-scoped bean is created once; the request-scoped bean changes per request. If CDI injected the real @RequestScoped instance, the application-scoped bean would hold a stale reference after the first request ends. CDI solves this with a client proxy — see the next section. The bug manifests when a class is final or has no no-arg constructor, making it impossible to proxy, and CDI throws a deployment exception at startup.

Client Proxies — How CDI Handles Scope Differences

CDI almost never injects the real bean instance into another bean. It injects a client proxy — a generated subclass that intercepts every method call and forwards it to the correct real instance for the current context. This is what makes scope mismatches safe and what enables interceptors to work transparently.

/*
 *  @ApplicationScoped bean holds a reference to a @RequestScoped bean:
 *
 *  ┌─────────────────────────────────┐
 *  │ ConfigService (@ApplicationScoped) │
 *  │                                  │
 *  │  @Inject                         │
 *  │  RequestContext ctx;             │ ← this is NOT a real RequestContext
 *  │                                  │   it's a CLIENT PROXY
 *  └─────────────────────────────────┘
 *              │
 *              │  every method call is forwarded by the proxy
 *              ▼
 *  ┌──────────────────────────────────────────────────────┐
 *  │  Proxy looks up the correct RequestContext instance  │
 *  │  for the CURRENT thread / HTTP request               │
 *  │  and delegates the call there                        │
 *  └──────────────────────────────────────────────────────┘
 *
 *  Request 1 → proxy resolves to RequestContext instance A
 *  Request 2 → proxy resolves to RequestContext instance B
 *  The proxy reference in ConfigService never changes.
 */

@ApplicationScoped
public class AuditService {

    @Inject
    private RequestContext ctx;  // a proxy — safe to hold in @ApplicationScoped

    public void log(String action) {
        // ctx.getCorrelationId() calls through the proxy → correct per-request instance
        log.info("[{}] {}", ctx.getCorrelationId(), action);
    }
}
Three constraints the proxy mechanism imposes — violating any causes a deployment error

Because CDI generates a subclass as the proxy, the proxied class must satisfy three requirements:

  • The class must not be final — a final class cannot be subclassed
  • No method must be final — final methods cannot be overridden in the subclass
  • There must be a no-arg constructor (can be package-private or protected) — the proxy needs to instantiate without arguments

Violation is a deployment error at startup — a javax.enterprise.inject.spi.DeploymentException — not a NullPointerException at runtime. This is better than it sounds: it fails fast and loudly, before any request is served. The same three constraints apply to Spring's CGLIB proxies (used for @Transactional, @Async, etc.) — if you've ever hit "cannot subclass final class" from Spring AOP, this is the exact same mechanism.

Qualifiers — Resolving Multiple Implementations

When two or more beans implement the same type, CDI can't choose automatically — injecting without a qualifier is an ambiguous dependency and fails at deployment. @Qualifier annotations are the disambiguation mechanism.

// Define qualifier annotations
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE, ElementType.PARAMETER})
public @interface Smtp {}

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE, ElementType.PARAMETER})
public @interface Mock {}

// Two implementations
@Smtp @ApplicationScoped
public class SmtpEmailService implements EmailService {
    public void send(String to, String body) { /* real SMTP */ }
}

@Mock @ApplicationScoped
public class MockEmailService implements EmailService {
    public void send(String to, String body) {
        log.info("[MOCK] Email to {}: {}", to, body);
    }
}

// Inject the one you need
@ApplicationScoped
public class NotificationService {
    private final EmailService emailService;

    @Inject
    public NotificationService(@Smtp EmailService emailService) {
        this.emailService = emailService;
    }
}
CDI @Qualifier vs Spring @Qualifier — type-safe vs string-based

CDI qualifiers are custom annotations — compile-time type-safe, refactoring-friendly. Spring's @Qualifier("smtp") takes a string — a typo silently fails at runtime. Spring also offers @Primary for "this is the default when nothing else is specified," which CDI achieves with the built-in @Default qualifier. Both models work; CDI's is strictly safer.

Producers — Creating Beans With Custom Logic

Not every object can be annotated with a CDI scope — third-party classes, objects that require runtime parameters to construct, or objects that need a factory method. @Produces turns a method or field into a bean factory that CDI manages.

@ApplicationScoped
public class Infrastructure {

    // Produce a configured ObjectMapper as a CDI bean
    @Produces
    @ApplicationScoped
    public ObjectMapper objectMapper() {
        return new ObjectMapper()
            .registerModule(new JavaTimeModule())
            .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    }

    // Produce a config value from a property file
    @Produces
    @Named("apiBaseUrl")
    public String apiBaseUrl() {
        return System.getenv().getOrDefault("API_BASE_URL", "http://localhost:8080");
    }
}

// Now ObjectMapper is injectable anywhere as a CDI bean
@ApplicationScoped
public class JsonConverter {
    private final ObjectMapper mapper;

    @Inject
    public JsonConverter(ObjectMapper mapper) { this.mapper = mapper; }
}

CDI Events — Decoupled Publish/Subscribe

CDI's event system allows beans to communicate without knowing about each other. The publisher fires an event object; any bean with an @Observes method for that type receives it. No wiring, no interface contract — pure type-based dispatch.

// Event payload — a plain class, no CDI annotation needed
public record UserRegisteredEvent(Long userId, String email) {}

// Publisher — injects Event<T> and fires it
@ApplicationScoped
public class RegistrationService {

    @Inject
    private Event<UserRegisteredEvent> registrationEvent;

    public void register(String email) {
        User user = createUser(email);
        registrationEvent.fire(new UserRegisteredEvent(user.getId(), email));
        // all @Observes methods run synchronously before this line returns
    }
}

// Observers — registered automatically, no explicit subscription needed
@ApplicationScoped
public class WelcomeEmailSender {
    public void onRegistration(@Observes UserRegisteredEvent event) {
        sendWelcomeEmail(event.email());
    }
}

@ApplicationScoped
public class AnalyticsTracker {
    public void onRegistration(@Observes UserRegisteredEvent event) {
        track("user_registered", event.userId());
    }
}

// Async event — fire and continue, observer runs in a separate thread
public void registerAsync(String email) {
    User user = createUser(email);
    registrationEvent.fireAsync(new UserRegisteredEvent(user.getId(), email));
    // returns immediately — observers run concurrently
}
CDI events vs Spring ApplicationEvent

Spring's equivalent is ApplicationEventPublisher + @EventListener. The model is identical — publisher fires, observers react, no direct coupling. CDI's version is arguably cleaner: Event<T> is injected as a typed dependency, and the observer method signature is the only "contract." Spring's @EventListener requires the event class to extend ApplicationEvent or be a plain object (since Spring 4.2). Both work; CDI's doesn't require any base class at all.

Interceptors — Cross-Cutting Concerns Without Inheritance

// Step 1: define an interceptor binding annotation
@InterceptorBinding
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD})
public @interface Timed {}

// Step 2: implement the interceptor
@Timed
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class TimingInterceptor {

    @AroundInvoke
    public Object measure(InvocationContext ctx) throws Exception {
        long start = System.nanoTime();
        try {
            return ctx.proceed();  // invoke the actual method
        } finally {
            long ms = (System.nanoTime() - start) / 1_000_000;
            log.debug("{}.{}() took {}ms",
                ctx.getTarget().getClass().getSimpleName(),
                ctx.getMethod().getName(), ms);
        }
    }
}

// Step 3: apply it with just one annotation
@ApplicationScoped
public class ReportService {

    @Timed  // interceptor runs transparently — zero change to business logic
    public Report generate(Long userId) { ... }
}
CDI interceptors vs Spring AOP — different mechanism, same outcome

Spring @Transactional, @Async, and @Cacheable are all implemented via Spring AOP, which uses CGLIB proxies — mechanically identical to CDI's client proxy model. CDI interceptors are the Jakarta standard for the same pattern. The practical difference: Spring AOP integrates with Spring's own annotations; CDI interceptors are portable across any CDI container (Quarkus, WildFly, TomEE) without Spring.

Bean Discovery — What CDI Scans and What It Ignores

CDI needs to know which classes to consider as beans. This is controlled by bean-discovery-mode in beans.xml, or by the absence of that file entirely.

<!-- src/main/resources/META-INF/beans.xml (or WEB-INF/beans.xml in a WAR) -->

<!-- annotated (recommended since CDI 1.1 / EE 7) -->
<!-- Only classes with a bean-defining annotation are considered beans -->
<beans xmlns="https://jakarta.ee/xml/ns/jakartaee"
       version="4.0"
       bean-discovery-mode="annotated">
</beans>

<!-- all — every class in the archive is a potential bean, even without annotations -->
<!-- Only use this for legacy compatibility; slow startup, unexpected beans -->
<beans bean-discovery-mode="all"></beans>

<!-- none — CDI disabled for this archive entirely -->
<beans bean-discovery-mode="none"></beans>

Bean-defining annotations (the ones that make a class discoverable in annotated mode):

AnnotationSource
@ApplicationScoped, @RequestScoped, @SessionScoped, @ConversationScopedjakarta.enterprise.context
@Dependentjakarta.enterprise.context
@Singletonjakarta.inject
@Interceptor, @Decoratorjakarta.interceptor
Any custom @NormalScope or @StereotypeUser-defined
No beans.xml at all — implicit bean archive in CDI 4.0+

In CDI 4.0 (Jakarta EE 11), the absence of beans.xml means the archive is treated as an implicit bean archive with annotated discovery mode — same as having an empty beans.xml with bean-discovery-mode="annotated". An explicit beans.xml is only required when you need all mode or none, or when you need to include portable extensions and interceptors via XML.

Interview Questions

🎓 Junior level

Q: What problem does CDI solve, and what is the difference between @Inject and new?
CDI solves tight coupling — when you use new to create a dependency, your class controls its creation and you can't swap it without modifying the class. With @Inject, the CDI container provides the instance, allowing you to change the implementation, apply interceptors, and inject mocks in tests — none of which require changing the class that depends on it.

Q: What is the difference between @ApplicationScoped and @RequestScoped?
@ApplicationScoped creates one instance for the entire application lifetime — effectively a singleton. @RequestScoped creates one instance per HTTP request and destroys it when the request ends. Data that belongs to one user request goes in @RequestScoped beans; shared configuration or caches go in @ApplicationScoped beans.

🔥 Senior level

Q: What is a CDI client proxy and what three constraints does it impose on proxied classes?
When a longer-lived bean injects a shorter-lived one, CDI injects a generated subclass (client proxy) that forwards every method call to the correct real instance for the current context — e.g. the right @RequestScoped instance for the current HTTP thread. Because the proxy is a subclass, the target class must not be final, none of its methods must be final, and it must have a no-arg constructor. Violating any of these produces a deployment exception at startup — the same constraints Spring's CGLIB proxies impose for @Transactional and @Async.

Q: How do CDI qualifiers differ from Spring's @Qualifier, and why does the difference matter?
CDI qualifiers are custom annotations — compile-time type-safe, IDE- refactorable, and can carry members (attributes) for fine-grained disambiguation. Spring's @Qualifier("smtpEmail") takes a string — a typo produces an UnsatisfiedDependencyException at startup (or worse, silently picks the wrong bean if names overlap). CDI's model eliminates the string entirely: if you rename the qualifier annotation, the compiler catches every usage site.

Q: What is bean-discovery-mode="annotated" and why is it preferred over "all"?
In annotated mode, only classes carrying a bean-defining annotation (@ApplicationScoped, @RequestScoped, etc.) are eligible as CDI beans. In all mode, every class in the archive is a potential bean — including third-party library classes, utility classes, and value objects — which causes slower startup, unexpected bean resolution conflicts, and proxying attempts on classes that can't be proxied. annotated is the explicit, predictable default and is the CDI 4.0 implicit mode when no beans.xml exists.