How to Use Variable Expansion in @Sources Annotations with Imports

Owner resolves ${var} placeholders inside @Sources annotations at runtime using a hierarchical lookup of environment variables, JVM system properties, and per-configuration import maps.

The Owner configuration library (matteobaccan/owner) allows dynamic resource locations through placeholder expansion in the @Sources annotation. This mechanism enables environment-specific configuration loading without recompiling your code. Understanding how to combine global factory properties with per-configuration imports gives you fine-grained control over configuration resolution.

How Variable Expansion Works in @Sources

The expansion is handled by VariablesExpander (see owner/src/main/java/org/aeonbits/owner/VariablesExpander.java), which constructs a StrSubstitutor from three sources. When ConfigFactory creates a configuration object, it builds a ConfigURIFactory that calls expander.expand(spec) before converting the string to a java.net.URI.

Resolution occurs in the following priority order:

  1. System environment variables – retrieved via System.getenv()
  2. JVM system properties – retrieved via System.getProperties()
  3. Properties supplied to the factory – set via ConfigFactory.setProperty(..), ConfigFactory.setProperties(..), or passed as imports to ConfigFactory.create(..)

If a placeholder cannot be resolved, it remains unchanged, causing the subsequent URI parsing to fail with a URISyntaxException or resource-not-found error.

Setting Global Factory Properties

Use ConfigFactory.setProperty() to define variables that apply to all configuration instances created by the factory.

@Sources("file:${configDir}/myapp.properties")
public interface GlobalConfig extends Config {
    String name();
}

// Set once for the entire JVM
ConfigFactory.setProperty("configDir", "/opt/config");

GlobalConfig cfg = ConfigFactory.create(GlobalConfig.class);
// Resolves to: file:/opt/config/myapp.properties

Source reference: The @Sources annotation is declared in owner/src/main/java/org/aeonbits/owner/Config.java (lines 69-71).

Using Per-Configuration Imports

Imports are Map<?,?> arguments passed to ConfigFactory.create(..). They merge into the VariablesExpander for that specific instance only, leaving other configurations unaffected.

@Sources("classpath:settings-${profile}.properties")
public interface ProfileConfig extends Config {
    String url();
}

Map<String, String> dev = Collections.singletonMap("profile", "dev");
Map<String, String> prod = Collections.singletonMap("profile", "prod");

ProfileConfig devCfg = ConfigFactory.create(ProfileConfig.class, dev);
ProfileConfig prodCfg = ConfigFactory.create(ProfileConfig.class, prod);

Source reference: Import handling in ConfigFactory.create is implemented in owner/src/main/java/org/aeonbits/owner/ConfigFactory.java (lines 65-73).

Mixing Resolution Sources

You can combine environment variables, JVM properties, and imports in a single source specification. The VariablesExpander merges all sources according to the priority chain defined in owner/src/main/java/org/aeonbits/owner/VariablesExpander.java (lines 31-35).

// OS env: HOME=/home/alice
// JVM arg: -Denv=stage
@Sources("file:${HOME}/${env}/${path}")
public interface MixedConfig extends Config {
    String data();
}

Map<String, String> vars = Collections.singletonMap("path", "data.properties");
MixedConfig cfg = ConfigFactory.create(MixedConfig.class, vars);
// Final URI: file:/home/alice/stage/data.properties

Dynamic Protocol and Path Expansion

Placeholders can contain entire URI schemes or partial paths. The ConfigURIFactory expands the specification before protocol handling occurs (see line 34 in owner/src/main/java/org/aeonbits/owner/ConfigURIFactory.java).

@Sources("${myurl}")
public interface UrlConfig extends Config {
    String value();
}

// Per-config import containing protocol
Map<String, String> map = Collections.singletonMap("myurl", "file:${configDir}/override.properties");
ConfigFactory.setProperty("configDir", "/tmp");

UrlConfig cfg = ConfigFactory.create(UrlConfig.class, map);
// Loads from file:/tmp/override.properties

Summary

  • Variable expansion in @Sources uses ${var} syntax resolved by VariablesExpander at runtime.
  • Resolution priority: environment variables → JVM system properties → factory properties → per-configuration imports.
  • Global properties set via ConfigFactory.setProperty() affect all subsequent configuration instances.
  • Per-configuration imports passed to ConfigFactory.create(..) provide isolated variable scopes for individual configuration objects.
  • Expansion occurs once before URI parsing, allowing dynamic protocol selection and path construction.

Frequently Asked Questions

What happens if a variable in @Sources cannot be resolved?

If a placeholder lacks a matching value in the environment, system properties, factory properties, or imports, the VariablesExpander leaves the literal ${var} text unchanged. This causes ConfigURIFactory to generate an invalid URI, resulting in a URISyntaxException or a "resource not found" error during configuration loading.

Can I use variable expansion for the protocol part of a URI?

Yes. Placeholders can represent the entire URI specification or just the protocol segment. As implemented in ConfigURIFactory.java, expansion occurs before protocol-specific handling, so @Sources("${protocol}:${path}") correctly resolves to file:config.properties or classpath:config.properties depending on your variable definitions.

Do imports override system properties in Owner?

No. The resolution chain follows a strict hierarchy where system environment variables and JVM system properties take precedence over factory properties and imports. According to VariablesExpander.java (lines 31-35), imports are checked last, meaning they can only provide values for variables not already defined at the OS or JVM level.

How do I debug which variables are being expanded in my @Sources?

Owner does not provide a dedicated debug mode for variable expansion, but you can inspect the resolved URI by catching loading exceptions or implementing a custom loader. Alternatively, set breakpoints in VariablesExpander.expand() or ConfigURIFactory.create() to observe the substitution process during configuration instantiation.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →