Skip to main content

@ConfigProperties Annotation Basics

The @ConfigProperties annotation (introduced in 10.0.0, package org.apache.juneau.commons.inject) is a declarative, prefix-scoped whole-object config binder — Juneau's analog of Spring Boot's @ConfigurationProperties.

Where @Value resolves a single configuration value onto a single field or parameter, @ConfigProperties resolves an entire prefix (myapp.host, myapp.port, myapp.timeout, ...) onto the fields of one POJO in a single pass:

import org.apache.juneau.commons.inject.ConfigProperties;

@ConfigProperties(prefix = "MyService")
public class MyServiceProperties {
public String host = "localhost";
public int port = 8080;
}

The two annotations are complementary, not competing — a class can carry @Value fields internally while also being the target of another class's nested @ConfigProperties bind (see Nested config objects below), and @ConfigProperties fields that are themselves annotated @Value/@Inject are left untouched by the config bind (see Coexistence with @Value and @Inject).

Minimal end-to-end example​

Given the MyServiceProperties class above, materializing it through BeanInstantiator runs the bind automatically:

BeanStore store = new BasicBeanStore(null);
MyServiceProperties props = BeanInstantiator.of(MyServiceProperties.class, store).run();

With MyService.host=example.com and MyService.port=9090 set as system properties (or environment variables, or any other registered PropertySource), props.host is "example.com" and props.port is 9090. A key that isn't present anywhere in the chain leaves the field's initializer value untouched — bind-only-present semantics, the same "default lives in the field initializer" idea @Value's ${key:default} syntax expresses inline.

You can also invoke the underlying binder directly, with or without the annotation, via ConfigPropertiesBinder's fluent builder:

MyServiceProperties props = ConfigPropertiesBinder.of(new MyServiceProperties(), "MyService")
.relaxed(true) // default
.run();

Relaxed key matching​

relaxed() defaults to true on the annotation (and on the binder). For each field, the candidate key prefix.fieldName is probed in up to three spellings, via RelaxedPropertySource.candidates(String) — the same relaxed-matching logic used elsewhere in juneau-commons:

  1. Verbatim — MyService.host
  2. SCREAMING_SNAKE_CASE — MY_SERVICE_HOST (the env-var-friendly spelling)
  3. lower.dotted — my.service.host

This is what lets a Kubernetes-style environment variable (MY_SERVICE_HOST=example.com) satisfy a camelCase-prefixed key without any extra configuration. Set relaxed = false on the annotation (or .relaxed(false) on the binder) to require an exact, verbatim key match only.

Source precedence​

Each candidate spelling is resolved through the same chain @Value uses — candidate-major: every spelling is checked against the entire chain before the next spelling is tried, in this order per candidate:

  1. A Settings.setLocal(...) / Settings.setGlobal(...) test override, if one is recorded for that exact candidate — always wins.
  2. Any caller-scoped PropertySource-typed beans registered in a BeanStore passed via ConfigPropertiesBinder.of(...).beanStore(...) (or supplied automatically by the @ConfigProperties resolution hook — see below) — consulted ahead of the global Settings chain, first-match-wins among the scoped sources.
  3. The process-wide Settings.get(name) chain — registered PropertySources, system properties, then system environment variables.

Because precedence is candidate-major rather than source-major, a verbatim hit in a lower-priority source can outrank a relaxed-spelling override set on a higher-priority source, if that override used a different spelling than the one that resolved first. In practice this rarely matters — most deployments pick one spelling convention per key — but it's worth knowing if a key is set two different ways in two different places.

The caller-scoped PropertySource layer is exactly how the RestContext pilot (see RestContext) lets a resource's @Rest(config=...) file participate in binding RestContext.* keys without mutating the shared Settings.get() singleton.

Nested config objects​

A field whose declared type is itself @ConfigProperties-annotated is bound recursively, using prefix.fieldName as the sub-prefix — the field's own @ConfigProperties(prefix=...) value is ignored for a nested bind (the enclosing prefix always wins):

@ConfigProperties(prefix = "unused-when-nested")
public class ServerProperties {
public String host = "localhost";
public int port = 80;
}

@ConfigProperties(prefix = "MyApp")
public class MyAppProperties {
public ServerProperties server = new ServerProperties();
}

MyApp.server.host and MyApp.server.port bind onto server's fields. A null nested field is only materialized (via an accessible no-arg constructor) if at least one key resolves anywhere under its sub-prefix — otherwise it's left null, so an optional nested block doesn't force an empty instance into existence. Circular @ConfigProperties nesting (a type that, directly or transitively, nests itself) is detected and raises a RuntimeException rather than recursing indefinitely.

This nested-field detection is inheritance-aware: a field whose declared type is a subclass of an @ConfigProperties-annotated type still triggers a nested bind. This is different from the annotation's own top-level trigger — see the note below.

Coexistence with @Value and @Inject​

@ConfigProperties and @Value serve different granularities and coexist on the same class without conflict. A field already annotated @Value or @Inject is never bound by ConfigPropertiesBinder, even when a matching prefix.fieldName key exists — injection resolution owns those fields exclusively:

@ConfigProperties(prefix = "MyService")
public class MyServiceProperties {
public String host = "localhost"; // bound by ConfigPropertiesBinder

@Value("${MyService.label:default-label}")
public String label; // bound by @Value, never touched by the config bind
}

When materialized through BeanInstantiator, the whole-object config bind runs after @Value/@Inject field-and-method injection and before any @PostConstruct method, so a @PostConstruct hook observes fully-bound config fields either way.

Auto-registration in the BeanStore​

When a @ConfigProperties-annotated class is materialized through BeanInstantiator, the bound instance is automatically registered into the bean store as a framework default (a non-clobbering addDefaultSupplier — lowest priority, so an explicit caller registration still wins), under the declared annotated type. It's then retrievable anywhere downstream:

MyServiceProperties props = beanStore.getBean(MyServiceProperties.class).orElseThrow();

or injected directly:

public class MyResource {
@Inject
MyServiceProperties props;
}

Not @Inherited: the BeanInstantiator trigger fires only when the exact declared type being instantiated carries @ConfigProperties — a subclass or interface implementor of an annotated type does not itself trigger binding or auto-registration. (This is distinct from the nested-field detection described above, which is inheritance-aware.)

Current limitations​

  • No Optional<T> / Supplier<T> field types. Unlike @Value, type conversion here is keyed on the field's raw Class, not its full generic Type — generic container types aren't supported as @ConfigProperties field types. Use a plain field type (e.g. String uriAuthority with a null default, rather than Optional<String>) if you need "unset" to be distinguishable from a real value.
  • Validation is currently a no-op seam. A post-bind extension point, ConfigPropertiesValidator, is invoked once per bound object (innermost-nested first) — but the shipped default, ConfigPropertiesValidator.NO_OP, performs no validation. It exists purely as a placement marker so a future release can add JSR-303-style validation without changing the binder's public shape.

See also​