Skip to content

feat: create managed dependent resources through Spring - #320

Merged
csviri merged 3 commits into
operator-framework:mainfrom
exijn:feat/2166-spring-managed-dependent-resource
Aug 11, 2026
Merged

feat: create managed dependent resources through Spring#320
csviri merged 3 commits into
operator-framework:mainfrom
exijn:feat/2166-spring-managed-dependent-resource

Conversation

@exijn

@exijn exijn commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

  • create managed dependent resources through Spring's AutowireCapableBeanFactory instead of reflection, so they can receive Spring-managed dependencies (including via constructor injection)
  • preserve Java Operator SDK's dependent-resource configuration step (configureWith) for dependent resources that implement ConfiguredDependentResource
  • allow applications to override the default by providing their own DependentResourceFactory bean (@ConditionalOnMissingBean)
  • add focused Spring Boot integration coverage for constructor injection, workflow registration, JOSDK configuration, factory override, and the existing no-arg-constructor path

Testing

  • ./mvnw -pl starter -am test -Dtest=SpringManagedDependentResourceIntegrationTest,DependentResourceFactoryConfigurationTest,AutoConfigurationTest
  • ./mvnw test (all modules)
  • ./mvnw verify (all modules)
  • ./mvnw spotless:apply / ./mvnw spotless:check

Related to operator-framework/java-operator-sdk#2166

Summary by CodeRabbit

  • New Features

    • Added Spring-managed dependent resource support with dependency injection and bean lifecycle processing.
    • Added configuration to enable or disable Spring-managed dependent resources, enabled by default.
    • Custom dependent resource factories continue to be supported and take precedence when provided.
  • Documentation

    • Documented Spring-managed resource behavior, lifecycle callbacks, AOP support, and configuration options.
  • Tests

    • Added coverage for default, custom, disabled, injection, workflow registration, and lifecycle scenarios.

Managed dependent resources are now instantiated via Spring's
AutowireCapableBeanFactory instead of reflection, so they can receive
Spring-managed dependencies (including via constructor injection)
while still going through the standard Java Operator SDK
configuration step. A user-supplied DependentResourceFactory bean
still takes precedence over this default.

Related to operator-framework/java-operator-sdk#2166
@csviri

csviri commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Thank you @hej090224 I'm a bit busy with something else ATM, but will take a look ASAP

@csviri
csviri requested review from csviri and a lite review from Copilot August 5, 2026 08:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the Spring Boot starter to create Java Operator SDK managed dependent resources via Spring’s AutowireCapableBeanFactory (enabling Spring-managed injection, including constructor injection), while keeping JOSDK’s dependent-resource configuration behavior and allowing users to override the factory via a custom DependentResourceFactory bean.

Changes:

  • Introduces SpringDependentResourceFactory and wires it into OperatorAutoConfiguration behind @ConditionalOnMissingBean.
  • Adds Spring Boot test coverage for constructor injection, workflow registration, default factory behavior, and factory override behavior.
  • Documents the new default dependent-resource instantiation behavior in the README.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactory.java Adds a Spring-backed DependentResourceFactory implementation.
starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java Registers the default factory bean and applies it to the operator configuration.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResourceIntegrationTest.java Integration test ensuring Spring-managed dependent resources receive injected beans and are registered in workflows.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResource.java Test dependent resource using constructor injection to validate Spring creation.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentReconciler.java Test reconciler defining a workflow with both Spring-created and no-arg dependent resources.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/NoArgConstructorDependentResource.java Test dependent resource validating the no-arg-constructor path still works.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/GreetingService.java Test service interface used for constructor-injection verification.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/DependentResourceFactoryConfigurationTest.java Verifies default factory creation and user override precedence in a lightweight context.
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/AutoConfigurationTest.java Adds assertion that the operator configuration uses SpringDependentResourceFactory by default.
README.md Documents the new default dependent-resource instantiation behavior and override mechanism.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

public class SpringManagedDependentReconciler implements Reconciler<TestResource> {

@Override
public UpdateControl<TestResource> reconcile(TestResource testResource, Context context) {
@csviri

csviri commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Hi @hej090224

Some findings that are partially also reported by copilot:

Nice approach overall — DependentResourceFactory is the right extension point, createFrom correctly still calls configure(...) so ConfiguredDependentResource.configureWith keeps working, @ConditionalOnMissingBean gives users an escape hatch, and NoArgConstructorDependentResource pins the backward-compatible path. A few things before merge.

1. associatedResourceType creates a fully-initialized bean and leaks it

SpringDependentResourceFactory.java:38-42

This calls createBean(...) only to read resourceType(), then discards the instance. AutowireCapableBeanFactory.createBean(Class) performs full initialization (constructor autowiring, all BeanPostProcessors, @PostConstruct), and per its contract the caller owns destruction — destroyBean is never called, so @PreDestroy/DisposableBean never run on the discarded object.

KubernetesDependentConverter calls associatedResourceType during controller registration, so every Kubernetes dependent is Spring-created twice and every @PostConstruct runs twice. Upstream's default also instantiates a throwaway, but via plain reflection — no injection, no lifecycle callbacks, and it returns null instead of throwing when instantiation fails; this override converts that into a BeanCreationException.

The resource type doesn't depend on injection, so Spring needn't be involved:

@Override
public Class<?> associatedResourceType(DependentResourceSpec spec) {
  return DependentResourceFactory.super.associatedResourceType(spec);
}

2. LAST_CREATED_INSTANCE can't distinguish the real instance from the throwaway

SpringManagedDependentResource.java:14-15

Because of (1) the constructor runs twice, so this static holder captures whichever instance was constructed last, not necessarily the one registered in the workflow. The tests pass only because createFrom happens to run after associatedResourceType — the assertions would silently follow the wrong object if that ordering changed, and managedDependentResourceIsStillConfiguredByJosdk is what's indirectly guarding it. The static is also never reset, so it leaks across tests.

The third test already shows how to reach the real objects (getRegisteredController(...).getConfiguration().getWorkflowSpec()); asserting on the instance obtained that way would make tests 1 and 2 test what they claim. Fixing (1) removes the double instantiation that makes this fragile.

3. This changes behavior for every existing user

Since it's the default rather than opt-in, worth calling out explicitly. For dependent resources that already worked:

  • constructor selection changes for classes with a single non-default constructor;
  • previously-ignored @Autowired/@Value fields now get injected;
  • @PostConstruct now runs;
  • matching AOP advice now wraps the instance in a proxy — if that's an interface-based JDK proxy rather than CGLIB, JOSDK's internal casts to KubernetesDependentResource would fail.

Consider gating behind a property, or at minimum documenting the change in the README and release notes.

Generic-type introspection is not affected — getFirstTypeArgumentFrom is only used for reconcilers, and these dependents pass resourceType explicitly.

Smaller items

  • OperatorAutoConfiguration.java:179@Order(0) collides with defaultConfigServiceOverrider (line 143), so @Order no longer determines their relative position in the injected list. Harmless today since they set disjoint properties, but worth a distinct value.
  • OperatorAutoConfiguration.java:180-181 — injecting DependentResourceFactory<?, ?> by type means two user-supplied factories fail startup with NoUniqueBeanDefinitionException (yours is suppressed by @ConditionalOnMissingBean). ObjectProvider would degrade more gracefully.
  • SpringManagedDependentReconciler.java:15 — raw Context; should be Context<TestResource>.
  • SpringDependentResourceFactory implements the raw interface while the bean method returns DependentResourceFactory<?, ?>. Raw is defensible given withDependentResourceFactory takes a raw type, but the two declarations disagree.
  • The README addition doesn't mention that lifecycle callbacks differ from normal beans — the class javadoc covers it, the user-facing docs don't.

- SpringDependentResourceFactory#associatedResourceType now destroys
  the throwaway instance it creates via destroyBean(), instead of
  leaking it. Falling back to the reflection-based default was not an
  option: it requires a no-arg constructor, which defeats the purpose
  of constructor-injected dependent resources.
- Add javaoperatorsdk.dependent-resources.spring-managed property
  (default true) so existing users can opt out of the new default
  behavior and keep JOSDK's no-arg-constructor instantiation.
- Fix @order collision between the two ConfigurationServiceOverrider
  beans, and switch the dependent-resource-factory overrider to an
  ObjectProvider gated on @ConditionalOnBean, so it degrades
  gracefully instead of failing to wire when the factory bean is
  absent or ambiguous.
- Use Context<TestResource> instead of a raw Context in the test
  reconciler.
- Reset the LAST_CREATED_INSTANCE static test hook after the class
  runs, and document why it reliably captures the workflow instance
  rather than the (now-destroyed) type-discovery throwaway.
- Document the Spring bean lifecycle differences and the new opt-out
  property in the README.
- Add unit tests for the factory's create/destroy behavior and for
  the new property.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d3d21bd1-fdde-48f0-a1c2-e3ccce339d56

📥 Commits

Reviewing files that changed from the base of the PR and between 493161e and 0f71ad0.

📒 Files selected for processing (2)
  • starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/DependentResourceFactoryConfigurationTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/DependentResourceFactoryConfigurationTest.java
  • starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java

📝 Walkthrough

Walkthrough

The starter now creates dependent resources through Spring by default. It adds configuration properties, custom-factory precedence, lifecycle handling, resource-type discovery cleanup, and integration tests for injection and workflow registration.

Changes

Spring-managed dependent resources

Layer / File(s) Summary
Configuration and auto-configuration
starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorConfigurationProperties.java, starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/*ConfigurationTest.java, README.md
Adds the dependentResources.springManaged property, creates the default DependentResourceFactory when enabled, applies available custom factories, and documents the configuration.
Spring factory and lifecycle handling
starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactory.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactoryTest.java
Creates dependent resources through AutowireCapableBeanFactory, applies standard configuration, and destroys temporary instances used for resource-type discovery.
Workflow integration and validation
starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResource.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentReconciler.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResourceIntegrationTest.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/NoArgConstructorDependentResource.java, starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/GreetingService.java
Adds Spring-managed and no-argument dependent resources, registers them in a workflow, and verifies injection, configuration, and registration.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Suggested reviewers: csviri

Sequence Diagram(s)

sequenceDiagram
  participant SpringBootContext
  participant OperatorAutoConfiguration
  participant SpringDependentResourceFactory
  participant SpringManagedDependentReconciler
  SpringBootContext->>OperatorAutoConfiguration: create configured factory
  OperatorAutoConfiguration->>SpringDependentResourceFactory: inject factory into operator configuration
  SpringManagedDependentReconciler->>SpringDependentResourceFactory: create dependent resource
  SpringDependentResourceFactory->>SpringBootContext: request Spring-managed instance
  SpringBootContext-->>SpringDependentResourceFactory: return injected instance
  SpringDependentResourceFactory-->>SpringManagedDependentReconciler: return configured dependent resource
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: creating dependent resources through Spring.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@exijn

exijn commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review, @csviri! Pushed a fix addressing all of it:

1. associatedResourceType leak — fixed, but not with the exact snippet you suggested: falling back to DependentResourceFactory.super.associatedResourceType(spec) actually breaks constructor-injected resources, since that default path requires a no-arg constructor (Utils.getConstructor throws IllegalStateException → wrapped OperatorException when there isn't one) — which is exactly what this factory exists to support. Verified this against the resolved operator-framework-core:5.5.0 on the classpath; I'd assumed the same until I actually ran it and the constructor-injected test resource failed to register at all.

Instead, associatedResourceType still creates the throwaway instance through Spring (unavoidable — needs to work for resources without a no-arg constructor), but now explicitly calls beanFactory.destroyBean(instance) in a finally block afterward, since AutowireCapableBeanFactory.createBean(Class)'s contract puts destruction on the caller. That's a direct fix for the leak without regressing the feature. Added unit tests (SpringDependentResourceFactoryTest) covering create+destroy, destroy-on-failure, and that createFrom's returned instance is not destroyed.

2. LAST_CREATED_INSTANCE fragility — the double-instantiation itself isn't going away (see above), but the ordering isn't incidental either: associatedResourceType runs during config/spec resolution, which always precedes workflow resolution (where createFrom runs) in JOSDK's registration pipeline — so the static field deterministically ends up holding the workflow instance. Added a doc comment explaining this, and noted that managedDependentResourceIsStillConfiguredByJosdk already cross-checks it structurally (the throwaway instance never goes through configure(), so it'd fail if the ordering assumption ever broke). Also reset the static after the test class runs, per your note about it leaking across tests.

3. Behavior change for existing users — added javaoperatorsdk.dependent-resources.spring-managed (default true) so it's opt-outable; setting it to false skips registering SpringDependentResourceFactory entirely and JOSDK falls back to its own default. Also expanded the README section to spell out the lifecycle differences (injection, @PostConstruct, possible AOP proxying) rather than just the class javadoc.

Smaller items — all applied:

  • Distinct @Order for dependentResourceFactoryConfigServiceOverrider (was colliding with defaultConfigServiceOverrider).
  • Switched it to ObjectProvider<DependentResourceFactory<?, ?>> + @ConditionalOnBean, so it degrades gracefully instead of failing to wire.
  • Fixed raw ContextContext<TestResource> in SpringManagedDependentReconciler.
  • Parameterized SpringDependentResourceFactory implements DependentResourceFactory<ControllerConfiguration<?>, DependentResourceSpec> instead of the raw type, matching the bean method's DependentResourceFactory<?, ?> return type.

Full test suite (incl. new tests) passes locally, spotless clean.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java`:
- Around line 185-191: Update dependentResourceFactoryConfigServiceOverrider to
use ObjectProvider.ifUnique instead of ifAvailable, so the overrider is applied
only when a single DependentResourceFactory bean can be selected and startup
does not fail when multiple non-primary factories exist.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 20f4e83f-da34-490b-9a72-ca55dc9e976f

📥 Commits

Reviewing files that changed from the base of the PR and between 812c64c and 493161e.

📒 Files selected for processing (12)
  • README.md
  • starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorAutoConfiguration.java
  • starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/OperatorConfigurationProperties.java
  • starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactory.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/AutoConfigurationTest.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/DependentResourceFactoryConfigurationTest.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/GreetingService.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/NoArgConstructorDependentResource.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactoryTest.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentReconciler.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResource.java
  • starter/src/test/java/io/javaoperatorsdk/operator/springboot/starter/SpringManagedDependentResourceIntegrationTest.java

ifAvailable() still throws NoUniqueBeanDefinitionException when multiple
non-primary DependentResourceFactory beans are present, same as a direct
injection would - it doesn't actually make the two-user-supplied-factories
case degrade gracefully. ifUnique() does: it simply skips applying the
override, leaving JOSDK's own default factory in place.

Adds a test that registers two ambiguous custom factories and asserts
applying the overrider doesn't throw and doesn't call
withDependentResourceFactory.

Addresses coderabbitai's review comment on the previous fix.
@exijn

exijn commented Aug 9, 2026

Copy link
Copy Markdown
Contributor Author

Addressed CodeRabbit's follow-up finding from the last review:

ObjectProvider.ifAvailable still throws NoUniqueBeanDefinitionException when multiple non-primary DependentResourceFactory beans are present.

Good catch — ifAvailable() resolves via the same single-candidate machinery as direct injection, so it didn't actually fix the "two user-supplied factories" scenario the way I'd intended, it just moved the failure point. Switched to ifUnique(), which only invokes the consumer when exactly one candidate exists and no-ops otherwise (leaving JOSDK's own default factory in place rather than throwing).

Added a test (ambiguousUserProvidedFactoriesAreLeftForJosdksOwnDefaultInsteadOfFailing) that registers two competing custom factory beans and asserts applying the overrider doesn't throw and doesn't call withDependentResourceFactory — confirmed it fails against the old ifAvailable() code and passes with ifUnique().

Copilot's two comments from the first round (leak in associatedResourceType, raw Context) were already covered by the previous push. Full test suite + spotless pass.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (2)

starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactory.java:47

  • This method uses the raw DependentResource type, which will generate unchecked/raw-type warnings. Using DependentResource here avoids those warnings while keeping behavior the same.
    final DependentResource instance =
        (DependentResource) beanFactory.createBean(spec.getDependentResourceClass());

starter/src/main/java/io/javaoperatorsdk/operator/springboot/starter/SpringDependentResourceFactory.java:36

  • If dependent-resource configuration (configure/configureWith) throws, the newly created Spring-managed instance is currently leaked (it has already gone through bean initialization). Consider destroying the instance before rethrowing so failures don’t leave partially-initialized dependent resources hanging around.

This issue also appears on line 46 of the same file.

  public DependentResource createFrom(DependentResourceSpec spec,
      ControllerConfiguration<?> controllerConfiguration) {
    final DependentResource instance =
        (DependentResource) beanFactory.createBean(spec.getDependentResourceClass());
    configure(instance, spec, controllerConfiguration);

@csviri csviri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you @hej090224 !!

@csviri
csviri merged commit 72a64f0 into operator-framework:main Aug 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants