Updated Jun 22, 2026

CDI in Quarkus (ArC)

In Phase 3 you built a JAX-RS resource that served Product JSON over HTTP. That resource was already quietly relying on something we glossed over: when you wrote @Inject ProductService, somebody created the service and handed it to the resource. This phase is about that somebody — Quarkus's dependency-injection container — and the one surprising thing it does differently from every container you've used before.

Here's the mental model to hold the whole way through: it's the same CDI you already know, but the wiring is figured out while your code compiles, not when it starts. A traditional container wakes up at startup, scans your classes, reads annotations with reflection, builds the graph of "who needs what," and then serves requests. Quarkus does almost all of that work at build time — the graph is baked into your application before it ever runs. Same annotations, same programming model, radically different timing. That timing shift is exactly the build-time-over-runtime idea from Phase 1, applied to dependency injection.

📝 If you've done CDI in Jakarta EE, you already know the entire programming model here — @Inject, @ApplicationScoped, qualifiers, producers, all of it. This phase does not re-teach CDI from scratch; it shows you Quarkus's twist on it and points you back to the Jakarta EE phase for the standard details. If CDI is new to you, read that first — Quarkus is "the standards, optimized," and this is one of those standards.

It's CDI, wired at build time

📝 Quarkus's DI container is called ArC. It's a build-time implementation of CDI — the standard dependency-injection spec you met in Jakarta EE. There's no new API to learn: you use the exact same jakarta.inject and jakarta.enterprise.context annotations. The difference lives under the hood, in when the wiring happens.

Let's make our Product service a real CDI bean and inject it into the resource:

import jakarta.enterprise.context.ApplicationScoped;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

@ApplicationScoped
public class ProductService {
    private final Map<Long, Product> store = new ConcurrentHashMap<>();

    public List<Product> all()        { return List.copyOf(store.values()); }
    public Product find(long id)      { return store.get(id); }
    public void save(Product p)       { store.put(p.id(), p); }
}

What just happened: @ApplicationScoped is the bean-defining annotation — it tells the container "this class is yours to create and manage, and there's one instance for the whole application." It's the standard CDI annotation, identical to Jakarta EE; ArC just notices it at compile time instead of startup. The ConcurrentHashMap is there for the same reason it was in the Jakarta EE phase: one shared instance means its mutable state must be thread-safe.

Now the resource asks for it:

import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import java.util.List;

@Path("/products")
public class ProductResource {
    @Inject
    ProductService service;

    @GET
    public List<Product> list() {
        return service.all();
    }
}

What just happened: @Inject declares "I need a ProductService; container, fill this in." When ArC sees this injection point, it goes looking for a bean that satisfies the ProductService type — finds the @ApplicationScoped one above — and records the connection. Note the field isn't private: ArC needs to set it, and a truly private field would force reflection, which Quarkus is trying to avoid. (Quarkus generates code to do the injection; a package-private field lets that generated code write directly.)

💡 You'll see field injection like this all over Quarkus examples because it's compact. Constructor injection is still the better habit for the same reasons it is everywhere else — more on that below.

Why build-time DI is a big deal

💡 Here's the payoff, and it's the reason ArC exists rather than Quarkus just shipping a normal CDI container. A runtime container's startup cost is dominated by two things: scanning the classpath to find your beans, and reflection to read their annotations and construct them. Both happen every single time the app boots. ArC does that scanning-and-reflection work once, at build time, and emits plain generated code that wires everything up directly. At runtime there's almost nothing left to do — which is a big slice of why a Quarkus app can boot in tens of milliseconds.

The deeper win is native images. As you'll see in Phase 9, compiling to a native executable with GraalVM uses a "closed-world" model that is hostile to reflection — anything discovered dynamically at runtime is a problem. Doing DI at build time sidesteps that entirely: there's no runtime classpath scan to break, because the wiring is already settled before the native compiler ever runs. Build-time DI is a big part of how Quarkus can offer full CDI and still compile to native — two things that would otherwise be at war with each other.

⚠️ There's a flip side, and it's mostly good news once you adjust. Errors that a runtime container throws when the app starts — a missing dependency, an ambiguous bean — ArC often catches when the app compiles. A build that won't produce a runnable app is annoying; a server that boots fine and then explodes on the first request in production is worse. Say you inject a type that no bean satisfies:

@Path("/products")
public class ProductResource {
    @Inject
    PricingEngine pricing;   // no bean of this type exists anywhere
}

The build fails, not the server:

[ERROR] Build step ...ArcProcessor#validate threw an exception:
jakarta.enterprise.inject.UnsatisfiedResolutionException:
Unsatisfied dependency for type com.example.PricingEngine and qualifiers [@Default]
  - java member: com.example.ProductResource#pricing
  - declared on CLASS bean [class=com.example.ProductResource]

What just happened: ArC validated the whole dependency graph at build time, found that nothing can supply a PricingEngine, and refused to build. The classic Jakarta EE version of this is an UnsatisfiedResolutionException at deploy/startup; ArC is throwing the same standard exception, just earlier. (An ambiguous dependency — two beans satisfying one type — fails the same way at build time, and the fix is the same standard @Qualifier you learned in Jakarta EE.) Shifting these failures left is a feature: you find them on your machine, not in prod.

Beans & scopes

The scopes are exactly the CDI scopes from the Jakarta EE phase — same annotations, same meanings — so this is a quick recap rather than a re-teach. A scope answers two questions: how long does a bean live and who shares the same instance.

Scope Annotation One instance per… Reach for it when
Application @ApplicationScoped the whole application stateless services & repositories (the common default in Quarkus)
Request @RequestScoped a single HTTP request per-request state tied to one call
Singleton @Singleton the whole application like app-scoped, but eager and proxy-free (a micro-optimization; usually prefer @ApplicationScoped)

📝 In Quarkus, @ApplicationScoped is the workhorse — most of your services and repositories will be app-scoped. The full detail on scopes, contexts, and the proxy machinery that makes mismatched lifetimes safe lives in the Jakarta EE CDI phase; it all applies here unchanged.

💡 One bonus that falls out of build-time analysis: Quarkus removes unused beans. Because ArC computes the whole graph at compile time, it can see which beans nothing actually injects and leave them out of the application entirely. The result is a smaller app and less to load at startup — a free win you get just for the container knowing the full picture ahead of time. (If you ever need to keep an apparently-unused bean — say it's only used reflectively — you mark it @Unremovable.)

Constructor injection & the basics

Field injection is compact, but constructor injection is the better habit — for the same reasons it is in Spring and Jakarta EE. Here's the resource rewritten to take its dependency through the constructor:

@Path("/products")
public class ProductResource {
    private final ProductService service;

    public ProductResource(ProductService service) {   // ArC passes one in
        this.service = service;
    }

    @GET
    @Path("/{id}")
    public Product byId(@PathParam("id") long id) {
        return service.find(id);
    }
}

What just happened: there's a single injectable constructor, so you don't even need @Inject on it — ArC treats the sole constructor as the injection point and supplies a ProductService when it builds the resource. The service field can be final, the constructor is an honest, visible list of what the class needs, and you can unit-test with a plain new ProductResource(fakeService) — no container, no Quarkus, just a normal object. The constructor is the testing seam.

📝 Everything else from standard CDI works in Quarkus exactly as documented: qualifiers (a custom @Qualifier to pick between two beans of the same type) and producers (@Produces methods to make a non-bean object injectable). Rather than re-teach them here, lean on the Jakarta EE CDI phase — the code is identical, and ArC resolves it all at build time.

The Spring bridge (brief)

💡 If you're coming from Spring, Quarkus meets you halfway. The quarkus-spring-di extension lets Spring's DI annotations work directly — @Autowired, @Component, @Service, @Value — so you can lift familiar code over without rewriting every wiring annotation:

import org.springframework.stereotype.Component;
import org.springframework.beans.factory.annotation.Autowired;

@Component                       // Spring's annotation, understood by Quarkus via quarkus-spring-di
public class ProductService {
    @Autowired
    ProductRepository repository;
}

What just happened: with the quarkus-spring-di extension on the classpath, ArC understands Spring's @Component and @Autowired and wires this bean as if it were CDI — still at build time, with all the same benefits. It's a compatibility bridge, not a Spring runtime: it maps the annotations you know onto ArC, which is handy for migration but not the idiomatic path.

📝 The throughline of this whole phase: idiomatic Quarkus uses standard CDI — the same DX you'd get from Jakarta EE or Spring — but resolves the wiring at build time for speed and native-image friendliness. Learn CDI once and you've learned the wiring model for Quarkus, Jakarta EE, and (via the bridge) much of Spring too. The only thing that's genuinely different here is when the magic happens.

Recap

  1. ArC is build-time CDI. Quarkus's DI container, ArC, implements the standard CDI spec — same @Inject, @ApplicationScoped, qualifiers, producers — but computes the wiring graph at compile time instead of at startup. No new API, just different timing.
  2. Why it matters: doing scanning and reflection once at build time (not on every boot) is a big reason Quarkus starts in milliseconds, and doing DI at build time sidesteps reflection — which is what lets full CDI coexist with GraalVM native images.
  3. Errors shift left. A missing or ambiguous bean that a runtime container would throw at startup, ArC often catches at build time — the build fails on your machine instead of the server failing in prod.
  4. Scopes are standard CDI. @ApplicationScoped is the common Quarkus default; @RequestScoped for per-request state; @Singleton as an eager, proxy-free variant. Full detail lives in the Jakarta EE CDI phase. Bonus: ArC removes unused beans for a smaller app.
  5. Prefer constructor injection (testable, final, no reflection needed); qualifiers and producers work as in standard CDI. For Spring devs, quarkus-spring-di bridges @Autowired/@Component — but idiomatic Quarkus uses CDI.

With wiring understood, the next piece slots right in: a real persistence layer. Next we give Product a database with Hibernate and Panache.

Quick check

Test yourself on the ideas that have to stick from this phase:

[
  {
    "q": "What is the key difference between Quarkus's ArC container and a traditional CDI container?",
    "choices": [
      "ArC computes the dependency-injection wiring graph at build time, not at application startup",
      "ArC uses a completely different set of annotations from standard CDI",
      "ArC only supports field injection and forbids constructor injection",
      "ArC runs the application as interpreted bytecode instead of compiled code"
    ],
    "answer": 0,
    "explain": "ArC is a build-time implementation of the standard CDI spec. The annotations (@Inject, @ApplicationScoped, etc.) are identical to Jakarta EE; what changes is that the wiring is resolved at compile time rather than at startup — which is what enables fast boot and native images."
  },
  {
    "q": "You @Inject a type that no bean satisfies. In Quarkus, when do you find out?",
    "choices": [
      "At build time — ArC validates the dependency graph and the build fails",
      "Never — Quarkus silently injects null",
      "Only in production, on the first request that uses the bean",
      "At unit-test time only, never during the build"
    ],
    "answer": 0,
    "explain": "Because ArC analyzes the whole graph at build time, an unsatisfied (or ambiguous) dependency fails the build with the standard exception — earlier than a runtime container would throw it at startup. The failure shifts left to your machine."
  },
  {
    "q": "Why does doing dependency injection at build time help Quarkus compile to a native image?",
    "choices": [
      "It avoids runtime reflection and classpath scanning, which the native closed-world model is hostile to",
      "It makes the application use less disk space at build time",
      "It converts all beans into static methods that need no instances",
      "It disables CDI entirely so there is nothing for the native compiler to analyze"
    ],
    "answer": 0,
    "explain": "GraalVM's closed-world native compilation struggles with reflection and dynamic discovery. By resolving DI at build time and generating plain wiring code, ArC removes the runtime scanning/reflection that would otherwise break native compilation — letting full CDI and native images coexist."
  }
]

← Phase 3: Building REST APIs · Guide overview · Phase 5: Persistence: Hibernate with Panache →

Check your understanding

1. What is the key difference between Quarkus's ArC container and a traditional CDI container?

2. You @Inject a type that no bean satisfies. In Quarkus, when do you find out?

3. Why does doing dependency injection at build time help Quarkus compile to a native image?

Was this page helpful?