Creating a Custom ByteBuddy Weaving Plugin with Gradle: A Step-by-Step Guide
Add Code at Runtime
👋 Introduction
ByteBuddy is a game-changer when it comes to modifying Java classes at runtime. With it, you can weave custom logic into your code at runtime. In this guide, we’ll walk through setting up a custom ByteBuddy weaving plugin in a Gradle project and do it with a simple but powerful example.
🚀 Set Up the Root Project
First, configure the root project to use ByteBuddy and our custom plugin. Here’s your build.gradle
for the root project:
buildscript {
dependencies {
classpath "com.espectro93:counter-plugin"
}
}
plugins {
id 'java'
id 'net.bytebuddy.byte-buddy-gradle-plugin' version "1.15.10"
id 'com.github.johnrengelman.shadow' version '8.1.1'
}
group = 'com.espectro93'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation "com.espectro93:counter-plugin"
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
jar {
manifest {
attributes(
'Main-Class': 'com.espectro93.Main'
)
}
}
shadowJar {
mergeServiceFiles()
manifest {
attributes 'Main-Class': 'com.espectro93.Main'
}
}
test {
useJUnitPlatform()
}
byteBuddy {
transformation {
pluginName = 'com.espectro93.CounterWeavingPlugin'
}
}
Why These Settings Matter 💡
buildscript: We declare the
counter-plugin
Here because it’s a custom plugin located in a separate module. Gradle needs to know it upfront to apply it during the build process. By placing it in thebuildscript
block, we're ensuring that the plugin is available during the build phase, not in the output Java program itself. This is crucial because dependencies in thebuildscript
block are meant to affect the build process (such as compiling, testing, or packaging) rather than the runtime behavior of your application.byte-buddy-gradle-plugin
: This plugin is what allows us to hook into Gradle and apply ByteBuddy transformations during the build process. Without it, ByteBuddy wouldn’t work its magic for us.Shadow Jar Plugin: To create an uber or fat jar with all dependencies, we need the plugin so that it includes the counter-plugin from the classpath. When you run it from IntelliJ IDEA runners for example IDE takes care of such things on the classpath but we want to run it with gradle.
Dependencies: This
counter-plugin
is included so that we can apply our custom logic in the root project.
🛠️ Create the Custom Plugin Module
Now, let’s create a new module where we’ll define the custom ByteBuddy plugin. This module will contain all the logic to intercept the instantiation of classes and count how many times they’ve been created. 🏎️
counter-plugin/build.gradle
plugins {
id 'java-library'
}
group = 'com.espectro93'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
}
dependencies {
implementation "net.bytebuddy:byte-buddy:1.15.10"
}
This is a pretty standard setup for a Java library module. We’re simply adding the ByteBuddy dependency so we can use its features. Let’s move on to the plugin code!
The CounterWeavingPlugin
🎯
package com.espectro93;
import net.bytebuddy.build.Plugin;
import net.bytebuddy.description.type.TypeDescription;
import net.bytebuddy.dynamic.ClassFileLocator;
import net.bytebuddy.dynamic.DynamicType;
import net.bytebuddy.implementation.MethodDelegation;
import net.bytebuddy.implementation.SuperMethodCall;
import net.bytebuddy.matcher.ElementMatchers;
import java.io.IOException;
public class CounterWeavingPlugin implements Plugin {
@Override
public boolean matches(TypeDescription target) {
return true; // Apply to all classes
}
@Override
public void close() throws IOException {}
@Override
public DynamicType.Builder<?> apply(DynamicType.Builder<?> builder, TypeDescription typeDescription, ClassFileLocator classFileLocator) {
return builder.constructor(ElementMatchers.any())
.intercept(MethodDelegation.to(ConstructorInterceptor.class)
.andThen(SuperMethodCall.INSTANCE));
}
}
What's Happening Here? 🤔
matches()
: We’re telling ByteBuddy to apply our plugin to all classes. You could modify this to target specific classes or types if needed. 🔍apply()
: This is where the weaving happens. We intercept every class constructor (withconstructor(ElementMatchers.any())
) and delegate to our custom interceptor. This allows us to inject additional behavior before or after the constructor runs.
🛑 The ConstructorInterceptor
package com.espectro93;
import java.util.concurrent.atomic.AtomicInteger;
public class ConstructorInterceptor {
private static final AtomicInteger counter = new AtomicInteger(0);
public static void increment() {
int count = counter.incrementAndGet();
System.out.println("A class was instantiated! Total count: " + count);
}
}
Every time a class is instantiated, the increment()
method gets triggered. It increments a counter and prints the total number of instances created to the console. This is your custom logic that ByteBuddy will weave into the constructor of every class you target.
🔧 Set Up the settings.gradle
File
In the root directory of your project, we need to declare the multi-module setup. Here’s how you do it:
rootProject.name = 'bytebuddy-gradle-custom-plugin'
includeBuild 'counter-plugin'
This is important because it tells Gradle that we have a composite build and that the counter-plugin
module is a part of it. Without this, the custom plugin wouldn’t be available for use in the root project and we would need to build it separately beforehand. Read more on it here.
🎬 Create the Main
Class to Test It
In the root module, let’s create a simple Main
class to verify everything is working.
package com.espectro93;
public class Main {
public static void main(String[] args) {
var car = new Car("Ferrari");
var car2 = new Car("Lamborghini");
}
}
Here, we’re just creating two Car
instances. With our custom plugin in place, every time a Car
is instantiated, the counter will increment, and we’ll see a log message like this:
A class was instantiated! Total count: 1
A class was instantiated! Total count: 2
🚧 Build and Run the Project
After everything is set up, run the build and watch it happen!
./gradlew clean shadowJar
java -jar build/libs/bytebuddy-gradle-custom-plugin-1.0-SNAPSHOT-all.jar
You should now see the instantiation counter printed every time a Car
object is created. 🚗
🧰 Other Use Cases for ByteBuddy
ByteBuddy is super versatile. Here are some other cool things you can do with it:
Enforce Bean Validation: You can use ByteBuddy to enforce Jakarta Bean Validation on Java records, ensuring that all invariants are respected before an object is instantiated. Check out this example for more info! 🛡️
Method Logging: Add logging to methods to track execution time, parameters, and return values. Handy for debugging! 🔍
Mocking and Stubbing: ByteBuddy is the backbone of frameworks like Mockito, enabling dynamic mocking of objects. 🧪
🏁 Conclusion
In this guide, we’ve shown you how to create a custom ByteBuddy weaving plugin, integrate it into a Gradle project, and count class instantiations with a simple Car
example. We’ve also covered why each step is important, and how you can extend ByteBuddy to do a lot more—like enforcing Bean Validation or logging method calls.
ByteBuddy opens up many possibilities in Java. From dynamic code generation to runtime transformations.