So I'm going to tell you about a moment this week where I was completely confident I had the right solution, my mentor said no, and after about an hour of reading docs I realized he was 100% correct and I had missed something fundamental about how Quarkus actually works.
Quick context first - I'm currently contributing to Debezium as part of Google Summer of Code 2026. My project is called host-based pipeline deployment, and the short version is this: right now, the Debezium Platform only knows how to deploy Debezium Server onto Kubernetes using the Debezium Operator. My job is to make it work on plain servers too - bare-metal, cloud VMs, anything that isn't Kubernetes. So someone running EC2 instances or on-premise machines can still use the platform without needing a full K8s cluster.
It's a big feature. The design document (DDD-41) breaks it down into 6 sub-issues as of the 1st stage. I'm currently on Sub-Issue 2 - which is about how the user selects their deployment mode. Operator (Kubernetes) or Host (remote servers). One config, two completely different code paths.
Simple enough problem, right?
My "Obviously Correct" First Approach
My initial design used @LookupIfProperty - a SmallRye annotation that lets you filter CDI beans at runtime based on a config property. The idea was clean: both OperatorEnvironmentController and HostEnvironmentController live in the same compiled binary, and when the app starts, it reads debezium.deployment.mode from the environment, and whichever one matches is the active bean.
I was genuinely excited about this. One Docker image. You want operator mode? Set DEBEZIUM_DEPLOYMENT_MODE=operator. Want host mode? Change one env var. Same binary, same deployment, no rebuild. It felt flexible and elegant.
The relevant snippet from my design:
// Operator path - default
@ApplicationScoped
@LookupIfProperty(name = "debezium.deployment.mode", stringValue = "operator", lookupIfMissing = true)
public class OperatorEnvironmentController implements EnvironmentController { }
// Host path - new
@ApplicationScoped
@LookupIfProperty(name = "debezium.deployment.mode", stringValue = "host")
public class HostEnvironmentController implements EnvironmentController { }
I wrote up the issue, sent it for review, and waited.
The Mentor's Response
My mentor took one look at it and pointed me to this link:
👉 https://quarkus.io/guides/cdi-reference
With the note: use the build-time approach, not the runtime one.
I won't lie - my first reaction was something like "but this works though?". The runtime approach isn't wrong in any technically breaking sense. The beans are selectable, the logic is correct, the app would run fine. I'd thought about it carefully.
But Mario was pointing me to something deeper, and I hadn't picked up on it yet. So I sat down and actually read the Quarkus CDI reference from top to bottom.
What I Didn't Understand About Quarkus
Here's the thing about Quarkus that I knew at surface level but hadn't fully internalized: Quarkus is a build-time framework. Not in a marketing-slogan way - in a literally-how-it-works way.
When you run ./mvnw package, Quarkus goes through a phase called augmentation. During augmentation, Quarkus's CDI engine (called ArC) scans your classes, processes annotations, resolves injection points, and generates the actual CDI container code. This isn't deferred to runtime - it happens at build time, and the output is pre-generated, optimized Java code that starts absurdly fast because all the reflection and scanning work is already done.
This is what makes Quarkus start in milliseconds compared to Spring's several seconds. Spring does all of this work when the JVM starts. Quarkus does it when you compile.
So when Mario pointed me toward @IfBuildProperty instead of @LookupIfProperty, he wasn't just suggesting a different annotation. He was pointing out that I was fighting against the entire architecture of the framework instead of working with it.
The Quarkus CDI guide puts it plainly:
"Properties set at runtime have absolutely no effect on the bean resolution using
@IfBuildProperty."
@LookupIfProperty (SmallRye) - evaluates at runtime. Both beans exist in the binary. One is filtered during lookup.
@IfBuildProperty (Quarkus ArC) - evaluates during augmentation. The "wrong" bean is completely removed from the generated CDI container. It doesn't just get hidden - it literally does not exist at runtime.
Let me draw this out because I think it's genuinely interesting:
── RUNTIME approach (@LookupIfProperty) ──
Compiled binary:
├── OperatorEnvironmentController ← EXISTS in binary
├── HostEnvironmentController ← EXISTS in binary
├── SshConfigWatcherService ← EXISTS in binary
└── ... all host code compiled in ...
At startup: reads debezium.deployment.mode
→ "operator": hides host beans from Instance<T> lookup
→ "host": hides operator beans from Instance<T> lookup
Both sets of code exist. They're just not returned during lookup.
── BUILD-TIME approach (@IfBuildProperty) ──
When you run: ./mvnw package
ArC sees @IfBuildProperty(stringValue = "host") on HostEnvironmentController
Property is not set → condition fails → bean is REMOVED during augmentation
Compiled binary:
├── OperatorEnvironmentController ← EXISTS
├── SshConfigWatcherService ← GONE. Not hidden. Gone.
└── HostEnvironmentController ← GONE. Not hidden. Gone.
At runtime: host code doesn't exist. Can't misconfigure it.
Can't accidentally trigger it. Can't have startup side effects.
This matters a lot more than it sounds. Here's a concrete example from my own feature: SshConfigWatcherService (Sub-Issue 4) watches ~/.ssh/config and fires on @Observes StartupEvent. If this class existed in an operator build - the one running inside Kubernetes - it would try to watch that SSH config file on startup. Which doesn't exist in Kubernetes. Which means an immediate crash on startup, in production, with a confusing error message that has nothing to do with what the user did wrong.
With the runtime approach, this is a very possible failure mode if someone misconfigures an env var. With the build-time approach, it's physically impossible - the watcher class doesn't exist in the operator binary.
That's not a small difference in philosophy. That's a completely different safety guarantee.
The Solution I Actually Implemented
Okay so once I understood why, the implementation came together pretty clearly. Three Quarkus CDI mechanisms working together:
1. @IfBuildProperty on the host controller - gates it behind the build property
2. @DefaultBean on the operator controller - makes operator the automatic fallback when no other EnvironmentController exists
3. A custom @HostModeBean stereotype - bundles @ApplicationScoped + @IfBuildProperty into one reusable annotation
That last one is the bit I'm most glad I spent time on. The Quarkus CDI reference confirms that @IfBuildProperty can be placed on a @Stereotype. This means instead of writing the full annotation on every single host-mode bean, every future service in the environment/host/ package just uses @HostModeBean and gets both scope and build condition for free.
@Stereotype
@ApplicationScoped
@IfBuildProperty(name = "debezium.deployment.mode", stringValue = "host")
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface HostModeBean {
/*
* RULE: every host-mode bean uses THIS instead of @ApplicationScoped.
* Without it, the bean initializes in operator builds too.
* SshConfigWatcherService crashing in Kubernetes is not a fun debugging session.
*/
}
And then the operator controller:
@DefaultBean // ← only change
@ApplicationScoped
@Named(OperatorEnvironmentController.BEAN_NAME)
public class OperatorEnvironmentController implements EnvironmentController {
// zero changes to business logic
}
@DefaultBean says: "activate me if no other EnvironmentController bean is present in the container." In an operator build, the host controller doesn't exist (removed at build time), so the operator controller activates automatically. In a host build, the host controller exists, so the operator controller backs off. No explicit @IfBuildProperty(stringValue = "operator") needed - @DefaultBean handles the "everything else" case cleanly.
The PipelineService change that falls out of this is actually really satisfying. The existing code had this comment sitting in it:
// TODO: only operator environment is supported currently;
return findById(id).map(pipeline -> environmentControllers.getFirst());
That TODO had been there because the original developer knew there'd eventually be more modes but couldn't implement them yet. With @IfBuildProperty + @DefaultBean, exactly one EnvironmentController exists in CDI at any build. So instead of a list that we getFirst() from, we just inject the one active bean directly:
// Before - a list because "maybe more someday"
private final List<EnvironmentController> environmentControllers;
// After - exactly one, always, by construction
private final EnvironmentController environmentController;
TODO resolved. List gone. getFirst() gone.
What This Means Going Forward (The Cascade)
One thing I want to be honest about: switching to build-time does change one thing from the original design. The DDD-41 doc said "one Docker image, switch mode via env var." That's no longer how it works. The mode is baked into the binary at build time.
So (Docker image) will build two images:
# Operator image (default - no property needed)
./mvnw package
# Host image
./mvnw package -Ddebezium.deployment.mode=host
The Dockerfile will take a build argument:
ARG DEPLOYMENT_MODE=operator
RUN ./mvnw package -Ddebezium.deployment.mode=${DEPLOYMENT_MODE}
Is that worse than one image? Honestly, I don't think so. Operator mode and host mode have fundamentally different runtime requirements - one needs the Kubernetes API, one needs Ansible and SSH config. Having mode-specific images means each one only carries what it actually needs. That's actually more correct.
And for Sub-Issues 3 - every new host-mode bean just gets @HostModeBean. One annotation. Done. The stereotype enforces the pattern so nobody accidentally forgets to gate their host bean and wonders why SshConfigWatcherService is crashing their operator deployment.
The Honest Reflection
When the mentor first pointed me toward the CDI docs, I spent probably 20 minutes reading while still mentally defending my original approach. Then somewhere in section 5.8 of the guide it clicked - and I went from "this seems like extra work for the same outcome" to "oh, this is actually a completely different guarantee."
The runtime approach gives you flexibility. The build-time approach gives you safety. And in a system where one class crashing at startup in the wrong environment could take down a production pipeline deployment, safety matters more than the convenience of not having to rebuild.
I'm glad Mario pushed back. That's the actual value of having mentors who know the framework deeply - not just getting review comments, but getting pointed toward the thinking behind the design choices.
The feature is called "host-based pipeline deployment." But what I learned this week was really about what it means for a framework to commit to doing expensive work at build time so the runtime can be leaner, safer, and faster. That's the Quarkus bet. And after going through this, I think it's a pretty good one.
Next up: Sub-Issue 3 - the SSH config file parser. A whole different kind of problem. See you there.
THANK YOU!!!



Top comments (0)