Quarkus Shim
Patch any Java class at build time — insert, wrap, or replace behavior in code you don’t own.
Shim is a Quarkus extension that weaves your patches into target classes during augmentation
(via BytecodeTransformerBuildItem and ASM). Because everything happens at build time,
patched classes work in JVM mode, dev mode (with live reload) and GraalVM native image alike —
no Java agent, no runtime instrumentation.
| "Shim" here means modifying existing behavior in classes you cannot edit — not a JavaScript-style compatibility polyfill. |
The six kinds of hook:
|
run code at method entry; may receive |
|
run code before every normal return; may receive |
|
run code when the method exits by throwing; may receive |
|
run code however the method exits; may receive |
|
replace the method body entirely |
|
wrap the method — call the original via |
Installation
Add the extension to your application:
<dependency>
<groupId>io.quarkiverse.shim</groupId>
<artifactId>quarkus-shim</artifactId>
<version>0.4.0</version>
</dependency>
Usage
Declare a shim class annotated with @Shim, pointing at the class to patch. Static hook
methods inside it describe the patches:
@Shim(Greeter.class) (1)
public class GreeterShim {
@ShimReplace(method = "greet") (2)
public static String greet(Greeter self, String name) {
return "Patched " + name;
}
@ShimReplace(method = "answer") (3)
public static int answer() {
return 42;
}
@ShimBefore(method = "touch") (4)
public static void beforeTouch(Greeter self) { /* ... */ }
@ShimAfter(method = "touch") (5)
public static void afterTouch() { /* ... */ }
}
| 1 | The class to patch. Use @Shim(targetName = "com.acme.internal.Greeter") when the class
is not visible from your code. |
| 2 | Replaces the whole method body. For instance methods the first parameter receives this;
remaining parameters and the return type must match the target method. |
| 3 | Replaces a static method: parameters match exactly. |
| 4 | Runs at method entry. Must be static void; may declare a single self parameter
(target type or Object) to receive the instance — allowed only on instance target
methods. |
| 5 | Runs before every normal return (not on exceptional exit). |
Arguments, return value, and ordering
@ShimBefore may receive self (the target class or Object) followed by a prefix of the
target’s arguments; @ShimAfter may receive self and, as a trailing parameter, the value
about to be returned:
@ShimBefore(method = "process")
public static void logInput(Pipeline self, String input) { ... }
@ShimAfter(method = "process")
public static void logOutput(Pipeline self, String returned) { ... }
When several hooks target one method, order them with @ShimPriority (lower runs first;
before-hooks ascend at entry, after-hooks ascend before each return):
@ShimBefore(method = "process") @ShimPriority(1) public static void first() { ... }
@ShimBefore(method = "process") @ShimPriority(10) public static void later() { ... }
Wrapping with @ShimAround
@ShimAround is the most general hook: it runs in place of the target and calls the original
through a ShimCall, so it can inspect, short-circuit, or transform the result.
@ShimAround(method = "greet")
public static String greet(ShimCall<String> original, Greeter self, String name) {
return original.proceed().toUpperCase(); // run the real greet(name), then transform
}
For an instance target the hook takes ShimCall, then self, then the target’s parameters;
for a static target, ShimCall then the parameters. The ShimCall type argument is the
target’s boxed return type (Void for void methods). @ShimAround must be the only hook on
a method and cannot target constructors.
Selecting an overload
When a method name is overloaded, pin the patch to one overload. Either give the JVM descriptor, or — more readably — the parameter types as class literals:
@ShimReplace(method = "format", paramTypes = { int.class }) // readable
@ShimReplace(method = "format", descriptor = "(I)Ljava/lang/String;") // equivalent
Attaching annotations
Put @ShimAnnotate on a shim class, method, or field and declare the annotations to copy on
the same template element:
@Shim(LegacyService.class)
@ShimAnnotate
@Deprecated(since = "shim")
public class LegacyServiceShim {
@ShimAnnotate(target = "state")
@Deprecated(since = "shim")
Object stateAnnotations;
@ShimAnnotate(target = "run", paramTypes = String.class)
@Deprecated(since = "shim")
void runAnnotations() {}
}
On a class, annotations are attached to the target class. On a method or field, target
defaults to the template member’s name; method overloads can be selected with paramTypes or
descriptor. Annotation values and RUNTIME/CLASS retention visibility are preserved.
If the target already declares the same annotation type, the shim annotation replaces it by
default. Set onConflict = AnnotationConflict.KEEP to retain the target annotation, or
onConflict = AnnotationConflict.FAIL to fail augmentation instead. REPLACE can also be
specified explicitly.
Attachment is a bytecode transformation, so reflection and other JVM consumers see the
annotations after augmentation. Quarkus build steps that only read the immutable Jandex index
do not; @ShimAnnotate is therefore not a way to add build-time annotations such as CDI scopes
or REST endpoints.
Reaching private and package-private members
Private fields and methods
The JVM enforces private access even at the bytecode level, so hook bodies use the
ShimFields / ShimMethods helpers (cached reflection; every @Shim target class is
automatically registered for reflection, so this works in native image too):
@ShimReplace(method = "greet")
public static String greet(Greeter self, String name) {
int count = ShimFields.<Integer> get(self, "greetCount") + 1; // private field read
ShimFields.set(self, "greetCount", count); // private field write
return ShimMethods.invoke(self, "decorate", "Patched " + name); // private method call
}
Static members use ShimFields.getStatic / setStatic and ShimMethods.invokeStatic.
When overloads cannot be inferred from runtime values (especially null), use
ShimMethods.invokeExact / invokeStaticExact with an explicit Class<?>[] signature.
Package-private classes and members: the same-package trick
Declare the shim class in the same package as the target. Application and dependency
classes share the Quarkus ClassLoader, so they live in the same runtime package — the shim
can then name package-private classes directly, call their package-private methods, and
access protected members with plain compiled code (no reflection):
package com.acme.internal; // same package as the library internals
@Shim(HiddenHelper.class) // a package-private class — visible from here
public class HiddenHelperShim {
@ShimReplace(method = "compute")
public static int compute(HiddenHelper self, int input) {
return self.packagePrivateMethod(input); // direct call, no reflection
}
}
Classes you cannot name at all
For private nested classes and similar: target them with @Shim(targetName = "…") and type
the self parameter as Object — combined with ShimFields/ShimMethods this covers
classes that cannot appear in source.
|
Dev mode and the same-package trick. Direct source-level access to a target’s
package-private/protected members works only when the shim and the target share a runtime
package (same classloader + package name). That always holds in JVM production, in native
image, and for a shim patching another application class in dev mode. But dev mode loads
application classes and dependency classes with different classloaders, so a shim (an
application class) using the same-package trick to reach a dependency’s package-private or
protected member can fail with Everything else works identically across JVM, dev mode and native image: the transform itself,
all hook kinds (including |
Final fields
Reading is unrestricted, but writing a final field via reflection is fragile (forbidden for
static final and records, and the JDK is progressively restricting reflective final
mutation). Since Shim already rewrites the target class, list the fields in definalize and
the transformer strips their final modifier at build time — the write becomes an ordinary
field write:
@Shim(value = Widget.class, definalize = { "name" })
public class WidgetShim {
@ShimAfter(method = "<init>")
public static void afterConstruct(Widget self) {
ShimFields.set(self, "name", "patched"); // 'name' is declared final on Widget
}
}
Static compile-time constants (static final int X = 5) cannot be definalized — javac
inlines their value into every reader at compile time, so rewriting the field would not
affect them; the build fails with an explanation instead.
Removing final forfeits the memory-model safe-publication guarantee for that field.
This only matters for instances shared across threads via data races — which
post-construction mutation compromises anyway.
|
Widening a whole class
@Shim(widenAccess = true) strips private and final from every declared member of the
target (compile-time constants excepted), making them public. They can then be accessed
reflectively without setAccessible(true) — handy as the JDK tightens setAccessible — and
by separately-compiled same-package code. It is a coarse, whole-class alternative to
definalize.
A shim’s own source still cannot reference members that were private in the target’s
source — javac checks access before the transformation runs. Use ShimFields/ShimMethods
for that (which then need no setAccessible).
|
Constructors and static initializers
Constructors and static initializers are addressed by their JVM names:
@Shim(Widget.class)
public class WidgetShim {
@ShimBefore(method = "<init>") // runs at entry, before super();
public static void beforeConstruct() { } // no 'self' — 'this' is not initialized yet
@ShimAfter(method = "<init>") // runs after construction, 'self' allowed
public static void afterConstruct(Widget self) {
ShimFields.set(self, "size", 99); // fix up state the constructor got wrong
}
@ShimReplace(method = "<clinit>") // replace the static initializer entirely
public static void staticInit() { }
}
Rules, all enforced at build time:
-
@ShimReplace(method = "<init>")is rejected: the JVM requires every constructor to callsuper()/this()beforethiscan escape, so constructor bodies cannot be delegated. Use@ShimAfter+ShimFields(anddefinalizefor final fields) instead. -
A constructor before-hook cannot receive
self(uninitialized); an after-hook can. -
Replacing
<clinit>discards static field initializers written at the declaration site too — they are part of<clinit>in bytecode. Set them from the hook withShimFields.setStatic, and list anystatic finalfield you assign indefinalize— otherwise the write is rejected and the class fails initialization permanently withNoClassDefFoundError. -
@ShimReplace(method = "<clinit>")only applies to a class that actually has a static initializer; a class with no static field initializers and no static block has no<clinit>, and the build says so. -
With constructor chaining (
this(…)), a hook woven into every overload fires once per constructor body entered; pin one overload withdescriptor()if that matters.
Pinning a shim to a dependency version
A patch for someone else’s bug is a bandaid: it is written against the exact release that has the bug, and it should come off when that release is upgraded. Declare the versions the patch belongs to and Shim stops applying it once the dependency moves past them:
@Shim(value = DecisionEngine.class,
name = "fail-closed-decision",
dependency = "com.acme:decision-engine", (1)
versions = "[1.2,1.5)") (2)
public class DecisionEngineShim {
@ShimReplace(method = "isAllowed", paramTypes = String.class)
public static boolean isAllowed(String decision) {
return "ALLOW".equalsIgnoreCase(decision);
}
}
| 1 | groupId:artifactId of the library being patched. |
| 2 | The releases this patch was written for: 1.2 up to but excluding 1.5. |
Upgrade com.acme:decision-engine to 1.5 and the target class is left untouched — the vendor’s
own code runs, and the build warns that the shim did not apply so it can be deleted:
WARN Shim 'fail-closed-decision' (com.acme.DecisionEngineShim) was not applied to
com.acme.DecisionEngine: com.acme:decision-engine is at 1.5.0, outside the pinned
range '[1.2,1.5)'
The same warning is repeated once at startup, and dev mode lists retired shims in a "Retired shims" Dev UI table next to the applied ones.
This matters because a shim is written against bytecode the vendor is free to change. Without a pin, an upgrade either silently reverts your fix (the patched method was renamed, so the hook no longer matches and the build fails) or — worse — keeps weaving a stale patch over a method whose meaning has moved on. Pinning makes the upgrade the thing that retires the patch.
Version ranges
versions takes standard Maven range syntax, matched against the version the build actually
resolved (after dependency management and conflict resolution):
|
1.2 or above, below 1.5 |
|
anything below 2.0 |
|
2.0 and up |
|
exactly that version |
|
either range |
Versions compare the Maven way, so 1.10 is above 1.9 and 1.5-SNAPSHOT sits just below
1.5. Note that a bare 1.4.2 means exactly that version here, unlike a Maven dependency
declaration where it is only a preference.
Leave dependency out and the artifact containing the target class is used, which is usually
what you want:
@Shim(value = DecisionEngine.class, versions = "[1.2,1.5)")
Name it explicitly when the target class is not in the artifact whose version should decide, or when it lives in a dependency without a Jandex index (auto-detection fails the build with that advice rather than guessing).
Used on its own, dependency becomes a presence gate — the shim applies only while that
artifact is on the classpath:
@Shim(value = DecisionEngine.class, dependency = "com.acme:decision-engine")
Failing the build instead
By default a shim that no longer applies is retired with a warning. For a patch that must not disappear without someone looking at it — a security fix, say — make the mismatch stop the build:
@Shim(value = DecisionEngine.class,
dependency = "com.acme:decision-engine",
versions = "[1.2,1.5)",
onVersionMismatch = VersionMismatch.FAIL)
The upgrade then fails augmentation until someone re-pins the range (having checked the patch still makes sense), rewrites the patch, or deletes it because the vendor fixed the bug.
Diagnostics
Shim logs every applied patch at build time and again, once, at application startup:
Shim applied 3 patch(es):
- com.acme.Greeter#greet [around] <- com.acme.GreeterShim#greet
...
In dev mode a Dev UI card lists the same information in an "Applied shims" table, with a "Retired shims" table beside it for shims held back by a version pin.
Set quarkus.shim.dump-transformed-classes=true to write a human-readable bytecode dump of
each transformed target to shim/<class>.txt under the module’s build output directory —
useful for seeing exactly what was woven in. The dump is written even when the weave fails
validation, which is when it is most useful.
Set quarkus.shim.verify-transformed-classes=true to run each woven class through ASM’s
CheckClassAdapter. Structural problems then fail the build at the point the class is woven,
instead of surfacing as a ClassFormatError or VerifyError when the class is first loaded.
A @Shim whose target is in no application archive fails the build rather than being
reported as applied — a class Quarkus cannot locate is one it cannot transform, which is
almost always a typo in targetName or a dependency without a Jandex index.
Enabling and disabling shims
Disable all shim processing with quarkus.shim.enabled=false (targets are left untouched).
Each shim has a name (defaulting to the shim class’s simple name); disable an individual one
with:
quarkus.shim.instances."my-shim-name".enabled=false
Configuration reference
Configuration property fixed at build time - All other configuration properties are overridable at runtime
Configuration property |
Type |
Default |
|---|---|---|
Whether shim processing is enabled. When set to Environment variable: |
boolean |
|
Whether a human-readable dump of every transformed target class is written to Environment variable: |
boolean |
|
Whether every transformed target class is checked with ASM’s Structural problems in the woven bytecode normally surface as a Environment variable: |
boolean |
|
Whether a Environment variable: |
boolean |
|
Whether this individual shim is enabled. Environment variable: |
boolean |
|
Semantics and limits
-
Patching happens during Quarkus augmentation; only classes loaded through the Quarkus ClassLoader can be patched (application classes and indexed dependencies — not JDK classes).
-
@ShimBefore/@ShimAfter/@ShimCatch/@ShimFinallyhooks arestatic void. Their parameters are optional and positional — see each annotation for the exact list. A parameter that reads equally well asselfor as an argument/returned value (typically a loneObject) is rejected at build time rather than silently bound to one of them. -
@ShimAfterruns before every normal return; it does not run when the method exits by throwing.@ShimCatchruns only on the throwing path,@ShimFinallyon both. Neither can target a constructor. -
@ShimReplacediscards the original body entirely and delegates to your static hook. It cannot be combined with any other hook on the same target method, and neither can@ShimAround. -
Abstract and native methods cannot be shimmed. Compiler-generated bridge methods are skipped, so a hook on a covariant override or a generic interface implementation fires once per call however the caller reached it.
-
ShimFields/ShimMethodsfind members declared in superclasses and interfaces of the target. The target and its indexed superclasses are registered for native-image reflection; a superclass outside the Jandex index is not. -
The same-package trick assumes classpath (unnamed module) deployment — standard for Quarkus apps. Sealed or signed JARs can reject same-package classes from other JARs (rare).
-
Invalid shims (non-static hooks, signature mismatches, unknown target methods,
selfon a static target, definalizing a compile-time constant, …) fail the build with a descriptive error.