I stumbled across Robert’s blog post introducing J API Proxy and started wondering how this small library might fit into Quarkus and which gaps native image would expose. A JDBC connection seemed like a good and simple test. It is late enough to see database activities, early enough to avoid changing every repository, and close enough to the pool that a slow call tells me something operationally.
On the JVM, Proxy.newProxyInstance() can combine interfaces at runtime and make a new class when it needs one. Native Image compiles a closed world ahead of time, so it needs every dynamic-proxy interface list before the application starts. If a list is missing, the application fails the first time it creates that proxy.
J API Proxy makes the constraint very visible. Its core is a filter chain over JDK dynamic proxies. Its JDBC adapter can recursively wrap a DataSource, connections, statements, result sets, metadata, and XA resources. On the JVM, that is a small option change. In native mode, each enabled layer can add another proxy definition.
We will build a small ledger endpoint backed by PostgreSQL. It wraps Quarkus’s Agroal datasource with J API Proxy, counts calls at the datasource and connection boundary, and leaves statements and result sets alone. Then we register the two exact native proxy shapes this choice creates and test the native executable.
The service intercepts two JDBC interfaces and leaves the rest of the JDBC graph alone. That gives us a native configuration we can review without building a hand-written observability product.
What We Build
GET /ledger/acct-42 returns a seeded account balance. The endpoint reaches a Quarkus-managed pool through an observed DataSource:
HTTP resource
-> LedgerRepository
-> observed DataSource proxy
-> Agroal datasource and PostgreSQL driverThe filter sees DataSource and Connection methods. Calls on a PreparedStatement or a ResultSet bypass it. That limit keeps the native configuration to two JDK proxy definitions:
javax.sql.DataSource + io.github.rrobetti.japiproxy.core.ProxyHandle
java.sql.Connection + io.github.rrobetti.japiproxy.core.ProxyHandleEvery J API Proxy proxy also implements ProxyHandle, which lets the library recover the original delegate. Registering DataSource alone does not describe the class that the library requests. Native image configuration needs the full ordered interface list.
What You Need
This article uses Quarkus 3.39.1, Java 21, PostgreSQL, and j-api-proxy-jdbc 0.1.0-alpha. The J API Proxy release is an alpha, so keep it pinned and re-run the native test whenever you upgrade it. The library supports Java 17, but Java 21 is a sensible LTS baseline for a new Quarkus service.
Java 21
Quarkus CLI 3.39.x
Podman for Dev Services and container-based native builds
curlandjqBasic Quarkus REST, CDI, and JDBC knowledge
About ☕️☕️
Create the Application
Create an empty Maven application with Quarkus REST, Jackson, and the PostgreSQL JDBC extension or start from my Github repository:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.39.1 \
--maven \
--java=21 \
--no-code \
--extensions=rest-jackson,jdbc-postgresql \
com.themainthread:j-api-proxy-quarkus-native
cd j-api-proxy-quarkus-nativequarkus-rest-jackson serves the JSON endpoint. quarkus-jdbc-postgresql supplies the driver and Agroal integration. A running Podman machine lets Quarkus start PostgreSQL through Dev Services in development and tests; it is not part of the native binary.
Add the J API Proxy version under the generated pom.xml <properties> element:
<j-api-proxy.version>0.1.0-alpha</j-api-proxy.version>Then add these dependencies inside the generated <dependencies> element:
<dependency>
<groupId>io.github.rrobetti</groupId>
<artifactId>j-api-proxy-jdbc</artifactId>
<version>${j-api-proxy.version}</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>Keep the Filter Small and Thread-Safe
We need a CDI qualifier because the normal Quarkus datasource must remain available. Code that needs observation explicitly injects our wrapped datasource; code that needs a vendor feature can keep using the original datasource.
Create src/main/java/com/themainthread/ledger/ObservedDataSource.java:
package com.themainthread.ledger;
import jakarta.inject.Qualifier;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Qualifier
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE })
public @interface ObservedDataSource {
}Now create JdbcCallMetrics.java:
package com.themainthread.ledger;
import java.sql.Connection;
import java.util.concurrent.atomic.LongAdder;
import javax.sql.DataSource;
import io.github.rrobetti.japiproxy.core.InvocationFilter;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class JdbcCallMetrics {
private final LongAdder dataSourceCalls = new LongAdder();
private final LongAdder connectionCalls = new LongAdder();
private final LongAdder failures = new LongAdder();
public InvocationFilter filter() {
return (invocation, chain) -> {
try {
Object result = chain.proceed(invocation);
if (invocation.interfaceType() == DataSource.class) {
dataSourceCalls.increment();
} else if (invocation.interfaceType() == Connection.class) {
connectionCalls.increment();
}
return result;
} catch (Throwable failure) {
failures.increment();
throw failure;
}
};
}
public JdbcMetricsSnapshot snapshot() {
return new JdbcMetricsSnapshot(dataSourceCalls.sum(), connectionCalls.sum(), failures.sum());
}
public record JdbcMetricsSnapshot(long dataSourceCalls, long connectionCalls, long failures) {
}
}Each J API Proxy call gets an independent invocation context, but filters can still run concurrently. LongAdder gives us low-contention counters without a shared mutable map. The filter records no SQL, bind value, or account identifier.
The counter increments after a successful call. On failure, it increments failures and rethrows the same exception. The pool and the application therefore keep their normal JDBC error behavior.
Declare the Native Proxy Shapes
Create src/main/java/com/themainthread/ledger/ObservedDataSourceProducer.java:
package com.themainthread.ledger;
import java.sql.Connection;
import javax.sql.DataSource;
import io.agroal.api.AgroalDataSource;
import io.github.rrobetti.japiproxy.core.ProxyHandle;
import io.github.rrobetti.japiproxy.jdbc.JdbcProxy;
import io.github.rrobetti.japiproxy.jdbc.JdbcProxyOptions;
import io.quarkus.runtime.annotations.RegisterForProxy;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
@RegisterForProxy(targets = { DataSource.class, ProxyHandle.class })
@RegisterForProxy(targets = { Connection.class, ProxyHandle.class })
@ApplicationScoped
public class ObservedDataSourceProducer {
private final AgroalDataSource delegate;
private final JdbcCallMetrics metrics;
public ObservedDataSourceProducer(AgroalDataSource delegate, JdbcCallMetrics metrics) {
this.delegate = delegate;
this.metrics = metrics;
}
@Produces
@ApplicationScoped
@ObservedDataSource
DataSource observedDataSource() {
JdbcProxyOptions options = JdbcProxyOptions.builder()
.connections(true)
.statements(false)
.resultSets(false)
.build();
return JdbcProxy.wrap(delegate, "ledger", options, metrics.filter());
}
}@RegisterForProxy registers application-owned JDK proxy definitions in Quarkus. J API Proxy passes its primary interface first and ProxyHandle second to Proxy.newProxyInstance(), so the registration follows that order.
JdbcProxy.wrap() immediately creates the DataSource proxy. With connections(true), its getConnection() return value creates the Connection proxy. With statements(false) and resultSets(false), a call to Connection.prepareStatement() returns the vendor statement directly, and no further J API Proxy shape is requested.
Before moving on, predict what happens if we turn statements(true) on but leave these annotations alone. The endpoint can work on the JVM, then fail in the native executable when it first calls prepareStatement().
Use the Observed Datasource
For a contained example, the application creates one table and one account at startup. Create LedgerSchema.java.
package com.themainthread.ledger;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
@ApplicationScoped
public class LedgerSchema {
private final DataSource dataSource;
public LedgerSchema(@ObservedDataSource DataSource dataSource) {
this.dataSource = dataSource;
}
void initialize(@Observes StartupEvent event) {
try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) {
statement.execute("""
CREATE TABLE IF NOT EXISTS ledger_account (
account_id VARCHAR(64) PRIMARY KEY,
balance NUMERIC(19, 2) NOT NULL
)
""");
statement.executeUpdate("""
INSERT INTO ledger_account (account_id, balance)
VALUES ('acct-42', 1250.00)
ON CONFLICT (account_id) DO NOTHING
""");
} catch (SQLException exception) {
throw new IllegalStateException("Could not initialize the ledger schema", exception);
}
}
}Yep. Don’t complaint ot me. This code boots the simplest test and nothing more; it is not a real strategy but my shortcut for this demo. A service that owns real data uses a versioned Flyway or Liquibase migration and removes the startup DDL!
Create LedgerRepository.java:
package com.themainthread.ledger;
import java.math.BigDecimal;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import javax.sql.DataSource;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.NotFoundException;
@ApplicationScoped
public class LedgerRepository {
private final DataSource dataSource;
public LedgerRepository(@ObservedDataSource DataSource dataSource) {
this.dataSource = dataSource;
}
public BigDecimal balance(String accountId) {
try (Connection connection = dataSource.getConnection();
PreparedStatement statement = connection.prepareStatement(
"SELECT balance FROM ledger_account WHERE account_id = ?")) {
statement.setString(1, accountId);
try (ResultSet resultSet = statement.executeQuery()) {
if (!resultSet.next()) {
throw new NotFoundException("Unknown account: " + accountId);
}
return resultSet.getBigDecimal("balance");
}
} catch (SQLException exception) {
throw new IllegalStateException("Could not read ledger account " + accountId, exception);
}
}
}The SQL remains a prepared statement. J API Proxy adds code around a standard interface.
Create LedgerResource.java:
package com.themainthread.ledger;
import java.math.BigDecimal;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/ledger")
@Produces(MediaType.APPLICATION_JSON)
public class LedgerResource {
private final LedgerRepository repository;
public LedgerResource(LedgerRepository repository) {
this.repository = repository;
}
@GET
@Path("/{accountId}")
public LedgerBalance balance(@PathParam("accountId") String accountId) {
return new LedgerBalance(accountId, repository.balance(accountId));
}
public record LedgerBalance(String accountId, BigDecimal balance) {
}
}The PreparedStatement and ResultSet are deliberately not proxied, while the Connection is. The filter sees prepareStatement() and close() on that connection. It can count connection-level behavior, inject a narrow test failure, or create one span around a database interaction. It does not log individual queries.
For the demo, expose the counters through a separate endpoint. Create JdbcMetricsResource.java:
package com.themainthread.ledger;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/internal/jdbc-metrics")
@Produces(MediaType.APPLICATION_JSON)
public class JdbcMetricsResource {
private final JdbcCallMetrics metrics;
public JdbcMetricsResource(JdbcCallMetrics metrics) {
this.metrics = metrics;
}
@GET
public JdbcCallMetrics.JdbcMetricsSnapshot metrics() {
return metrics.snapshot();
}
}In a real application you must not publish this endpoint without authentication and authorization. In production, export the counters through the existing metrics pipeline or put the resource on the management interface.
Configure a Bounded Pool
Add the following to src/main/resources/application.properties:
quarkus.datasource.db-kind=postgresql
quarkus.datasource.jdbc.min-size=2
quarkus.datasource.jdbc.max-size=12
quarkus.datasource.jdbc.acquisition-timeout=3sdb-kind lets Quarkus select the PostgreSQL driver and lets Dev Services provide a local database when no JDBC URL is configured. Outside development and test, set the JDBC URL, username, and password from your deployment environment or a credentials provider, not in this file.
The pool keeps two connections ready and can use at most 12. Every replica therefore needs an allocation of 12 PostgreSQL connections, plus room for migrations, administration, and other services. When the pool is exhausted, the three-second acquisition timeout fails a request instead of allowing application threads to wait indefinitely. Set the number from a capacity plan, not from a laptop benchmark with no competing traffic.
Run It on the JVM
Start dev mode with a working Podman environment:
./mvnw quarkus:devQuarkus starts a PostgreSQL Dev Service, the startup observer creates the table, and the account endpoint returns:
curl -s http://localhost:8080/ledger/acct-42 | jq{
"accountId": "acct-42",
"balance": 1250.00
}Now inspect the counters:
curl -s http://localhost:8080/internal/jdbc-metrics | jq{
"dataSourceCalls": 2,
"connectionCalls": 4,
"failures": 0
}The exact totals can be higher because startup checks and local tooling may also use the datasource. Check that the account request increases both call counts while failures stay at zero.
Test the delta around one request. Create src/test/java/com/themainthread/ledger/LedgerResourceTest.java:
package com.themainthread.ledger;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
@QuarkusTest
class LedgerResourceTest {
@Inject
JdbcCallMetrics metrics;
@Test
void returnsTheBalanceAndObservesTheConnectionBoundary() {
JdbcCallMetrics.JdbcMetricsSnapshot before = metrics.snapshot();
String accountId = given()
.when().get("/ledger/acct-42")
.then()
.statusCode(200)
.extract().path("accountId");
JdbcCallMetrics.JdbcMetricsSnapshot after = metrics.snapshot();
assertEquals("acct-42", accountId);
assertTrue(after.dataSourceCalls() > before.dataSourceCalls());
assertTrue(after.connectionCalls() > before.connectionCalls());
}
}Run it with ./mvnw test. The test verifies the JVM path, but it cannot verify native metadata. The compiled image may still lack proxy classes, so native verification has its own test.
Build and Test the Native Executable
Add src/test/java/com/themainthread/ledger/LedgerResourceIT.java:
package com.themainthread.ledger;
import static io.restassured.RestAssured.given;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusIntegrationTest;
@QuarkusIntegrationTest
class LedgerResourceIT {
@Test
void nativeExecutableCanCreateTheRegisteredProxies() {
given()
.when().get("/ledger/acct-42")
.then()
.statusCode(200)
.body("accountId", org.hamcrest.Matchers.equalTo("acct-42"));
}
}Before spending time on a native build, ask Quarkus to prepare the native-image inputs and inspect the generated proxy metadata:
./mvnw package -Dnative -Dquarkus.native.sources-only=true -DskipTests
unzip -p target/native-sources/j-api-proxy-quarkus-native-1.0.0-SNAPSHOT-runner.jar \
META-INF/native-image/proxy-config.json | jq[
{
"interfaces": [
"javax.sql.DataSource",
"io.github.rrobetti.japiproxy.core.ProxyHandle"
]
},
{
"interfaces": [
"java.sql.Connection",
"io.github.rrobetti.japiproxy.core.ProxyHandle"
]
}
]sources-only runs Quarkus augmentation and stops before invoking GraalVM. It exposes the usual registration error: code registers the public interface and omits an interface that the library adds to the proxy definition. It cannot verify the binary itself.
Build the Linux executable in a Mandrel container and run the native integration test with the test profile:
./mvnw verify -Dnative \
-Dquarkus.native.container-build=true \
-Dquarkus.native.container-runtime=podman \
-Dquarkus.test.integration-test-profile=testQuarkus’s native build guide documents both the Podman container build and @QuarkusIntegrationTest. A container build produces a Linux executable, so run it in a compatible Linux environment. It is not directly executable on macOS or Windows just because the host built it.
The integration test verifies that the native binary starts, creates both registered JDK proxy definitions, obtains a connection, and serves the request. Remove the Connection registration and the error appears at getConnection(). Remove DataSource and it appears earlier, while CDI creates the producer result.
How Quarkus, GraalVM Native Image, and Mandrel Fit Together
(h/t to Thomas ;-) I use a couple of different names in this article to describe different parts of the build. Quarkus prepares the application for native compilation. It analyzes the framework at build time and generates the classes, configuration, and metadata required by the native compiler. When you run ./mvnw package -Dnative, Quarkus orchestrates this process and passes the prepared application to GraalVM Native Image.
GraalVM Native Image is the ahead-of-time compilation technology. The native-image command performs static reachability analysis and turns the application bytecode into a platform-specific native executable. The executable contains the reachable application code, required Java libraries, runtime components, and native code. It does not run on HotSpot and it has no JIT compiler.
Mandrel sits at the compiler distribution layer. It is a downstream distribution of GraalVM Community Edition that packages the Native Image builder for Quarkus-focused use. Mandrel aligns the compiler with standard OpenJDK and Red Hat Enterprise Linux libraries and leaves out GraalVM components that Quarkus native applications do not need, such as polyglot language support. It is not a separate native compilation technology. A Quarkus build using Mandrel still uses GraalVM Native Image.
The precise description of the artifact produced here is therefore a Quarkus native executable built with GraalVM Native Image, using the Mandrel distribution.
GraalVM Native Image: the technology
native-image: the compiler commandMandrel: a distribution containing the Native Image builder
Quarkus native executable: the resulting application artifact
Where the Simple Approach Ends
Statement and result-set interception adds more native proxy definitions. Enabling the two options does not complete the native configuration. J API Proxy can then create these composite interface lists:
java.sql.Statement + ProxyHandle
java.sql.PreparedStatement + java.sql.Statement + ProxyHandle
java.sql.CallableStatement + java.sql.PreparedStatement + java.sql.Statement + ProxyHandle
java.sql.ResultSet + ProxyHandle
java.sql.DatabaseMetaData + ProxyHandleXA and Jakarta JMS add their own combinations. Read the adapter source and tests at the pinned library version, register exactly what the application can create, then add a native test that drives every shape. Registering combinations you do not create makes the configuration harder to review and can add unused code to the executable.
Who creates the dynamic behavior decides where native registration belongs:
Application annotation — Use @RegisterForProxy when your application creates a small, static set of proxy definitions. This fits the sample because the registration sits beside the JdbcProxyOptions that creates each shape.
Native metadata file — Use proxy-config.json under src/main/resources/META-INF/native-image/<group-id>/<artifact-id>/ when application code cannot carry the annotation or when you consume existing GraalVM metadata. The Quarkus native tips guide documents the format. It still needs the exact ordered lists and an upgrade review.
Quarkus extension — If a library should run natively in many applications, move registration into an extension deployment module. A build step can emit native proxy definitions during augmentation, inspect extension configuration, and fail early for unsupported choices. Application teams then do not need to know an internal marker interface.
Stay on the JVM — Use the JVM when proxy interfaces come from tenant plug-ins, arbitrary class-path discovery, or an open-ended configuration file. Native Image cannot support that open-ended runtime composition because it analyzes the application ahead of time.
The GraalVM tracing agent can discover dynamic features while JVM tests run, and Quarkus can show the generated configuration. The output is an inventory of the paths the test exercised, not evidence that every production path is covered. Automatically applying agent configuration can make the binary depend on incidental test coverage, so Quarkus leaves generated agent configuration informative by default.
Two boundaries remain. J API Proxy exposes standard interfaces, so casting a wrapped object to a PostgreSQL vendor implementation fails. Calling unwrap() returns the original delegate, and calls on that delegate bypass every filter. Add tests when your code uses vendor-specific behavior.
Conclusion
This example wraps a Quarkus datasource with J API Proxy, registers the two proxy shapes it creates, and drives them through a native integration test. Native Image supports the setup because the application fixes those interface combinations at build time.


