In the first two entries of our Gradle Under the Hood series, we looked at source-driven incremental tracking and how we engineered network-immune sandboxes using ClassLoader harvesting.
Today, we shift our focus from build-time execution performance to deployment safety and packaging compliance. Let's discuss why narrowing dynamic dependency propagation to exclusive compileOnly scopes is crucial when building plugins that target enterprise application servers, and how we hardened this boundary in JPersist.
The Enterprise Server Reality: Shared Runtimes
When deploying enterprise Java/Jakarta EE applications to robust application servers (such as WildFly, WebSphere Liberty, GlassFish, or Payara), you enter a container-managed world. These runtime environments already provide and manage their own core persistence layers. They bundle production-ready JPA providers (typically highly tuned versions of Hibernate or EclipseLink) out of the box.
Because the container provides the infrastructure, the industry-standard best practice is to build Thin Archives (lightweight WAR or EAR files). Your deployment binaries should only carry your business logic and lightweight application configurations—never the heavy framework jars themselves.
The Problem: Implicit Configuration Pollution
When designing a Gradle plugin to automate persistence configurations (like static metamodel generation or compile-time named query validation), the plugin must provision the targeted JPA provider libraries so that the compiler and the annotation processors can map entity metadata:
// A typical, naive plugin dependency injection mapping
project.configurations.named("implementation").configure { config ->
project.dependencies.add(config.name, "org.hibernate.orm:hibernate-core:6.6.0.Final")
}
Why this breaks production deployments:
By injecting the provider directly into the standard implementation or runtimeOnly configurations, your plugin inadvertently introduces a severe architectural defect: runtime classpath pollution.
When the developer executes ./gradlew build or ./gradlew bootWar, Gradle bundles the heavy, transitive Hibernate or EclipseLink JAR files directly inside the final packaged production archive (WEB-INF/lib/).
When this polluted archive is deployed onto an enterprise Jakarta EE container, a massive ClassLoader conflict occurs. The application server's native classloader (carrying its own provided JPA runtime classes) collides with the overlapping dependencies bundled inside your thin archive. This results in unpredictable runtime crashes, such as LinkageError, ClassCastException, or unexpected metadata initialization failures.
The Solution: Surgical CompileOnly and Processor Isolation
To protect production packaging workflows while maintaining full access to static metamodel generation and compile-time linting utilities, we completely overhauled the dependency propagation matrix in the core JPersist plugins.
We broke the implicit inheritance chains and isolated the jpa configuration mappings to target exclusively compilation and annotation processing scopes:
// Inside JakartaPersistencePlugin.groovy
project.extensions.getByType(SourceSetContainer).configureEach { sourceSet ->
// Create our localized jpa configuration container
def jpaProvider = project.configurations.register('jpa') { config ->
config.canBeConsumed = false
config.canBeResolved = false
}
// Narrow down propagation strictly to compilation and processing boundaries
if (project.plugins.hasPlugin(JavaLibraryPlugin)) {
extendProjectConfigurations(project, jpaProvider,
sourceSet.compileOnlyApiConfigurationName, // Target compile-time API boundaries
sourceSet.annotationProcessorConfigurationName // Target processing scopes
)
} else {
extendProjectConfigurations(project, jpaProvider,
sourceSet.compileOnlyConfigurationName, // Target standard compileOnly
sourceSet.annotationProcessorConfigurationName // Target processing scopes
)
}
}
The Metamodel Processor Fallback Coordination
By limiting framework dependencies to compileOnly, we hit another Gradle edge case: What happens if a developer applies our plugin for automated canonical metamodel generation, but doesn't declare any explicit JPA implementation in their build script?
To keep the setup completely frictionless, the plugin registers a deferred evaluation fallback. It checks if the modelgenProcessor or enhancement configurations are empty during the afterEvaluate phase, and if so, safely provisions the correct default coordinates only for the build-time processor context:
project.afterEvaluate {
def globalConfigName = 'modelgenProcessor'
if (project.configurations.names.contains(globalConfigName)) {
project.configurations.named(globalConfigName).configure { config ->
// Fallback coordination: Only provision if the developer hasn't added an explicit override
if (config.dependencies.isEmpty()) {
project.dependencies.add(globalConfigName, 'org.apache.openjpa:openjpa')
}
}
}
}
The Engineering Win
By transitioning to surgical compileOnly extensions and isolating processing dependencies, we secured a cleaner build and deployment lifecycle:
- Pristine Production Archives: Packaged WAR or EAR files remain lightweight, thin, and container-compliant. Your plugins will never accidentally leak heavy framework binaries into production deployment environments.
- Zero ClassLoader Collisions: By relying on the application server's provided JPA context at runtime, you eliminate the risk of version mismatch crashes between bundled dependencies and container-provided classes.
- Uncompromised Developer Experience: Developers retain full IDE autocompletion, compile-time type-safety, robust static metamodel generation, and deep query linting during local compilation loops, with zero runtime pollution overhead.
In Part 4 of our Gradle Under the Hood series, we will look at Polymorphic Bytecode Weaving and discuss how we designed a unified SPI abstraction layer to orchestrate Hibernate, EclipseLink, and OpenJPA bytecode manipulation within a single build graph.
How do you manage the boundary between build-time automation dependencies and container-provided runtime classes in your enterprise build configurations? Let's discuss in the comments below!
đź”— Check out the JPersist open-source architecture: https://github.com/jpersist/persistence
Top comments (0)