Dynamic Beans, Static Speed: How Quarkus Outsmarts Spring’s New BeanRegistrar
Learn how Quarkus achieves the same runtime flexibility as Spring’s new BeanRegistrar but without the startup cost. A hands-on guide for modern Java developers.
Spring Framework 7 recently introduced a new BeanRegistrar API, a feature that lets developers dynamically register beans at runtime, usually during application startup. Dan Vega covered it in this video recently.
This is a powerful capability: you can create beans based on environment variables, conditionally decide which implementations to load, or even loop through configuration values to register multiple beans programmatically.
However, if you’re coming from Quarkus, this may sound familiar, but also strangely old-fashioned.
Why? Because Quarkus already does most of this at build time, not runtime.
In this tutorial, we’ll look at how you can achieve the same dynamic flexibility in Quarkus: Without the runtime cost! You’ll see how to conditionally load beans, use factories for runtime variability, and even programmatically select implementations, all while keeping the startup blazing fast.
What you’ll build
You’ll create a small Quarkus project that:
Chooses between
EmailMessageServiceandSmsMessageServiceat build time using@IfBuildProperty.Switches data services by build profiles using
@IfBuildProfile.Handles complex conditions with a
@Producesfactory.Implements runtime selection among multiple implementations using CDI’s
InstanceAPI.Demonstrates a simple plugin loop scenario without dynamic registration.
Quarkus version: any recent 3.x works.
JDK 21+ recommended.
Grab the full example from my repository if you like.
The Spring BeanRegistrar Pattern
Dan uses the BeanRegistrar to register message services dynamically:
public class MessageServiceRegistrar implements BeanRegistrar {
@Override
public void register(BeanRegistry registry, Environment environment) {
String messageType = environment.getProperty(”app.message-type”, “email”);
if (messageType.equals(”email”)) {
registry.registerBean(EmailMessageService.class);
} else {
registry.registerBean(SmsMessageService.class);
}
}
}
At runtime, Spring reads the environment, runs this registrar, and wires in the right bean.
It’s a great improvement for Spring developers, but it still means every startup re-evaluates the same logic.
Now, let’s see how Quarkus approaches this problem differently.
Quarkus Philosophy: Build-Time First
Quarkus applications resolve most dependency injection decisions during build time.
That’s why they start in milliseconds and consume far less memory than traditional Spring Boot apps.
Instead of having a runtime API like BeanRegistrar, Quarkus relies on conditional annotations evaluated once during the build:
@IfBuildProperty@IfBuildProfile@UnlessBuildProperty
These annotations prune unused beans from the final binary.
Let’s see them in action.
Project setup
Create a new app:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=org.acme \
-DprojectArtifactId=dynamic-beans-quarkus \
-DclassName="org.acme.messaging.NotificationResource" \
-Dpath="/notify" \
-Dextensions="rest-jackson"
cd dynamic-beans-quarkusWe’ll use Quarkus REST for a tiny HTTP endpoint to verify behavior.
Build-time conditional beans with @IfBuildProperty
Define a contract
src/main/java/org/acme/messaging/MessageService.java
package org.acme.messaging;
public interface MessageService {
String send(String recipient, String text);
}Two implementations, each gated by a build property
src/main/java/org/acme/messaging/EmailMessageService.java
package org.acme.messaging;
import io.quarkus.arc.properties.IfBuildProperty;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@IfBuildProperty(name = “app.message-type”, stringValue = “email”)
public class EmailMessageService implements MessageService {
@Override
public String send(String recipient, String text) {
return String.format(”EMAIL -> %s :: %s”, recipient, text);
}
}src/main/java/org/acme/messaging/SmsMessageService.java
package org.acme.messaging;
import io.quarkus.arc.properties.IfBuildProperty;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@IfBuildProperty(name = “app.message-type”, stringValue = “sms”)
public class SmsMessageService implements MessageService {
@Override
public String send(String recipient, String text) {
return String.format(”SMS -> %s :: %s”, recipient, text);
}
}A simple endpoint that uses the service
src/main/java/org/acme/messaging/NotificationResource.java
package org.acme.messaging;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path(”/notify”)
public class NotificationResource {
@Inject
MessageService service;
@GET
public String notifyUser(@QueryParam(”to”) String to, @QueryParam(”msg”) String msg) {
String recipient = to != null ? to : “markus@example.com”;
String text = msg != null ? msg : “Dynamic beans in Quarkus!”;
return service.send(recipient, text);
}
}Configure and run
src/main/resources/application.properties
# choose email or sms
app.message-type=emailRun dev mode:
./mvnw quarkus:devTry it:
curl “http://localhost:8080/notify?to=you@example.com&msg=hello”You should see:
EMAIL ➜ you@example.com :: helloNow change the property to app.message-type=sms
No need to stop/start Quarkus. Dev mode reloads with the next request:
Try it:
curl “http://localhost:8080/notify?to=you@example.com&msg=hello”You should see:
SMS ➜ you@example.com :: helloWhat happened? At build time, Quarkus evaluates app.message-type and prunes the unused implementation from the binary. The “wrong” class is not carried into your final artifact, which improves startup and memory use.
Build profiles with @IfBuildProfile
Sometimes configuration maps cleanly to build profiles.
Define a new service contract
src/main/java/org/acme/data/DataService.java
package org.acme.data;
public interface DataService {
String fetch();
}Two profile-gated implementations
src/main/java/org/acme/data/DevDataService.java
package org.acme.data;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@IfBuildProfile(”dev”)
public class DevDataService implements DataService {
@Override
public String fetch() {
return “DEV data (in-memory)”;
}
}src/main/java/org/acme/data/ProdDataService.java
package org.acme.data;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@IfBuildProfile(”prod”)
public class ProdDataService implements DataService {
@Override
public String fetch() {
return “PROD data (external DB)”;
}
}Expose it via HTTP
src/main/java/org/acme/data/DataResource.java
package org.acme.data;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path(”/data”)
public class DataResource {
@Inject
DataService service;
@GET
public String view() {
return service.fetch();
}
}Try profiles
Dev (default in dev mode):
./mvnw quarkus:dev -Dquarkus.profile=devAnd curl:
curl "http://localhost:8080/data"
# DEV data (in-memory)Prod:
./mvnw quarkus:dev -Dquarkus.profile=prodAnd curl:
curl “http://localhost:8080/data”
# PROD data (external DB)Again, Quarkus resolves this at build time. Only the active profile’s bean remains.
Complex conditions with a @Produces factory
If your logic does not fit neatly into a single property or profile, use a producer factory.
Create a new contract
We’ll use a different interface (DynamicMessageService) for this example to avoid conflicts.
src/main/java/org/acme/messaging/DynamicMessageService.java
package org.acme.messaging;
public interface DynamicMessageService {
String send(String recipient, String text);
}Two plain implementations (not CDI beans)
These classes are not annotated with @ApplicationScoped.
They’re just plain Java objects created by our factory at runtime.
src/main/java/org/acme/messaging/DynamicEmailService.java
package org.acme.messaging;
public class DynamicEmailService implements DynamicMessageService {
@Override
public String send(String recipient, String text) {
return String.format(”EMAIL -> %s :: %s”, recipient, text);
}
}src/main/java/org/acme/messaging/DynamicSmsService.java
package org.acme.messaging;
public class DynamicSmsService implements DynamicMessageService {
@Override
public String send(String recipient, String text) {
return String.format(”SMS -> %s :: %s”, recipient, text);
}
}Create the factory with @Produces
Now we define one CDI bean that decides which implementation to produce based on multiple configuration properties.
It’s the Quarkus equivalent of Spring’s BeanRegistrar logic — just simpler.
src/main/java/org/acme/messaging/DynamicMessageProducer.java
package org.acme.messaging;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Produces;
import org.eclipse.microprofile.config.ConfigProvider;
@ApplicationScoped
public class DynamicMessageProducer {
@Produces
public DynamicMessageService createService() {
var config = ConfigProvider.getConfig();
String type = config.getOptionalValue(”dynamic.message-type”, String.class).orElse(”email”);
boolean premium = config.getOptionalValue(”dynamic.premium”, Boolean.class).orElse(false);
if (”sms”.equalsIgnoreCase(type)) {
if (premium) {
return new DynamicSmsService() {
@Override
public String send(String recipient, String text) {
return “Premium “ + super.send(recipient, text);
}
};
}
return new DynamicSmsService();
} else {
return new DynamicEmailService();
}
}
}This class acts as a runtime factory.
You could load configuration from files, databases, or remote services here.
Expose via a REST endpoint
src/main/java/org/acme/messaging/DynamicMessageResource.java
package org.acme.messaging;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path(”/dynamic”)
public class DynamicMessageResource {
@Inject
DynamicMessageService service;
@GET
public String send(
@QueryParam(”to”) String to,
@QueryParam(”msg”) String msg) {
String recipient = to != null ? to : “markus@example.com”;
String text = msg != null ? msg : “Dynamic @Produces example!”;
return service.send(recipient, text);
}
}# application.properties
app.message-type=sms
dynamic.message-type=sms
dynamic.premium=trueSwitch back to dev mode:
curl "http://localhost:8080/dynamic?to=you@example.com&msg=hello"
# Premium SMS -> you@example.com :: helloYou’ll still avoid “dynamic registration” while keeping the flexibility to compute your choice at runtime.
Runtime selection among many implementations with Instance
When you really must choose at request time, inject them all and pick the right one on demand.
Define a plugin contract
src/main/java/org/acme/plugin/Plugin.java
package org.acme.plugin;
public interface Plugin {
String name();
String apply(String input);
}Two plugins
src/main/java/org/acme/plugin/LoggingPlugin.java
package org.acme.plugin;
import io.quarkus.logging.Log;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Named;
@ApplicationScoped
@Named(”logging”)
public class LoggingPlugin implements Plugin {
@Override
public String name() {
return “logging”;
}
@Override
public String apply(String input) {
Log.info(”[PLUGIN:logging] “ + input);
return input;
}
}src/main/java/org/acme/plugin/MetricsPlugin.java
package org.acme.plugin;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Named;
@ApplicationScoped
@Named(”metrics”)
public class MetricsPlugin implements Plugin {
@Override
public String name() {
return “metrics”;
}
@Override
public String apply(String input) {
// pretend to record a metric
return “metrics(” + input + “)”;
}
}Select by name at runtime
src/main/java/org/acme/plugin/PluginService.java
package org.acme.plugin;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.inject.Any;
import jakarta.enterprise.inject.Instance;
import jakarta.enterprise.inject.literal.NamedLiteral;
import jakarta.inject.Inject;
@ApplicationScoped
public class PluginService {
@Inject
@Any
Instance<Plugin> plugins;
public Plugin byName(String name) {
return plugins.select(NamedLiteral.of(name)).get();
}
}Endpoint to run a plugin from a query param
src/main/java/org/acme/plugin/PluginResource.java
package org.acme.plugin;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path(”/plugin”)
public class PluginResource {
@Inject
PluginService service;
@GET
public String run(
@QueryParam(”name”) String name,
@QueryParam(”input”) String input) {
Plugin p = service.byName(name != null ? name : “logging”);
String value = input != null ? input : “hello”;
return “[” + p.name() + “] -> “ + p.apply(value);
}
}Try:
curl "http://localhost:8080/plugin?name=logging&input=hi"
# [logging] -> hi
curl "http://localhost:8080/plugin?name=metrics&input=hi"
# [metrics] -> metrics(hi)All implementations are available, but you choose which one to use per request. No dynamic registration required.
“Create beans in a loop” without dynamic registration
If your loop is configuration-driven (for example a list of endpoints), keep the “instances” as plain objects that your CDI bean manages internally. You only make the factory a bean:
src/main/java/org/acme/factory/EndpointClientFactory.java
package org.acme.factory;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.eclipse.microprofile.config.Config;
import org.eclipse.microprofile.config.ConfigProvider;
import jakarta.annotation.PostConstruct;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class EndpointClientFactory {
private final Map<String, HttpClient> clients = new HashMap<>();
@PostConstruct
void init() {
Config cfg = ConfigProvider.getConfig();
// e.g., endpoints.list=payments,users
List<String> ids = cfg.getOptionalValue(”endpoints.list”, String.class)
.map(s -> Arrays.stream(s.split(”,”)).map(String::trim).collect(Collectors.toList()))
.orElseGet(List::of);
for (String id : ids) {
String url = cfg.getOptionalValue(”endpoints.” + id + “.url”, String.class)
.orElseThrow(() -> new IllegalStateException(”Missing URL for “ + id));
clients.put(id, new HttpClient(url));
}
}
public HttpClient client(String id) {
return clients.get(id);
}
public static class HttpClient {
private final String baseUrl;
public HttpClient(String baseUrl) {
this.baseUrl = baseUrl;
}
public String getBaseUrl() {
return baseUrl;
}
public String call(String path) {
return “GET “ + baseUrl + path;
}
}
}src/main/resources/application.properties
endpoints.list=payments,users
endpoints.payments.url=https://api.example.com/pay
endpoints.users.url=https://api.example.com/usersExpose a small endpoint to verify:
src/main/java/org/acme/factory/EndpointResource.java
package org.acme.factory;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
@Path(”/endpoint”)
public class EndpointResource {
@Inject
EndpointClientFactory factory;
@GET
public String call(@QueryParam(”id”) String id, @QueryParam(”path”) String path) {
var client = factory.client(id != null ? id : “payments”);
return client == null ? “unknown id” : client.call(path != null ? path : “/status”);
}
}Give it a test:
curl “http://localhost:8080/endpoint?id=payments&path=/status”Quick tests (optional)
Of course you can test all of the above extensively if you like/have to.
src/test/java/org/acme/messaging/NotificationResourceTest.java
package org.acme.messaging;
import io.quarkus.test.junit.QuarkusTest;
import org.junit.jupiter.api.Test;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.containsString;
@QuarkusTest
class NotificationResourceTest {
@Test
void notify_usesConfiguredMessageService() {
given()
.when().get(”/notify?to=test@example.com&msg=hi”)
.then()
.statusCode(200)
.body(containsString(”hi”));
}
}Run:
./mvnw testWOOOOWWW! That FAILED! Markus MADE A MISTAKE!
Yes, to remind you about profiles in Quarkus. We defined a Prod and a Dev DataService but no TestDataService yet. Well, I did. In the repository for this article. So, if you just grabbed the example and ran it, you’ve been good. If not, you would need to add:
package org.acme.data;
import io.quarkus.arc.profile.IfBuildProfile;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
@IfBuildProfile(”test”)
public class TestDataService implements DataService {
@Override
public String fetch() {
return “TEST data (mock)”;
}
}And NOW the test actually is successful.
When to choose which Quarkus pattern
If a single property or profile decides the implementation, prefer
@IfBuildPropertyor@IfBuildProfile.If logic depends on multiple inputs or computation, use a
@Producesfactory.If the choice must be made per request, inject all and select via
Instanceor a map of plugins.If you need to materialize many instances at runtime from config, keep them as plain objects managed by a CDI factory bean, not as dynamically registered beans.
Differences between Quarkus and Spring
The key difference between Spring’s BeanRegistrar and Quarkus’ build-time model comes down to when decisions are made.
Spring’s BeanRegistrar is a runtime, startup-phase feature designed for application developers who want to programmatically add beans based on the current environment, profiles or custom logic. On every application start, Spring evaluates your registrar code, reads the environment, and decides which beans to create. This is a major usability improvement for Spring because it replaces clever but brittle workarounds such as bean factory post-processors and complicated conditional annotations. Standard CDI exposes a similar power level through portable extensions and events like AfterBeanDiscovery, but that SPI is intentionally low level, aimed at framework and container authors rather than everyday app teams. Quarkus takes a different stance: it prioritizes build-time analysis to remove unused beans and, whenever possible, resolves injection decisions before the application is packaged. Instead of programmatically adding beans at runtime, you typically annotate candidates with @IfBuildProperty or @IfBuildProfile so the build includes only what is needed. For situations that truly depend on runtime inputs, Quarkus encourages factories (@Produces) and selection (Instance/qualifiers) rather than dynamic bean registration. The result is that Spring optimizes for flexibility at startup, re-evaluating conditions each time the app launches, whereas Quarkus optimizes for performance by making decisions once during the build and delivering smaller, faster binaries that suit container and serverless environments.
The Quarkus Way of Dynamic Beans
Spring’s BeanRegistrar empowers developers to react to the environment.
Quarkus empowers developers to anticipate it.
Spring:
“Decide what to create when you start.”
Quarkus:
“Decide what to include when you build.”
If your use case is runtime plugin loading or user-driven configuration, Quarkus provides factories and selectors for that.
But if your goal is simply to avoid unnecessary beans, Quarkus’ build-time pruning does it automatically, and far faster.



