Vauban rests on a simple principle: anything that can be computed at compile time is. No hot reflection, no dynamic proxies, no external bytecode library. This page documents the implementation choices that uphold that discipline.
Code generation via Class-File API and APT
CDI 4.1 describes a richly reflective object model: Bean<T>, BeanManager, InjectionPoint, AnnotatedType. Vauban refuses to evaluate that model at runtime. It compiles it.
Two tools generate, at two different moments:
-
vauban-processor— ajavax.annotation.processing.Processor, running inside javac. It generates in the last annotation-processing round, mostly as Java source so the generated provider can instantiate its siblings in-module, and through the Class-File API (JEP 484) where source is not possible. It writes the bean indexMETA-INF/vauban-beans.listfor the module it compiles. No ASM, no Byte Buddy, no Gizmo. -
vauban-maven-plugin— thegenerategoal, bound toprocess-classes. It covers what never went through this compilation: dependency archives and modules built without the processor. It merges the bean index and emits the missing classes as bytecode, since it has no javac.
Both also ship what an in-package proxy needs, and it is worth naming the two files plainly:
-
the pre-generated proxy classes, written as resources under
META-INF/vauban/placed/. A resource, not a class, so the build never writes into someone else’s package. At runtime a Vauban class loader defines them inside the produced type’s own package. -
the list of produced types whose proxy must sit inside their own package,
META-INF/vauban/required-opens.list, one binary name per line. The file is named after the problem it records: with a Vauban class loader the list is the loader’s authorisation to define the shipped proxy in that package; without one, the container reads the same list to know which packages it would otherwise have to open. The class loader unions the lists of every archive it manages.
The processor writes both for the module it compiles. Since it never sees an archive compiled
elsewhere, vauban:generate writes them too, for the produced types only it can see, and merges
into whatever the processor already wrote in the same output directory.
vauban-indexer underneath both is a library, not a tool: the class-file scanner, the bean model and the shared component collector. It walks nothing on its own and writes no file.
The processor generates:
| Generated artefact | Role |
|---|---|
|
One per bean. Implements |
|
Subclass generated for normal scopes. Forwards every public method to the active context. Where the proxy is generated depends on who owns the proxied type — see Client proxies — one strategy per case NEW. |
|
Subclass generated for beans with interceptor bindings — routes the intercepted methods through the interceptor chain (see which methods it covers). |
|
One per package. Implements |
Every generated class file carries the project’s Java release, never the release of the JDK that
happens to run the build: the shared GeneratedClassFile.build pins them to Java 25, class file 69.
A build on a newer JDK would otherwise write classes next to javac’s own output that the baseline
runtime refuses to load, with UnsupportedClassVersionError at deployment and jlink refusing the
image. Weaving is the mirror rule: it patches a class someone else compiled, so it hands back that
class’s own version untouched.
NEW The output is also reproducible: the same input gives the same files from one build
to the next. The processor and vauban:generate write the entries of each _VaubanComponents in a
stable order (beans by binary name, a bean’s Intercepted` right after it, then the client
proxies, the producers' proxies, the annotation types and the service file's provider names, each
sorted), and the generators that read a class by reflection, the run-time `Intercepted and client
proxy ones that vauban:generate also uses, list its methods in a stable order rather than in the
order Class#getDeclaredMethods happens to return, which depends on what the JVM loaded before.
NEW A bean method may declare checked exceptions, as CDI allows for constructors,
initializers, observers, producers and disposers. The generated source calls those methods
directly, so it rethrows whatever they throw without declaring it: _VaubanComponents and
MyBean_Factory route the call through a small generic sneaky helper, and the constructors of
MyBean_ClientProxy and MyBean$$Intercepted declare throws Throwable. The exception reaches
the container as thrown, unwrapped, which is what VaubanComponentProvider#invoke promises. The
Class-File path needs nothing of the kind: bytecode has no checked exceptions (vauban#145).
Which methods the intercepted subclass covers NEW
MyBean$$Intercepted overrides every business method of the bean: the ones it declares, the
public, protected and package-private superclass methods it inherits, and the interface default
methods no class of the bean overrides. The processor’s subclass already covered the inherited
protected and package-private methods; the subclass the container generates at run time and the
one vauban:generate writes now cover them too, so a call the bean makes on itself to such a method
is intercepted as well.
Three kinds of method are not business methods and are never overridden, by any of the generators,
which read one list: @Inject initializers, the bean class’s own interceptor methods
(@AroundInvoke, @AroundConstruct, @AroundTimeout), and its lifecycle callbacks
(@PostConstruct, @PreDestroy, and the EJB @PostActivate and @PrePassivate). A lifecycle
callback therefore reaches only the interceptors' own lifecycle methods, never an @AroundInvoke.
A method-level interceptor binding is read from the declaration a call runs. The binding of an
inherited method, on a default method as on a superclass method, stops applying once a class of the
bean overrides that method. The class-level bindings of an interface never apply.
InvocationContext.getMethod() reports that declaration, not the generated $$super$ bridge, and
getInterceptorBindings() includes its bindings. The client proxy of a normal-scoped bean forwards
the same default methods to the contextual instance.
NEW Every client proxy the processor writes forwards these inherited methods, not only
the source one. A bean with private constructors only gets a bytecode proxy built from its
elements, which forwards the inherited and default methods as the source proxy does; the proxy
built from the index alone, which forwards only what the bean class declares, is left for a bean the
compiler cannot resolve. A top-level class whose own name contains $ (Dollar$Service) is named
by its canonical name, so its intercepted subclass, its producers' proxies and its annotation
artefacts compile, and a normal-scoped producer of a nested class or interface gets a build-time
proxy like any other.
NEW An intercepted bean with an @Inject constructor takes its parameters' qualifiers
from the bean’s descriptor, as a bean that is not intercepted does, so it is created under
-Dvauban.annotations.reflection=forbid.
An inherited member may have a signature, as a member of the bean, that names a type the generated
class’s package cannot name: a package-private type of another package, or a private nested type.
Java source cannot override such a method; a class file can, by its descriptor.
NEW The processor therefore emits that intercepted subclass or client proxy — a
producer’s proxy included — as bytecode, with the same emitters the container and vauban:generate
use, and only that class: everything else is still rendered as source. The module’s source
_VaubanComponents reaches these classes through its own MethodHandles.lookup(), so the module
still needs no opens. Such a build used to fail. Two cases remain where a method is left out:
-
a default method shadowed by a superclass method the bean does not inherit, when no interface carrying it can be named from the generated class’s package: the processor’s client proxy and intercepted subclass leave it out, and the processor prints a
[Vauban]warning that says what a call does instead; -
a method whose return type the intercepted subclass may not access: no class outside that type’s package can type the value the interceptor chain returns, so every generator leaves the method out of the subclass, and the processor warns. A call runs it as on a plain instance of the bean, not intercepted. The run-time subclass used to throw
IllegalAccessErroron every call instead.
Client proxies — one strategy per case NEW
A normal-scoped bean is never handed out directly: the container returns a _ClientProxy
subclass that forwards every business method to the contextual instance. Where that
proxy is generated, and whether it costs an opens, depends entirely on who owns the
proxied type. Vauban resolves this in the order below; the first strategy that applies
wins.
| Case | Strategy | Cost |
|---|---|---|
Managed bean — a bean class you compile |
The APT emits |
Zero |
Producer of a fully-public class — |
The APT emits the proxy at build time in the producer’s package (never the produced
type’s — that would be a split package), registered under the produced-type key the
runtime looks up before any fallback. Eligibility requires the produced type to be fully
public: public non-final non-sealed class, only public overridable methods, an accessible
(public/protected) constructor. The check walks the whole superclass hierarchy (up to, but
excluding, |
Zero |
Producer of an interface |
The APT emits a build-time static class |
Zero |
Producer of a class whose proxy must live in its own package — a package-private or protected overridable member (declared or inherited), or no accessible constructor — when a Vauban loader defines the library NEW |
The APT ships the co-located proxy as bytecode in the bean archive
( |
Zero |
Same shapes, when no Vauban loader defines the library — a bare boot layer, or a module the
launcher or |
Nothing can be placed. |
An |
Producer of a class that is unproxyable outright — |
No proxy can be built anywhere, co-located or not. A deployment error, as the specification requires; nothing is shipped. |
Nothing helps — the type cannot be normal-scoped. |
The two levers that remove the hand-written opens when no Vauban loader defines the library
(when one does, placement makes both unnecessary):
-
The container opens the module itself (42, Stage 3b) — opt-in, and on borrowed time [.tag-new]#NEW. The annotation processor lists the produced types that fall to runtime generation in
META-INF/vauban/required-opens.list; at bootOpensApplierloads each and — only when it is in a named module and not already open — callsInstrumentation.redefineModuleto open its package toio.vidocq.vauban.core, reusing the load-time weaving agent (Client-proxy weaving tiers). No--add-opens, no jar rewrite, and a strict no-op when the list is absent (the class path, and the common module-path deployment).This lever is off unless you set
-Dvauban.opens.auto=true. Opening another module’s package behind the user’s back is precisely what this project faults runtime CDI implementations for needing — the only difference would be who grants it; and silently acquiring a dependency on dynamic agent attachment is a defect that surfaces late, on a locked-down JVM in production. Left off, the container refuses and logs what is missing together with every way to avoid it. Treat it as a last resort: integrity by default is closing dynamic agent attachment, so prefer a fully-public produced type (nothing to open) or the build-time lever below. -
Rewriting a copy of the dependency jar (#42, Stage 2, opt-in, last resort). Prefer the shipped in-package proxy above: it needs no copy and leaves the artefact alone. Keep this goal for the three cases placement cannot reach — the application never starts inside a Vauban layer, the library exports nothing so the container cannot instantiate the placed proxy, or the target is a GraalVM native image, where a class defined at runtime is not supported. The goal now says so itself: it warns, per type, when the class loader could have shipped the same proxy. The
vauban:enhance-dependenciesgoal writes an enhanced copy of a third-party jar undertarget/vauban-enhanced-deps/in which<Type>_ClientProxyis co-located in the type’s own package — so it can forward package-private and protected methods — and themodule-info.classis rewritten toprovidesthe generated component provider. Placed ahead of the original on the module path it resolves with zeroopensand no agent — and it survives integrity by default, since nothing is opened, attached or defined at runtime.Because the copy is a modified redistribution of someone else’s artefact, it declares itself NEW: the enhanced jar drops the inherited signature files (
META-INF/*.SF,.DSA,.RSA,.EC) rather than carrying a signature over content that no longer matches, drops the manifest’s per-entry digests, and records its origin in the manifest main attributes:Vauban-Enhanced-From: org.example:libwidget:1.0 Vauban-Enhanced-Digest: sha256:<digest of the exact source jar> Vauban-Enhanced-By: vauban-maven-plugin/<version>Declare these copies to whatever SBOM, attestation or reproducible-build tooling your pipeline runs — they are not the artefact their coordinates name. That governance cost, not a technical limitation, is why the goal is opt-in.
A producer whose class type is not build-time proxyable is surfaced at compile time by
-Avauban.producerProxy=error|warn|note (default note), naming the reason and the ways
to keep zero opens.
|
When no Vauban loader defines the library, the reflex is |
|
The class-typed front-ends build the same neutral |
Case by case: from your source to a running proxy
The strategy table above says what is generated. This section walks the four situations you can actually be in, and shows what each one prints.
1. Your own code, compiled here
The processor renders the factory, the client proxy, the intercepted subclass and the per-package provider as Java source in the same compilation, and the auto-started javac plugin weaves the side-effect-free entry constructor into the compiled beans once javac has written them.
Nothing is logged. Silence is the success case: nothing reflects into your classes, no opens, no agent. What you can
check is on disk, under target/classes and target/generated-sources/annotations.
2. A dependency the processor never saw
Ecosystem jars and modules built without the Vauban processor carry no generated code. At
process-classes the vauban:generate goal scans them, merges their beans into
META-INF/vauban-beans.list, weaves the entry constructor into what javac wrote, and — for a
produced type whose proxy cannot live in the producer’s package — ships that proxy pre-generated and
lists the type.
The build says so, and deliberately stops short of promising it will work:
[WARNING] Shipped an in-package client proxy for 1 produced type(s) that cannot be proxied from
the producer's package: [com.acme.lib.FraudScreen]. The Vauban class loader defines them inside the
produced type's own package, which requires the application to start through
io.vidocq.vauban.classloader.Launch or Vidocq.run, and that package to be exported. Started without
a Vauban layer, the container falls back to opening the package at runtime and says so at boot.
The annotation processor prints the same verdict for the module it compiles, naming the obstacle:
[Vauban] Producer of io.vidocq.vauban.example.cdi1015.lib.FraudScreen needs its client proxy inside
io.vidocq.vauban.example.cdi1015.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). On a bare module path the runtime generates it reflectively, which needs `opens
io.vidocq.vauban.example.cdi1015.lib to io.vidocq.vauban.core;`. […]
3. A third-party module, running inside a Vauban layer
Start through the launcher or Vidocq.run and the loader owns the library’s module, so it defines
the shipped proxy into the library’s own package on the first lookup miss. One line tells you the
layer exists, and which modules moved into it:
INFO io.vidocq.vauban.classloader.Launch run
Vauban layer ready: [io.vidocq.vauban.example.cdi1015.app, io.vidocq.vauban.example.cdi1015.lib]
The runnable example then prints where each proxy ended up. Two live in the application’s own package, because their produced types are fully public; two live inside the library, placed by the loader:
PaymentGateway proxy : …app.PaymentGateway$$8dad1edd_ClientProxy (reflect.Proxy? false)
AuditLog proxy : …app.AuditLog$$386ae28_ClientProxy (reflect.Proxy? false)
FraudScreen proxy : …lib.FraudScreen_ClientProxy (module …cdi1015.lib, VaubanClassLoader)
ReceiptPrinter proxy : …lib.ReceiptPrinter_ClientProxy (module …cdi1015.lib, VaubanClassLoader)
4. The same application, started without a layer
The bytes are still in the jar, but no loader owns the library’s package, so nothing can define them there. The container refuses to open the package behind your back and lists every way out:
WARNING Vauban needs package io.vidocq.vauban.example.cdi1015.lib of module
io.vidocq.vauban.example.cdi1015.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 (the proxy is then
built at compile time and nothing needs opening); run the vauban:enhance-dependencies goal (the
proxy moves inside the dependency, still nothing to open); add the matching `opens … to
io.vidocq.vauban.core;`; or, as a last resort, set -Dvauban.opens.auto=true to let the container
open it at boot through an agent — which integrity by default is closing off.
Ignore the warning and the boot fails at the injection point, with the whole chain named:
jakarta.enterprise.inject.spi.DeploymentException: Failed to create client proxy for normal-scoped
bean io.vidocq.vauban.example.cdi1015.app.Integrations
Caused by: java.lang.RuntimeException: Failed to define proxy class for class …lib.FraudScreen
Caused by: java.lang.RuntimeException: Cannot reflectively access …lib.FraudScreen on the module
path. Either (preferred) provide a generated VaubanComponentProvider for its module […] or
open the package: `opens io.vidocq.vauban.example.cdi1015.lib to io.vidocq.vauban.core;`
Caused by: java.lang.IllegalAccessException: module …cdi1015.lib does not open …cdi1015.lib to
module io.vidocq.vauban.core
5. Last resort: rewriting a copy of the dependency
When no layer is possible — a native image, a library that exports nothing, a launch you do not
control — vauban:enhance-dependencies writes an enhanced copy of the jar with the proxy inside the
type’s own package. It costs the jar’s signature, so the goal tells you when it was not needed:
[WARNING] enhance: com.acme.lib.FraudScreen only needs its client proxy inside its own package
(PACKAGE_PRIVATE_VIRTUALS). The Vauban class loader ships and defines that proxy without touching
the jar, while rewriting a copy here drops the jar's signature. Keep this goal for what placement
cannot do: no Vauban layer at launch, a package the library does not export, or a GraalVM native
image.
6. When no proxy is possible at all
Three shapes are unproxyable wherever the proxy sits, so nothing is shipped for them and the container refuses to start. The validation runs at build time through the annotation processor and again at boot, and both speak the same language.
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
(Vauban extension, Vidocq/vauban#24)
That block is a jakarta.enterprise.inject.spi.DeploymentException, thrown out of
SeContainerInitializer.initialize() with no wrapping of its own: one line per problem, each
prefixed with a dash. A DefinitionException carries the same shape under the header
CDI definition validation failed:.
Wiring problems are reported the same way, and are worth recognising because they look alike but have nothing to do with proxies:
- Unsatisfied dependency: field com.acme.Checkout#gateway of type com.acme.PaymentGateway with
qualifiers [@Default]
- Ambiguous dependency: field com.acme.Checkout#store of type com.acme.Store. Matching beans:
[com.acme.SqlStore, com.acme.MemoryStore]
If a bean slips past the build — a class you did not compile, a scope decided by an extension — the
runtime refuses at the moment the proxy is requested, with
jakarta.enterprise.inject.UnproxyableResolutionException:
Normal scoped bean com.acme.Ledger is final
Normal scoped bean com.acme.Report has final method render
Normal scoped bean com.acme.Session has only a private no-arg constructor
Interception adds its own checks, because an intercepted bean is also a subclass. A managed bean that is not normal-scoped and carries a class-level interceptor binding is checked with the rest of the deployment, in the block above:
- Intercepted bean com.acme.Audit cannot be final (interception requires subclassing)
- Intercepted bean com.acme.Audit has final method write (interception requires subclassing)
NEW A bean intercepted any other way is checked when the container wraps it: through a
method-level binding it declares or inherits, an interface default method included, a constructor
binding, or an @AroundInvoke method of its own. The DeploymentException then says why the bean
is intercepted, since its own source may carry no binding at all:
jakarta.enterprise.inject.spi.DeploymentException: Intercepted bean com.acme.Audit must not be
final. An intercepted bean must be proxyable (CDI 4.1 §3.10); com.acme.Audit is intercepted
because of the method-level interceptor binding @com.acme.Logged of com.acme.Journaled.write(),
an interface default method it inherits.
The same explanation follows has final method <name>. and has only private no-arg constructor
(unproxyable). A final class used to fail there with a DefinitionException.
None of these has a workaround in Vauban, and that is deliberate: CDI 4.1 §3.10 rules them out, so
the answer is to change the type — drop final, add a non-private constructor — or to produce an
interface instead, which is proxied without any of these constraints.
7. An IDE run, where the build output was never woven
IntelliJ writes class files after javac has finished, which defeats the javac plugin. The container notices before loading a bean and tells you which tier took over. Inside a Vauban layer it simply weaves at definition. Outside one, it attaches the agent and says so:
WARNING Build output is not woven for 3 normal-scoped bean(s) [com.acme.Checkout, com.acme.Ledger,
com.acme.Audit] — 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
APT pipeline — sequence diagram
The whole index, the whole injection grid, every proxy is written before javac finishes its job.
Client-proxy weaving tiers
A normal-scoped bean without a non-private no-arg constructor needs a side-effect-free
client-proxy entry point: the synthetic protected <init>(ProxyLink) constructor
(Usage — proxyable beans). Annotation processing cannot add members to an
existing class (JSR 269 only creates new files), so the marker is woven into the compiled
bytes by the Class-File API transformations of the vauban-weaver module — one canonical
implementation, applied at three possible moments:
| Tier | When | Covers |
|---|---|---|
javac plugin |
|
Maven, Gradle, plain |
|
|
Belt-and-braces for Maven builds; ecosystem jars whose classes do not go through javac (enrichment, sjar packaging) |
Load-time agent |
Container boot — |
IDE builds — IntelliJ’s build system (JPS) flushes hand-written classes to disk after javac finishes, so both build-time tiers see stale files |
Details worth knowing about the load-time tier:
-
Why an agent and not reflection — Vidocq application modules keep bean packages fully encapsulated (zero
opens, zeroexports). Any reflective instantiation trick (ReflectionFactory-style relaxed construction) is blocked by Java Modules; aClassFileTransformerworks below access control and produces bytecode identical to a woven build. -
Self-attach through a child process — the JVM refuses
VirtualMachine.attach()on itself;LoadTimeWeavingspawnsjava -cp vauban-weaver.jar io.vidocq.vauban.weaver.AttachBack <pid> <jar> <plan>, which attaches back to the parent and loads the agent. The IDE debugger session is unaffected. The JDK prints its standard dynamic-agent warning. The same agent (hence the sameInstrumentation) is reused byOpensApplierto open a third-party module’s package when a producer’s type is not fully public (Client proxies — one strategy per case NEW). -
Plan-driven, no heuristics — the agent only ever touches the classes named in the plan computed from the container’s own index: unwoven beans get the marker constructor, their
_ClientProxycompanions are retargeted to it. -
Ordering constraint — adding a constructor is not a valid retransformation, so a class loaded before the attach can no longer be woven. The hook therefore runs inside
VaubanContainerBuilder.build()right after the index is final and before bean discovery, and the detection itself never loads classes. -
In a Vauban layer, no agent NEW — when the application’s classes are defined by a
VaubanClassLoader(the Java SE launcher,Vidocq.run, a dist), the loader’s cdi-proxifier transformer applies the same weaving at class definition;LoadTimeWeavingdetects the loader in the context class-loader chain and stands down. When the loader also defines a library whose proxies it places, the transformer weaves the listed produced types — which carry no scope annotation — and the superclasses they need, so a placed client proxy does not chain a third-party constructor. -
Opt-out —
-Dvauban.weaving.loadtime=disabled; unwoven beans then fail deployment validation with the regular vauban#24 diagnostic. Explicit-javaagent:vauban-weaver.jar=<plan>is also supported (Premain-Class).
Giving a dependency a module descriptor
A jar with no module-info is an automatic module: its name is guessed from its file
name, it reads everything, and nothing can depend on it reliably. jlink refuses it
outright. The fix is to give it a real descriptor — which used to mean delegating to
ModiTect, a build-time dependency that generates module-info.java and runs a compiler
over it.
ModuleDescriptorSynthesizer (in vauban-maven-plugin) does the same work with the JDK
alone. There is no generated source and no compiler invocation: the Class-File API writes
the descriptor bytes directly, and ModuleFinder answers the only question that matters —
which module owns a given package.
| Directive | How it is derived |
|---|---|
|
Every class of the jar is read and every type name in its constant pool collected — descriptors, internal names, annotation types and generic signatures alike. Each referenced package is attributed to the module that exports it (a JDK module, or a module of the dependency closure; an automatic module owns everything it holds). Packages nobody owns are reported, never guessed at. |
|
Every package the jar contains. A library that used to sit on the class path had everything visible; narrowing that would break its users for no benefit. |
|
Nothing, for an |
|
Promoted from |
|
Supplied by the caller. Which services a jar looks up cannot be derived from its bytes without a call-graph analysis, and guessing wrong makes the module fail to resolve. |
The collection errs towards completeness: a spurious requires costs nothing, a missing
one breaks the application at run time. What no static analysis can see — Class.forName
on a computed name — is invisible to jdeps too.
The patched jar is a copy, written under the original file name so downstream tooling can substitute it. Signature files are left behind on purpose: adding an entry invalidates them, and a jar whose signature no longer verifies fails to load at all.
Class loading
Vauban owns application class loading, as a single engine with two plugin natures
chained before defineClass:
┌─ sources ─────────────┐ ┌─ transformers ───────────┐
archive ───▶ │ dir | jar | sjar | … │ ─▶│ cdi-proxifier | (future) │ ─▶ defineClass
└───────────────────────┘ └──────────────────────────┘
-
vauban-classloader-spi— the contracts:ByteSourcePlugin(where the bytes of an archive come from — the encrypted-jar support invauban-sjaris one plugin) andClassTransformerPlugin(what happens to the bytes before definition — experimental SPI). Both are ServiceLoader-discovered and priority-ordered. -
vauban-classloader— the engine:VaubanClassLoaderresolves each archive through the first source plugin that handles it (plain jars and exploded directories built in) and pipes every class through the transformer chain. The shippedcdi-proxifiertransformer applies the vauban#24 weaving (ProxyLinkWeaver, same bytes as a build-time weave — idempotent) at definition; an unwoven bean inside an encrypted archive can be fixed nowhere else, the decrypt → weave composition falls out of the chaining for free. The container’s scanner defines sjar classes through this engine. Diagnostics: transformations are logged,-Dvauban.classloader.dump=<dir>writes transformed bytes,-Dvauban.classloader.transformers.disabled=<names>disables transformers by name, and a transformer failure fails the class load — nothing silent. -
VaubanLayerFactory— the module-path story: application archives stay off the JVM module path and resolve into a childModuleLayerwhose single defining loader is the engine. Exports and opens keep being enforced inside the child layer;ServiceLoaderprovides/uses work across it. The Vidocq launcher activates this with-Dvidocq.app.path=<archives>(VidocqAppLayer, installed before anything can touch an application class), and every Vidocq launch vehicle uses it by default: packaged distributions ship the application jar inapp/(scripts boot the runtime,vidocq.package.layer=falseopts out),vidocq devhandstarget/classesover the same way (vidocq.dev.layer=falseopts out), and in-process embedders (the CLI) get the layer fromVidocqBootstrap.configure(). An applicationmainis run through the layer via-Dvidocq.app.main— its package is exported to the runtime through the layerModuleLayer.Controller, the same technique the JDK launcher uses. The load-time weaving agent then stands down — the loader does the weaving, withoutjava.lang.instrumentand without the JDK’s dynamic-agent warning. Implementation note for custom layer loaders: the JVM loads layer-module classes throughfindClass(String moduleName, String name), whose inherited default returnsnull— both name-based and module-based lookups are overridden. Known limitation: custom JUL log handlers declared inlogging.propertiesmust live on the module path —LogManagerinstantiates handlers through the system class loader and cannot see the layer. -
The container itself never reflects into application packages: generated
_VaubanComponentsproviders (ServiceLoader) instantiate beans and proxies in-module, which is what keepsopens-free deployments possible.
The layer is also what makes the dev-mode in-JVM hot reload possible (M3): after a
successful recompile, vidocq dev signals the running JVM (reload file touch,
vidocq.dev.hotReload=false restores the respawn cycle) — the runtime shuts the
deployment down, discards the application layer and its loader, resolves a fresh layer
over the recompiled classes and boots again. Same process, warm JIT, debugger and dev
services untouched. Hosts embedding Cassini across such reloads must discard its static
discovery caches (io.vidocq.cassini.runtime.CassiniMaintenance.resetDiscoveryCaches(),
called by the Vidocq Cassini extension’s onStop): they are keyed by application
Class objects, which a new layer replaces wholesale.
Design study and milestones: tasks/vauban-classloader-universal.md (M1 engine + sjar
fold-in, M2 application layer, M2b launch vehicles and M3 in-JVM hot reload are
implemented).
Runtime bootstrap sequence
The ApplicationContext is built in memory, without opening any package.
Failures are reported, not swallowed NEW
-
A class named in a
META-INF/vauban-beans.listthat fails to load is still skipped, but with a warning that names it and the likely causes: a split package, or a missing module. It used to vanish without a word, and the only trace was a downstream failure with no cause. -
A failed field injection reaches the caller. A
RuntimeExceptionkeeps its type, so a caller catchingUnsatisfiedResolutionExceptionorIllegalProductExceptionstill sees it, and a checked exception is wrapped in aCreationExceptionthat names the field. The container used to log it, leave the fieldnulland hand the bean out, and the failure came back later as aNullPointerExceptionin application code.Bean#getInjectionPoints()likewise throws rather than reporting no injection points. -
The build compatible extension phase resolves a bean class across the whole deployment: the classes the container holds, the loader set on the builder, then the build’s context loader — inside a Vauban layer, the layer’s loader, which sees the application and the libraries it re-layered. It used to take the loader of the first bean class, which reaches every archive only when the deployment has one loader: under
vidocq:dev, where the application’s classes and its libraries are defined by two loaders, CDI 4.1 invokers fell back to reflection for every application bean. The run-time@Enhancementpass resolves classes the same way.
Threading model
Vauban uses virtual threads (JEP 444) in two places:
-
Async observers — every
@ObservesAsyncis dispatched on anExecutors.newVirtualThreadPerTaskExecutor()internal to theEventDispatcher. No platform-thread pool, no bounded queue. -
Request context — when Vauban is embedded in Cassini or Foy, the
RequestContextis carried by aScopedValue(JEP 487) rather than aThreadLocal. That lets structured virtual threads propagate the context without leaks.
|
The |
AOT compatibility
What the container relies on at run time:
-
Generated code for everything that builds a bean —
_Factoryclasses, the per-package_VaubanComponentsprovider,_ClientProxyand$$Interceptedsubclasses — called directly. No bytecode is generated at run time on this path. -
MethodHandles, throughVaubanLookup, for@PostConstruct,@PreDestroy, observer and interceptor methods. -
LambdaMetafactoryto lift the generated interceptor glue into the invocation chain.
What still reflects:
-
Class loading by name. The bean index is a list of names, and discovery turns each into a
ClasswithClass.forName. That needs noopens— a public class of an exported package loads without one — but an ahead-of-time compiler cannot see a computed name. -
Annotations. Matching no longer reflects: qualifiers and interceptor bindings are compared as
AnnotationKey`s built from the index (member defaults applied, `@Nonbindingleft out — the members a declaration marks and the ones an extension makes non-binding alike) — the outcome of vauban#70. Injection does not read them either: the qualifiers of a field or a parameter come from the descriptor the index built, not from the member the container holds.A module compiled with the Vauban APT also ships what its own annotation types declare — their members, their declared types, their defaults, which are
@Nonbinding— together with a reader and a literal class per type, in that type’s own package.Bean#getQualifiers()then hands out the module’s own literal, so even a package-private qualifier costs no reflection and needs noopens.A qualifier from a dependency built without the processor is covered by the module that uses it, which carries a literal for it in its own package — provided the type is public, since no other package can implement a package-private interface. That last case, a non-public qualifier in a module compiled without the processor, is the one where a
java.lang.reflect.Proxyover index data remains the fallback.What is left reflects only where nothing describes the site. A disposer’s non-
@Disposesparameters are no longer among them: the descriptor now models them as injection points (Producers and@Disposes). What remains is theAnnotatedTypea portable extension passes togetInjectionTargetFactory, which by construction comes from the caller; and a live annotation the index does not describe — an@Inheritedone found on a superclass, a literal passed by a build compatible extension or a synthetic bean, the default of a member read from its declaration. TheAnnotatedSPI facades are not in that list: the specification requires them to hand out the annotations themselves.-Dvauban.annotations.reflection=allow|warn|forbid(defaultallow) logs or refuses every one of those, so a deployment can prove it never needs them.forbidthrows a type of its own that nocatch (Exception)on the way out swallows. See Reference — system properties. -
Fallbacks. A type no generated provider covers — the class path, a producer only known at run time — is instantiated reflectively, and the container says so at boot.
A GraalVM native image therefore needs reflection metadata for the above today, and Vauban does not
generate it. Placed in-package proxies are defined at run time and are not available in a native
image at all: vauban:enhance-dependencies is the ahead-of-time path for those (see
Vauban in Java SE, Limits).
Project Leyden CDS and jlink images are unaffected: every byte is produced at build time, and the
Vauban layer reads modules straight from a runtime image.
Module lookups for trusted extensions NEW
Some extensions must call a member the application never opened. MicroProfile Fault Tolerance is the first: @Fallback(fallbackMethod = "recover") may name a private method, and the implementation lives in another module. Without help it needs opens <package> to <extension>, which is exactly what Vauban removes everywhere else.
io.vidocq.vauban.core.access.ModuleLookups.lookupFor(Class<?>) returns an Optional<MethodHandles.Lookup>: a full-privilege lookup on that one class, so the extension can findSpecial / findVirtual the member and nothing else needs to change.
- Where the lookup comes from
-
The generated
_VaubanComponentsof the class’s own package. It is written by the processor, or byvauban:generate/vauban:enhance-dependenciesinto the completed dependency’s package, so in both cases it belongs to the class’s module. ItsgrantModuleLookup(ModuleLookupGrant)doesgrant.accept(MethodHandles.lookup()). The container keeps that lookup only if its lookup class is the provider itself and it has full privilege. It then hands outprivateLookupIn(managedClass, providerLookup), which is legal without any read edge oropensbecause both are in the same module. The application writes nothing and opens nothing: shipping the generated provider is the consent, as it already is for in-module instantiation and field injection. - What is handed out
-
A lookup only for a class the running container, or the container being built, manages, whose package has a generated provider granting one. Nothing for any other class, and nothing once that container is closed. A deployment is registered once its providers are loaded, so build compatible extensions can already ask during
@Enhancement. - Who may ask
-
Only the modules named in
io.vidocq.vauban.core’s qualified `exports io.vidocq.vauban.core.access to …. Today that isio.vidocq.heisenberg.cdi.vauban. A new consumer is added there, by name, in review. A provider hands its lookup over only through aModuleLookupGrant, and only the container can create one: the factory sits inio.vidocq.vauban.api.access, exported toio.vidocq.vauban.corealone. Code that reaches a provider throughServiceLoadercannot make it give its lookup away. On the class path every module is unnamed and nothing of this is enforced, but nothing is encapsulated there anyway.
Providers generated before this method existed grant nothing; the extension keeps its own fallback (for Heisenberg, an opens to io.vidocq.heisenberg.core).
The container uses the same lookup itself to wire an intercepted bean: once the generated provider has created the Intercepted` subclass, its post-construction method `init is called through a method handle found with that lookup, so the bean’s package needs neither exports nor opens (BUG-20261010-02). Without a granting provider, the call falls back to reflection, which needs the package exported to io.vidocq.vauban.core.
Exported Java modules
| Module | Main exports |
|---|---|
|
|
|
|
|
|
|
(internal, requires |
|
|
|
|
|
|
|
AES-256-GCM encrypted implementation of the SPI (optional) |
See the reference for the exhaustive list.
Sources
-
vauban-core — container runtime
-
vauban-processor — APT and Class-File API generators
-
vauban-indexer — build-time scanner
-
BUG.md — tracked reproducible bugs (VAU-PRX-002, VAU-INJ-001)