One App, Every Proxy Case: Where Your CDI Proxies Actually Come From

A normal-scoped bean is never handed out directly — a generated subclass is. Where it comes from depends on one thing only, the origin of the type being proxied: your own code, a modular dependency, a jar with no module-info, or a build with no class loader to help it. One example application carries all four cases, and this walks through each — what is generated as source, what as bytecode, and why.

Viduke, in top hat and red-lined cape, juggles glowing module-info cubes over the Paris rooftops while ribbons of bytecode curl around him and a tangled old classpath rope lies at his feet

A CDI container has to manufacture classes. A normal-scoped bean is handed out as a client proxy — a subclass forwarding every business method to the contextual instance. A bean with interceptor bindings is handed out as an intercepted subclass routing its methods through the chain. Most containers build these at runtime, with reflection and a bytecode library.

Vauban builds them ahead of time and keeps strict Java modules: no opens, no dynamic proxy, no ASM. Under that constraint, one question decides everything, and it is not "how do we generate a proxy". It is:

Where does the type being proxied come from?

A subclass has to be defined in a package. If that package is one you compile, the answer is easy. If it belongs to someone else’s module, everything hinges on what you are allowed to add to it — and that is a Java module question, not a CDI one.

One application, every case

Everything below is in a single runnable example, example-cdi1015-app, which deliberately carries every origin at once:

Origin In the example What it needs

Your own code — compiled here, with the annotation processor

CheckoutService, Vault (intercepted), Ledgers.Ledger (nested)

Nothing. Source proxies, in your package.

A modular dependency — has a module-info, you have no sources

example-cdi1015-lib: PaymentGateway, AuditLog, FraudScreen, ReceiptPrinter

Nothing, or a Vauban launcher for the hard shapes.

A dependency with no module-info

example-legacy-lib, consumed by example-legacy-app

vauban:modularize first.

No class loader at all — a GraalVM native image

The same dependency, packaged differently

vauban:enhance-dependencies.

The question, in one picture

diag vauban tiers

Case 1 — your own code

The annotation processor runs inside javac, so it sees your beans as elements and writes ordinary Java source next to them: CheckoutService_ClientProxy, and for an intercepted bean a Vault$$Intercepted sibling. A generated _VaubanComponents class per package instantiates both in-module — new CheckoutService_ClientProxy() — which is precisely why nothing needs to be opened: the container never reflects into your package, it calls generated code that already lives there.

A nested bean takes the same route, with one bookkeeping detail. A nested type has two names, and they are not interchangeable:

case "app.Ledgers$Ledger" -> new app.Ledgers.Ledger();
//    ^ binary name: what the container looks a component up by
//                     ^ canonical name: the only form you can write in a `new`

Its proxy is named Ledgers$Ledger_ClientProxy — a top-level class whose simple name contains a $, never a member of Ledgers, since nothing can add a member to a class that already exists.

The constructor problem: your code, run twice

A client proxy is a subclass. CheckoutService_ClientProxy extends CheckoutService — and in Java, constructing a subclass constructs the superclass first. So creating the proxy runs one of your bean’s constructors, on an instance that will never serve a single request. Its state is dead weight: every call is forwarded to the contextual instance.

Nobody notices, until a constructor does something:

@ApplicationScoped
public class Registry {

    private final Connection connection;

    @Inject
    public Registry(DataSource ds) throws SQLException {
        this.connection = ds.getConnection();              // a second connection, never closed
        Metrics.counter("registry.created").increment();    // counted twice
        log.info("Registry ready");                         // logged twice
    }
}

Two distinct failures come out of this, and the second is worse than the first.

The side effects happen twice. A connection opened and leaked, a counter that reads double, a registration into some static map that now holds an instance nobody will ever call. Nothing throws; your metrics are simply wrong. This is the shape Sébastien’s constructor case made famous, and it is the reason a container cannot just call whatever constructor it finds.

And the arguments are lies. The proxy has no DataSource to pass — it is built by the container before any injection happens — so it can only pass defaults: super(null). The body then runs with ds == null and throws a NullPointerException from a stack trace that points at your constructor, in a class you never knowingly instantiated.

What Vauban does instead

The bean gets a constructor of its own that does nothing at all:

protected synthetic Registry(ProxyLink marker) {
    super();          // that is the entire body
}

The proxy chains to that one — super((ProxyLink) null) — so constructing it runs no bean code whatsoever. Not the connection, not the counter, not even the field initializers: javac inlines those into each constructor it compiles, and this one is added after javac has finished.

Which is precisely why it cannot be the annotation processor’s job. A processor can create files; it cannot modify the class javac is compiling. So Vauban auto-starts a javac plugin that runs once compilation ends, weaves the marker constructor into the compiled bean, and retargets the generated proxy onto it. Your source never mentions any of it, and the constructor is synthetic, so it does not show up in your API.

The container never selects that constructor for injection either — it is a marker, not a candidate.

Tip

If you ever declare the entry constructor by hand — which you only need to do when no weaving tier is available — then it is a source constructor, and javac will inline your field initializers into it. Keep them side-effect free:

protected Registry(ProxyLink link) {
    this.connection = null;   // javac requires blank finals to be assigned
}

Case 2 — a modular dependency you cannot recompile

Now a library that knows nothing about CDI, whose module opens nothing. You @Produces one of its types as a normal-scoped bean.

If the produced type is fully public (public non-final class, only public overridable methods, an accessible constructor), the proxy is generated at build time in your own package, forwarding through the public API. Nothing at run time. Same for an interface: you get a generated static implementation, not a java.lang.reflect.Proxy.

If it is not, the proxy must be defined inside the library’s own package — and that normally costs an opens the library owner will never grant. This is jakartaee/cdi#1015, and it is rarely an exotic type. A package-private method can only be overridden from the same runtime package, and JLS §6.6.2 forbids forwarding a protected method on another instance from outside the declaring package. So the proxy has to be co-located, and Vauban does it without asking anyone to open anything: the build ships the proxy bytes as a resource under META-INF/vauban/placed/, and the Vauban class loader defines them into the library’s package when the layer is created. No agent, no opens, no rewritten jar.

The catch is honest and the build says it out loud: this requires the application to start inside a Vauban layer, through the Java SE launcher or Vidocq.run. A build cannot promise that — whether a layer exists is decided at launch.

Why this is not an exotic corner

It is rarely an exotic library type. It is the service next door. OrderService injects PricingService, both in the same package, and calls a method nobody ever made public because it never needed to be:

@ApplicationScoped
public class PricingService {

    BigDecimal rawQuote(Order order) { ... }   // package-private, on purpose
}

@ApplicationScoped
public class OrderService {

    @Inject PricingService pricing;            // a client proxy, not the bean

    public Quote quote(Order order) {
        return new Quote(pricing.rawQuote(order));
    }
}

pricing is a proxy. If that proxy does not override rawQuote, the call runs against the proxy’s own empty instance instead of the contextual one: no exception, just a wrong number. That is the worst failure mode a container can have. Overriding the method requires the proxy to sit in the same runtime package as PricingService, which is exactly the constraint the rest of this post is about.

For the record, we would have preferred a specification where only public members are proxyable. The rule would be one sentence long and the generated code trivial. But the code that exists today is full of package-private collaborators between classes of the same package, and a container that quietly broke them would simply be wrong. So we cope, and the tiers above are what coping looks like.

So the build ships two things inside your own bean archive, and they deserve plainer names than "manifest". First, the proxy class itself, pre-generated, written as a resource under META-INF/vauban/placed/ — a resource, not a class, so the build never writes into someone else’s package. Second, the list of produced types whose proxy must sit inside their own package, META-INF/vauban/required-opens.list, one name per line. That list is named after the problem it records: with a Vauban class loader it is the loader’s authorisation to define the shipped proxy in that package; without one, the container reads the very same list to know which packages it would otherwise have to open. At the first lookup miss, the loader defines the proxy into the library’s package.

diag vauban classloading

Two plugin kinds hang off that pipeline, both discovered with ServiceLoader and ordered by priority. A byte-source plugin decides how an archive is read — this is where encrypted modules plug in: the sjar plugin detects its clear header and decrypts entries in memory, so the loader sees ordinary class bytes and nothing else in the chain knows the difference. A class-transformer plugin sees each class before definition; the shipped one is the CDI proxifier, which applies at definition exactly the weaving the javac plugin applies at compile time.

The limits are honest ones. Placement replaces the opens, never the exports: the container instantiates the placed proxy from its own module, so the produced type’s package must still be exported. A type that is final, sealed or abstract stays unproxyable, as the specification says. And a module the loader had to keep in the boot layer is not covered.

Case 3 — a dependency with no module-info

On the module path, a jar without a descriptor is an automatic module: it reads everything, exports everything, and jlink refuses to link it. Before any of the above applies, give it a descriptor:

<plugin>
    <groupId>io.vidocq.vauban</groupId>
    <artifactId>vauban-maven-plugin</artifactId>
    <executions>
        <execution><goals><goal>modularize</goal></goals></execution>
    </executions>
</plugin>

vauban:modularize writes a copy into target/vauban-modularized/ with a synthesized descriptor: requires derived from the types the jar actually uses, every package exported, META-INF/services promoted to provides, and the ServiceLoader lookups found in its bytecode declared as uses — an automatic module may consume any service, an explicit one only what it declares. It is all done with the JDK alone: the Class-File API writes the descriptor bytes, and ModuleFinder answers the only question that matters, which module owns a package.

Once the jar is a real module, you are back in case 2.

example-legacy-app in the repository does exactly this, and its test reads the result back:

open module com.acme.legacy@0.4.0-SNAPSHOT {
    requires jakarta.inject;     (1)
    requires java.base;
    exports com.acme.legacy;
    exports com.acme.legacy.spi;
}
  1. Nothing declared that. One class of the jar implements jakarta.inject.Provider, and the derivation read it off the bytecode.

Case 4 — when there is no class loader to help

One situation has no class loader to work with, and no amount of cleverness changes it:

  • GraalVM native image — classes are defined when the image is built. There is no loader left at run time, so nothing can be placed into anything.

jlink looks like it belongs here and does not. Inside a runtime image every module is served from jrt: rather than from a jar on a module path — but a module in an image is just an exploded directory, the JDK exposes it as /modules/<name> of the jrt file system, and a child layer resolves from it perfectly well. Vauban used to drop those locations and refuse to start; it reads them now, so a jlink image gets its layer, its placed proxies and its load-time weaving like any other run. (That was BUG-20260912-02.)

So case 4 is the native-image case. The proxy has to be in the jar before the application ever runs, and that is vauban:enhance-dependencies: it writes an enhanced copy of the dependency, with the proxy co-located in the type’s own package, a per-package _VaubanComponents, and a rewritten module-info that provides it. Put it ahead of the original on the module path and everything resolves with zero opens and no agent.

Treat it as a last resort, and the goal says so itself — it warns, per type, when the class loader could have shipped the same proxy without touching the jar. Rewriting someone else’s artefact drops its signature, so the copy declares what it is: Vauban-Enhanced-From, Vauban-Enhanced-Digest and Vauban-Enhanced-By in the manifest. It is a modified redistribution; tell your SBOM tooling.

What is generated, and in which form

Source wherever it can be, because source is readable, debuggable, and — crucially — a generated source file can reference another generated source file by name, which is what lets the in-module provider instantiate proxies without reflection. Bytecode only where source is impossible.

Artefact Form Why that form

<Bean>_ClientProxy, <Bean>$$Intercepted, <Bean>_Factory

Source

Written by the APT next to your code, compiled by javac in a later round.

_VaubanComponents (one per package)

Source

It must name the classes above; a generated source cannot see generated bytecode.

The (ProxyLink) entry constructor

Bytecode patch

It modifies a class javac already compiled — the one thing a processor cannot do.

Everything vauban:generate produces for a dependency jar

Bytecode

There is no javac at process-classes; the Class-File API writes it directly.

An in-package proxy for a produced type (case 2b)

Bytecode, shipped as a resource

It must be defined in a package that is not compiled here — the loader defines it at run time.

A synthesized module-info (case 3)

Bytecode

Class-File API. No module-info.java is generated and no compiler is invoked.

One asymmetry worth remembering: the processor sees method-level interceptor bindings, because it reads javac’s model; the Maven plugin only sees class-level bindings from the index. They are not interchangeable.

Interceptors: two sibling subclasses, no reflection at the end of the chain

The intercepted subclass and the client proxy both extend the bean class. They are siblings, not stacked: the proxy’s delegate resolves the contextual instance, and that instance is the $$Intercepted one, because the bean’s factory was swapped at boot.

Each intercepted method gets three generated members: the override that builds the invocation context, a super$` bridge that calls `super.method()` with `invokespecial`, and a private static `ti$ glue lifted into a TargetInvoker through LambdaMetafactory. That last point matters: at the end of the chain, the real method is called through a lambda call site, not through Method.invoke.

diag vauban intercepted call

The chain itself is resolved per call, even for a build-time subclass, because a binding can be added by a build-compatible extension. The invocation index travels as a parameter and never as a field, so proceed() can be called several times — a retry interceptor — and the context is safe on virtual threads.

What the container refuses is what the specification refuses: a final class, or a non-private final instance method, cannot be intercepted or proxied. You get a deployment error naming the type, at boot, not a mysterious failure later.

How to run it: Vauban in plain Java SE

For the loader to own the library’s package, the application has to run inside a Vauban layer. In plain Java SE, that is the launcher, and the entry point itself has to move — it cannot be hidden inside initialize(). A class is identified by its name and its loader: a main left in the boot layer holds the boot layer’s Gadget.class while the placed proxy extends the layer’s Gadget, and select(Gadget.class) ends in a ClassCastException.

So main re-enters itself, once:

public static void main(String[] args) throws Throwable {
    if (Launch.run("com.acme.app/com.acme.app.Main", args)) {
        return; // this call re-launched us inside the layer; nothing left to do here
    }
    try (SeContainer container = SeContainerInitializer.newInstance()
            .addBeanClasses(Integrations.class, CheckoutService.class)
            .initialize()) {
        // the application, now running inside the layer
    }
}

Launch.run returns true when it ran the target in a fresh layer and false, doing nothing, when the caller already runs in one. Your command line does not change:

java -p mods -m com.acme.app/com.acme.app.Main

If you would rather invoke the launcher as the main module, add --add-modules ALL-MODULE-PATH, because with -m the launcher would otherwise be the only root module and there would be nothing to re-layer.

Which modules move is one published policy: the platform, Jakarta and container modules stay, so do automatic modules, modules owning a javax., sun. or com.sun. package, anything you name in -Dvauban.launch.keep, and transitively everything a kept module reads. Your own module is a root: no keep prefix holds it back. Everything else moves.

How to run it: Vauban inside Vidocq

The Vidocq runtime applies the same policy, through a trampoline:

@VidocqMain
public class MyApp implements VidocqApp {

    static void main(String[] args) {
        Vidocq.run(MyApp.class, args);   // nothing before this line
    }

    @Override
    public int run(String... args) {
        Vidocq.waitForExit();
        return 0;
    }
}

Two entry paths exist. Set -Dvidocq.app.path=<archives> and the application archives stay off the JVM module path entirely; the runtime resolves them into the layer. Or set nothing, and the runtime detects the application modules in the boot layer starting from the caller’s own module, which is the plain IDE launch. -Dvidocq.app.modules=<names> overrides the detection when you need to.

The rule to remember is the one the compiler cannot enforce: no business logic before Vidocq.run(…​). The trampoline class is loaded by the boot layer; anything you execute before the call runs in the wrong loader, against untransformed classes.

What happens when you press Run in your IDE

This is where the tiers meet, and it is worth spelling out.

IntelliJ’s build system writes class files to disk after javac has finished, which defeats the auto-started javac plugin: the plan executes, then the IDE overwrites the patched classes with its own copies. The result is a build output where beans lack the entry constructor.

The container notices before loading a single bean class. It reads the bean lists visible on the module path and inspects the candidates' bytes as resources, so nothing is loaded and nothing is decided by reflection. Then it branches:

  • if a Vauban class loader sits in the context class-loader chain — you launched through the trampoline or the SE launcher — it logs that the loader will weave those beans at definition, and no agent is attached;

  • otherwise it warns, listing the affected beans, and self-attaches the load-time weaving agent for this JVM, which applies the same transformation as the class is defined. You also get the JDK’s dynamic-agent notice, and the log suggests the trampoline as the way to avoid it.

Both paths end with the same bytecode. The difference is that the first one needs no agent, which matters as the platform keeps closing dynamic attachment down. Delegating the IDE build to Maven also removes the problem at the source, since then javac writes the classes last.

What you actually see

Every case above leaves a trace. Knowing which one you are looking at is most of the diagnosis.

The build tells you when a proxy had to be shipped rather than generated in your own package. The annotation processor names the obstacle for the module it compiles, and the Maven plugin does the same for dependency archives:

[Vauban] Producer of com.acme.lib.FraudScreen needs its client proxy inside com.acme.lib
(PACKAGE_PRIVATE_VIRTUALS). Its co-located proxy is shipped under META-INF/vauban/placed/ and the
Vauban class loader defines it there when the application runs in a Vauban layer — zero opens, no
agent: start through io.vidocq.vauban.classloader.Launch (or Vidocq.run). […]

The layer announces itself, with the modules that moved into it:

INFO Vauban layer ready: [com.acme.app, com.acme.lib]

Without a layer, the container refuses to open anything behind your back and lists every way out, in order of preference:

WARNING Vauban needs package com.acme.lib of module com.acme.lib opened to io.vidocq.vauban.core
for a runtime producer proxy, and will not do it on its own. Pick one: start the application through
io.vidocq.vauban.classloader.Launch (in a Vauban layer the loader defines the shipped proxy inside
the package itself — zero opens, no agent); make the produced type fully public […]; run the
vauban:enhance-dependencies goal […]; add the matching `opens … to io.vidocq.vauban.core;`; or, as a
last resort, set -Dvauban.opens.auto=true […]

Inside a jlink image, the same line tells you the layer was built from the image itself — the modules are read from jrt:, and everything else behaves as it does on a module path:

INFO Vauban layer ready: [com.acme.app, com.acme.lib]
FraudScreen proxy : com.acme.lib.FraudScreen_ClientProxy  (module com.acme.lib, VaubanClassLoader)

Ignore it and the boot fails, with the whole chain named rather than a bare access error:

jakarta.enterprise.inject.spi.DeploymentException: Failed to create client proxy for normal-scoped
bean com.acme.Integrations
  Caused by: RuntimeException: Failed to define proxy class for class com.acme.lib.FraudScreen
  Caused by: RuntimeException: Cannot reflectively access com.acme.lib.FraudScreen on the module
      path. Either (preferred) provide a generated VaubanComponentProvider for its module […] or
      open the package: `opens com.acme.lib to io.vidocq.vauban.core;`
  Caused by: IllegalAccessException: module com.acme.lib does not open com.acme.lib to module
      io.vidocq.vauban.core

Some shapes nothing can rescue. A final class, a final method, a private-only constructor: CDI 4.1 §3.10 rules them out wherever the proxy sits, so the container stops at boot with one line per problem:

CDI deployment validation failed:
  - Normal-scoped bean com.acme.Ledger cannot be a final class
  - Normal-scoped bean com.acme.Report has final method render
  - Normal-scoped bean com.acme.Session must have a non-private no-arg constructor, or declare a
    non-private constructor taking io.vidocq.vauban.api.ProxyLink as its client-proxy entry point

There is no flag to force those through, on purpose. Change the type, or produce an interface, which carries none of these constraints.

In an IDE, finally, the line that tells you the agent was needed:

WARNING Build output is not woven for 3 normal-scoped bean(s) [...] — attaching the Vauban load-time
weaving agent (typical of IDE builds; Maven/Gradle/javac builds weave at compile time). Disable with
-Dvauban.weaving.loadtime=disabled. Hint: a trampoline main that re-layers the application (Vidocq:
@VidocqMain + Vidocq.run) avoids the agent entirely

Checking it yourself

Everything above leaves evidence on disk. After a build:

# what the processor rendered as source
ls target/generated-sources/annotations/**/*_ClientProxy.java

# the bean index, the list of types needing an in-package proxy, and those proxies
unzip -l target/my-app.jar | grep -E 'vauban-beans.list|required-opens.list|vauban/placed'

# every generated class file must carry the project's release, not the build JDK's
javap -v -cp target/classes com.acme.MyBean_Factory | grep major

At boot, the container tells you which case is in play: a line naming the modules re-layered into the Vauban layer, or a warning naming the beans that still need the agent. A runnable end-to-end example, with a public class, an interface, a class with a package-private member and a class with no accessible constructor, lives in the Vauban repository under example-cdi1015-app.

The full reference is on the Vauban internals page, and the launcher is documented on Vauban in Java SE. Questions and corrections are welcome on our forge.