CDI and the Programming Model (ArC)

This section documents Quarkus 3.39.x (current) with 3.33.x LTS as the production recommendation, on the Java 17+ baseline, as published at the official Quarkus guides — which are the reference these pages are written and verified against.

Quarkus 4 raises the baseline to Java 21 (first release candidate planned for end of September 2026, see the Quarkus blog); readers upgrading past 3.39.x/3.33.x LTS should expect that change.

This content was generated with the assistance of AI and should be verified against those official guides before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

ArC is Quarkus’s own implementation of Jakarta CDI (Contexts and Dependency Injection) — not an embedded copy of Weld, the reference runtime CDI container most Jakarta EE developers already know. ArC resolves as much of the bean graph as possible at build time: which beans exist, which scope each one lives in, and which concrete beans satisfy each injection point are all decided before the application ever starts, and the generated bytecode reflects that decision directly instead of discovering it through reflection at runtime. This is the single biggest reason a Quarkus application starts as fast as it does, and it shapes almost every rule in this page: bean discovery is annotation-driven rather than exhaustive classpath scanning, unused beans are pruned rather than kept "just in case", and the container is a closed world once the application is built. This page covers how ArC discovers and scopes beans, the injection and extension points CDI offers (producers, qualifiers, alternatives, stereotypes, interceptors, decorators), the CDI eventing model, and where ArC deliberately stops short of full runtime CDI.

Bean discovery: annotations in, unused beans out

A traditional CDI container can be configured to scan an entire archive for beans via an empty beans.xml (bean-discovery-mode="all"). ArC never does this: a class is only considered a bean candidate if it carries a bean-defining annotation — a scope annotation (@ApplicationScoped, @RequestScoped, @Singleton, @Dependent, @SessionScoped), a stereotype, or an interceptor/decorator annotation. A plain class with no such annotation is simply not a bean, no matter how it is packaged:

package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
public class OrderPricingService {

    @jakarta.inject.Inject
    TaxCalculator taxCalculator;

    public java.math.BigDecimal priceWithTax(java.math.BigDecimal net) {
        return net.add(taxCalculator.taxFor(net));
    }
}

Discovery being annotation-driven at build time also means ArC can safely do something a runtime container cannot: remove beans nobody actually uses. By default, any discovered bean that is never injected, never looked up programmatically, and not otherwise reachable is treated as removable and stripped from the generated application to shrink its footprint and startup work. A bean is kept regardless — unremovable — when it is looked up dynamically (CDI.current().select(…​)), extended by another Quarkus extension that reads it via reflection, or explicitly marked:

package com.example.orders;

import io.quarkus.arc.Unremovable;
import jakarta.enterprise.context.ApplicationScoped;

@ApplicationScoped
@Unremovable
public class MetricsRegistryBean {
    // kept even though nothing in this codebase injects it directly --
    // e.g. it is only found reflectively by another extension
}

Because bean removal and injection-point resolution both happen at build time, ArC needs to see the whole application graph before it packages anything — it cannot register a brand-new bean type after the application has started the way a runtime container can. This closed-world constraint is the same one behind Quarkus’s build-time augmentation model, covered in depth on the dedicated build-time augmentation page in this section.

Scopes

Every CDI bean lives in exactly one scope, which governs how many instances exist and how long each one lives:

Scope Behavior

@ApplicationScoped

One shared instance for the entire application lifetime. This is the default, most-used scope in Quarkus applications. ArC backs it with a client proxy, so an injected reference is actually a lightweight proxy that resolves to the real contextual instance on each call — this is what lets an @ApplicationScoped bean be injected into a bean with a shorter-lived scope without capturing a stale reference.

@RequestScoped

One instance per request context (an HTTP request, or another unit of work Quarkus binds a request context to, such as a message being processed). Also proxied, for the same reason as @ApplicationScoped.

@Singleton

Also one instance for the application lifetime, but without a proxy: injection points hold a direct reference to the real instance. That makes it marginally faster to call through and avoids proxy-related restrictions (e.g. on final classes/methods), at the cost of losing context-aware behaviors a proxy enables — notably, a @Singleton cannot stand in correctly for a normal-scoped bean behind an injection point that expects proxy semantics.

@Dependent

The scope a bean gets when no scope annotation is present at all — @Dependent is CDI’s default. A new instance is created per injection point and its lifecycle is tied to the lifecycle of the bean it was injected into; it is never shared and never proxied.

@SessionScoped

One instance per HTTP session. Only meaningful for stateful, browser-session-backed web scenarios, and used sparingly in Quarkus applications, whose architecture generally favors stateless request handling over server-side session state.

package com.example.orders;

import jakarta.enterprise.context.Dependent;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Singleton;

@RequestScoped
public class RequestAuditContext {
    // one instance per request; safe to hold request-specific mutable state
}

@Singleton
public class ClockProvider {
    // one instance for the application's lifetime, injected without a proxy
    java.time.Clock clock() {
        return java.time.Clock.systemUTC();
    }
}

@Dependent
public class ReportBuilder {
    // a fresh instance every time this type is injected
}

Injection styles

Field injection is fully idiomatic in Quarkus/ArC, and is the style most Quarkus example code and generated project templates use by default — unlike DI cultures built around a runtime proxying container, where field injection is often discouraged because it hides dependencies inside an object that can be constructed "half-wired" by new. That specific risk does not transfer to ArC in the same way: ArC generates the bean graph and the proxy/direct-call bytecode at build time, so a field-injected bean is exactly as inspectable, testable (via @QuarkusTest and @InjectMock), and safe to reason about as a constructor-injected one. Both styles are fully supported:

package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;

// field injection: idiomatic and common in Quarkus
@ApplicationScoped
public class InvoiceService {

    @Inject
    OrderRepository orderRepository;

    @Inject
    OrderPricingService pricingService;
}

// constructor injection: also fully supported, and still often preferred
// when immutability (final fields) matters to the design
@ApplicationScoped
public class ShippingService {

    private final OrderRepository orderRepository;

    @Inject
    public ShippingService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }
}

Producers and disposers

A producer creates a bean from something the application doesn’t own the source of — a third-party class, or an object that needs to be built rather than simply constructed:

package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Disposes;
import jakarta.enterprise.inject.Produces;

@ApplicationScoped
public class ObjectMapperProducer {

    @Produces
    @ApplicationScoped
    public com.fasterxml.jackson.databind.ObjectMapper objectMapper() {
        return new com.fasterxml.jackson.databind.ObjectMapper()
                .findAndRegisterModules();
    }

    public void closeMapper(@Disposes com.fasterxml.jackson.databind.ObjectMapper mapper) {
        // paired cleanup hook, invoked when the produced bean's context ends
    }
}

@Disposes pairs a cleanup method with a @Produces method of the same type — useful for producers whose result holds a resource (a connection, a file handle) that needs an explicit close rather than relying on garbage collection.

Qualifiers

A qualifier disambiguates between multiple beans of the same type. It is itself a meta-annotation, built with CDI’s own @Qualifier:

package com.example.orders;

import jakarta.inject.Qualifier;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.PARAMETER, ElementType.METHOD, ElementType.TYPE})
public @interface Express {
}
package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;

@Express
@ApplicationScoped
public class ExpressShippingCalculator implements ShippingCalculator {
    // one of possibly several ShippingCalculator implementations
}
package com.example.orders;

import jakarta.inject.Inject;

public class CheckoutService {

    @Inject
    @Express
    ShippingCalculator shippingCalculator;
}

Alternatives, priorities, and stereotypes

@Alternative combined with @Priority overrides which concrete bean satisfies an injection point without touching the original implementation — typically used to swap in a test double or an environment-specific implementation:

package com.example.orders;

import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Alternative;

@Alternative
@Priority(1)
@ApplicationScoped
public class InMemoryShippingCalculator implements ShippingCalculator {
    // takes precedence over the default implementation while active
}

A stereotype bundles several annotations (a scope, qualifiers, interceptor bindings) into one, so beans that should always share the same combination don’t have to repeat it:

package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Stereotype;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

import static java.lang.annotation.ElementType.TYPE;

@Stereotype
@ApplicationScoped
@Retention(RetentionPolicy.RUNTIME)
@Target(TYPE)
public @interface DomainService {
    // any @DomainService-annotated class is @ApplicationScoped, with room
    // to add shared interceptor bindings later without touching each bean
}

Interceptors and decorators

An interceptor wraps method calls matched by an interceptor-binding annotation, functionally similar to a Spring @Around aspect but defined through CDI’s own SPI:

package com.example.orders;

import jakarta.interceptor.AroundInvoke;
import jakarta.interceptor.Interceptor;
import jakarta.interceptor.InvocationContext;

@LogExecutionTime
@Interceptor
public class LogExecutionTimeInterceptor {

    @AroundInvoke
    Object logTime(InvocationContext context) throws Exception {
        long start = System.nanoTime();
        try {
            return context.proceed();
        } finally {
            long elapsedMs = (System.nanoTime() - start) / 1_000_000;
            System.out.println(context.getMethod() + " took " + elapsedMs + " ms");
        }
    }
}

A decorator (@Decorator) instead wraps a bean at the level of a business interface it implements, adding behavior before/after delegating to the wrapped instance — useful when the added behavior is itself naturally expressed as another implementation of the same interface, rather than as generic around-advice.

CDI events

CDI’s observer pattern lets one bean fire an event without knowing who, if anyone, is listening:

package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Event;
import jakarta.inject.Inject;

@ApplicationScoped
public class OrderService {

    @Inject
    Event<OrderPlaced> orderPlacedEvent;

    void placeOrder(Order order) {
        // ... persist the order ...
        orderPlacedEvent.fire(new OrderPlaced(order.id()));
    }
}
package com.example.orders;

import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;

@ApplicationScoped
public class OrderPlacedNotifier {

    void onOrderPlaced(@Observes OrderPlaced event) {
        // runs synchronously on the firing thread by default;
        // ObservesAsync/fireAsync exist for the asynchronous variant
    }
}

Startup and shutdown

Quarkus fires its own lifecycle events through the same mechanism. StartupEvent and ShutdownEvent are raised by the Quarkus runtime itself — once at application startup, once at graceful shutdown — and any bean can observe them:

package com.example.orders;

import io.quarkus.runtime.ShutdownEvent;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;

@ApplicationScoped
public class CacheWarmer {

    void onStart(@Observes StartupEvent ev) {
        // eagerly warm a cache at application startup
    }

    void onStop(@Observes ShutdownEvent ev) {
        // release resources before the application exits
    }
}

The @Startup annotation offers a shorter path to the same eager-initialization outcome when all that’s needed is "construct this bean right away, don’t wait for its first use":

package com.example.orders;

import io.quarkus.runtime.Startup;
import jakarta.enterprise.context.ApplicationScoped;

@Startup
@ApplicationScoped
public class WarmStartCache {
    // instantiated eagerly at startup instead of on first injection/lookup
}

@Observes StartupEvent and @PostConstruct answer different questions and are easy to conflate. @PostConstruct runs when this specific bean is actually created — which, for a normal-scoped bean, may be lazily deferred until its first real use, or may never happen at all if the bean is never touched. @Observes StartupEvent (like @Startup) runs at application startup, unconditionally, which is precisely why it is the tool to reach for when a bean’s initialization work must happen eagerly regardless of whether anything would otherwise have triggered that bean’s construction yet.

Where ArC stops short of a runtime CDI container

ArC deliberately implements a subset of full Jakarta CDI, trading away features that are fundamentally incompatible with build-time resolution:

  • No dynamic bean registration after startup. A runtime container like Weld can, in principle, register new beans while the application is running (portable extensions doing so during container boot is normal even there); ArC’s bean graph is closed once the application is built, and no bean can be added afterward. This is the same closed-world model behind Quarkus’s build-time augmentation, referenced above.

  • Limited portable-extension SPI support. Full CDI’s javax.enterprise.inject.spi/jakarta.enterprise.inject.spi portable extension mechanism assumes a runtime container performing its own classpath scanning; ArC supports only the subset of that SPI that is meaningful when bean discovery has already happened at build time, and Quarkus extensions typically integrate at the build-time BuildStep level instead of through a portable CDI extension.

  • Interceptors and decorators are resolved at build time, alongside everything else — the interceptor chain for a given method is fixed before the application starts rather than assembled dynamically per call.

Injected configuration participates in the same bean graph

Configuration values are themselves exposed as CDI beans: @ConfigProperty on a field or parameter, and @ConfigMapping-annotated interfaces for structured configuration, are both resolved and injected through the same ArC machinery as any other bean — a configuration value or mapping interface is just another injection point in the graph ArC builds. The dedicated configuration page in this section covers @ConfigProperty and @ConfigMapping in depth; the point worth keeping here is that configuration injection is not a separate, parallel mechanism bolted onto CDI, it is CDI.

Build-time vs. runtime bean resolution

flowchart TD subgraph BT["ArC -- build time"] A1["Scan for bean-defining annotations\n(scopes, stereotypes, interceptors)"] --> A2["Build the complete bean graph\n(resolve every injection point, apply\nremoval of unused beans)"] A2 --> A3["Generate bytecode:\ndirect calls or client proxies,\ninterceptor chains fixed"] A3 --> A4["Packaged application\n(bean graph is now closed)"] end subgraph RT["Traditional runtime CDI (Weld) -- application startup"] B1["Classpath scan for beans.xml\nand annotated classes"] --> B2["Reflection-based bean discovery\nand injection-point resolution"] B2 --> B3["Dynamic proxy generation"] B3 --> B4["Context resolution per request,\nstill happening at startup/runtime"] end

ArC finishes the left-hand path once, before the application ever runs; a traditional runtime container performs the right-hand path’s discovery-and-wiring work every time the application starts, and portable extensions can still influence it while it happens. That difference in when the graph is decided is the direct cause of Quarkus’s fast boot time discussed at the top of this page.