Configuration
The configuration module reads application settings from HOCON or YAML files, environment variables, Java system
properties, and maps them to typed classes in Kora. The resulting configuration objects become regular dependency
graph components and can be injected into services, clients, servers, and other integrations.
In Kora, application configuration is usually described by an interface annotated with @ConfigSource: the path in
the file points to the section to read, and the interface methods describe required values, optional values, and defaults.
Libraries and reusable configuration shapes use @ConfigMapper, which creates only the mapping rule, while
the concrete path is selected in the library module.
For a step-by-step walkthrough before the reference details, see HOCON Configuration and YAML Configuration.
HOCON¶
Support for HOCON is implemented with Typesafe Config.
HOCON is a JSON-based configuration file format. It is less strict than JSON and supports substitutions, defaults,
and a convenient syntax for nested objects.
services {
foo {
bar = "SomeValue" //(1)!
baz = 10 //(2)!
propRequired = ${REQUIRED_ENV_VALUE} //(3)!
propOptional = ${?OPTIONAL_ENV_VALUE} //(4)!
propDefault = 10
propDefault = ${?NON_DEFAULT_ENV_VALUE} //(5)!
propReference = ${services.foo.bar}Other${services.foo.baz} //(6)!
propArray = ["v1", "v2"] //(7)!
propArrayAsString = "v1, v2" //(8)!
propMap = { //(9)!
"k1" = "v1"
"k2" = "v2"
}
propObject = { //(10)!
p1 = "v1"
p2 = "v2"
}
propObjects = [ //(11)!
{
p1 = "v1"
p2 = "v2"
},
{
p1 = "v3"
p2 = "v4"
}
]
}
}
- String configuration value
- Numeric configuration value
- Required configuration value substituted from the
REQUIRED_ENV_VALUEenvironment variable - Optional configuration value substituted from the
OPTIONAL_ENV_VALUEenvironment variable; if the variable is not found, the configuration value is omitted - Configuration value with a default: the default is specified as
propDefault = 10, andNON_DEFAULT_ENV_VALUE, if found, replaces it - Configuration value assembled from substitutions of other configuration parts with the
Othervalue between them - String list configuration value; the value can be set as an array of strings or as a comma-separated string
- String list configuration value; the value can be set as a comma-separated string or as an array of strings
- Configuration value as a key-value dictionary
- Configuration value as a mapped class
- Configuration value as a list of mapped classes
Values can also reference other configuration keys (self-reference / cross-reference) via ${path}, and environment
variables via ${VAR} (required) or ${?VAR} (optional). A default is expressed the HOCON way: assign the key twice,
first with the fallback literal and then with an optional substitution, as propDefault does above. Substitutions inside
a HOCON file are resolved by Typesafe Config after every layer of that file is merged, so a reference can point at a
key defined in another HOCON file pulled in through include.
Configuration representation in code:
@ConfigSource("services.foo")
public interface FooConfig {
String bar();
Integer baz();
String propRequired();
@Nullable
String propOptional();
Integer propDefault();
String propReference();
List<String> propArray();
List<String> propArrayAsString();
Map<String, String> propMap();
@ConfigMapper
public interface ObjectConfig {
String p1();
String p2();
}
ObjectConfig propObject();
List<ObjectConfig> propObjects();
}
@ConfigSource("services.foo")
interface FooConfig {
fun bar(): String
fun baz(): Int
fun propRequired(): String
fun propOptional(): String?
fun propDefault(): Int
fun propReference(): String
fun propArray(): List<String>
fun propArrayAsString(): List<String>
fun propMap(): Map<String, String>
@ConfigMapper
interface ObjectConfig {
fun p1(): String
fun p2(): String
}
fun propObject(): ObjectConfig
fun propObjects(): List<ObjectConfig>
}
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
File¶
By default, the reference.conf and application.conf configuration files are expected.
First, all reference.conf files from the classpath are merged, then application.conf is overlaid on top of the
unresolved reference.conf, and Java system properties are overlaid on top of that. Only after the whole stack is
assembled is the result resolved and required substitutions are checked.
The application configuration is expected to be in application.conf, while library configuration is expected to be in reference.conf.
HOCON also supports the include directive:
files pulled in through include participate in the same merge and substitution resolution as the main file.
Includes that resolve to real files on disk are also tracked by the Config Watcher, so changing an
included file refreshes the graph too; includes that resolve to classpath resources or URLs are not tracked.
Application file selection priority for HOCON:
- Use the file from
config.resourceif specified (file from theresourcesdirectory) - Use the file from
config.fileif specified (file from the file system) - Use
application.confif present (file from theresourcesdirectory) - Use an empty configuration if none of the above is present
Only one property can be specified at the same time: config.resource or config.file. If both properties are specified,
the application will fail on startup with Application config source is ambiguous.
YAML¶
Support for YAML is implemented using SnakeYAML.
services:
foo:
bar: "SomeValue" #(1)!
baz: 10 #(2)!
propRequired: ${REQUIRED_ENV_VALUE} #(3)!
propOptional: ${?OPTIONAL_ENV_VALUE} #(4)!
propDefault: ${NON_DEFAULT_ENV_VALUE:10} #(5)!
propReference: ${services.foo.bar}Other${services.foo.baz} #(6)!
propArray: ["v1", "v2"] #(7)!
propArrayAsString: "v1, v2" #(8)!
propMap: #(9)!
k1: "v1"
k2: "v2"
propObject: #(10)!
p1: "v1"
p2: "v2"
propObjects: #(11)!
- p1: "v1"
p2: "v2"
- p1: "v1"
p2: "v2"
- String configuration value
- Numeric configuration value
- Required configuration value substituted from the
REQUIRED_ENV_VALUEenvironment variable - Optional configuration value substituted from the
OPTIONAL_ENV_VALUEenvironment variable; if the variable is not found, the configuration value is omitted - Configuration value with a default: the default is
10, andNON_DEFAULT_ENV_VALUE, if found, replaces it - Configuration value assembled from substitutions of other configuration parts with the
Othervalue between them - String list configuration value; the value can be set as an array of strings or as a comma-separated string
- String list configuration value; the value can be set as a comma-separated string or as an array of strings
- Configuration value as a key-value dictionary
- Configuration value as a mapped class
- Configuration value as a list of mapped classes
YAML has no substitution syntax of its own, so references are resolved by Kora itself once all configuration layers
are merged. Three forms are supported:
${path}— required: the reference must resolve, otherwise the application fails on startup${?path}— optional: an unresolved reference yields no value, and the key behaves as if it were absent${path:defaultValue}— an unresolved reference falls back todefaultValue
The same forms work for environment variables and for references to other configuration keys, because environment
variables and system properties are configuration layers themselves. Several references can be embedded into one string,
as propReference does above.
Attention
The ? and the default value cannot be combined: in ${?path:defaultValue} the whole path:defaultValue text is
treated as the reference name, and the key resolves to nothing. Use ${path:defaultValue} — it already falls back
when the reference is missing.
Configuration representation in code:
@ConfigSource("services.foo")
public interface FooConfig {
String bar();
Integer baz();
String propRequired();
@Nullable
String propOptional();
Integer propDefault();
String propReference();
List<String> propArray();
List<String> propArrayAsString();
Map<String, String> propMap();
@ConfigMapper
public interface ObjectConfig {
String p1();
String p2();
}
ObjectConfig propObject();
List<ObjectConfig> propObjects();
}
@ConfigSource("services.foo")
interface FooConfig {
fun bar(): String
fun baz(): Int
fun propRequired(): String
fun propOptional(): String?
fun propDefault(): Int
fun propReference(): String
fun propArray(): List<String>
fun propArrayAsString(): List<String>
fun propMap(): Map<String, String>
@ConfigMapper
interface ObjectConfig {
fun p1(): String
fun p2(): String
}
fun propObject(): ObjectConfig
fun propObjects(): List<ObjectConfig>
}
Dependency¶
Dependency build.gradle:
Module:
Dependency build.gradle.kts:
Module:
File¶
By default, the reference.yaml and application.yaml configuration files are expected.
First, all reference.yaml files from the classpath are merged, then application.yaml is overlaid on top of
reference.yaml, and after that the result is resolved and required substitutions are checked.
The application configuration is expected to be in application.yaml, while library configuration is expected to be in reference.yaml.
Every reference.yaml must be resolvable on its own, without the application file: it is validated at startup, and an
unresolvable reference fails the application with Reference config ... cannot be resolved without external application config.
Give such keys a literal default, make the reference optional with ${?path}, or provide a fallback with ${path:defaultValue}.
Application file selection priority for YAML:
- Use the file from
config.resourceif specified (file from theresourcesdirectory) - Use the file from
config.fileif specified (file from the file system) - Use
application.yamlif present (file from theresourcesdirectory) - Use an empty configuration if none of the above is present
Only one property can be specified at the same time: config.resource or config.file. If both properties are specified,
the application will fail on startup with Application config source is ambiguous.
Custom configuration¶
A custom configuration maps a configuration file section to a user type. That type can then be injected as a dependency just like any other component.
Both @ConfigSource and @ConfigMapper generate a ConfigValueMapper<T> implementation at compile time.
The declaration shapes they accept are:
Java— aninterface, arecord, or a class. A class must be non-abstract and must override bothequalsandhashCodeKotlin— aninterfaceor adata class
Methods of a configuration interface describe fields, so they must take no parameters, must not be generic, and must
return a value. Anything else has to be a default method. Fields declared in super-interfaces are inherited into the
mapping.
Application config¶
Use the @ConfigSource annotation to create custom configurations in an application.
It generates a ConfigValueMapper for the type and a module that adds the ready configuration object to the
dependency graph. The annotation value points to the section path inside the resulting configuration:
This code sample will add an instance of the FooServiceConfig class to the dependency container, which when created will expect the following kind of configuration:
After that, the FooServiceConfig class can already be used as a dependency in other classes:
Library config¶
Use the @ConfigMapper annotation to create custom configurations in libraries.
It creates a rule for mapping a ConfigValue<?> to the type, but does not bind it to a concrete configuration path.
The path is selected in a library module factory method, so the same configuration shape can be reused for different sections.
The annotation has the mapNullAsEmptyObject parameter (default: true). When enabled, a missing section is treated
as an empty object: required fields still fail, while optional fields and defaults behave as if an empty section was present.
If mapNullAsEmptyObject = false, a missing section is mapped to null for the whole configuration object.
A type annotated only with @ConfigSource always behaves as if mapNullAsEmptyObject = true.
Consider this configuration class:
For the library to provide configuration, implement a factory in a module:
The factory will expect a configuration of the following kind:
Then, after connecting FooLibraryModule in the application, FooLibraryConfig can be used as a dependency in other classes.
ConfigValueMapper<T> has two reading methods: map(...) may return null — for a generated mapper that happens when
mapNullAsEmptyObject = false and the section is missing — while mapOrThrow(...) turns the same null into a
ConfigValueException. Factory methods normally use mapOrThrow(...), because a missing library section is a startup
error rather than a valid state.
The same shape can be bound to several sections at once by adding a tag to each factory method:
public interface FooLibraryModule {
final class Lib1Tag {}
final class Lib2Tag {}
@Tag(Lib1Tag.class)
default FooLibraryConfig lib1Config(Config config, ConfigValueMapper<FooLibraryConfig> mapper) {
return mapper.mapOrThrow(config.get("libs.lib1"));
}
@Tag(Lib2Tag.class)
default FooLibraryConfig lib2Config(Config config, ConfigValueMapper<FooLibraryConfig> mapper) {
return mapper.mapOrThrow(config.get("libs.lib2"));
}
}
interface FooLibraryModule {
class Lib1Tag private constructor()
class Lib2Tag private constructor()
@Tag(Lib1Tag::class)
fun lib1Config(config: Config, mapper: ConfigValueMapper<FooLibraryConfig>): FooLibraryConfig {
return mapper.mapOrThrow(config.get("libs.lib1"))
}
@Tag(Lib2Tag::class)
fun lib2Config(config: Config, mapper: ConfigValueMapper<FooLibraryConfig>): FooLibraryConfig {
return mapper.mapOrThrow(config.get("libs.lib2"))
}
}
Required values¶
By default, all values declared in the configuration are considered required and must be present in the
resulting configuration. If a required value is missing or has the null value, the application will fail while creating
the configuration object with Config expected value, but got null at path: '...'.
Optional values¶
If you need to specify a value from the configuration file as optional, you can use this format:
It is suggested to use the @Nullable annotation over the method signature:
@ConfigSource("services.foo")
public interface FooServiceConfig {
@Nullable//(1)!
String bar();
int baz();
}
- JSpecify
org.jspecify.annotations.Nullable, the annotationKoraitself is built on.
Use Kotlin null-safety syntax and mark the parameter as nullable:
JSpecify @Nullable is a type-use annotation, so for a qualified or generic type it is written immediately before
the type name rather than before the whole declaration:
An Optional<T> return type is also supported (an absent value maps to Optional.empty()), but a @Nullable value
(or a Kotlin nullable type) is the recommended style.
Default values¶
If you need to set a default value in configuration mapping, use a default method:
Defaults are available for the other declaration forms too, but the mechanism differs by language: a Kotlin
data class takes them from constructor parameter defaults, while a Java class takes them from field
initializers and therefore needs a public no-argument constructor together with accessors:
@ConfigMapper
public class FooServiceConfig {
private String bar;
private int baz = 42;
public String getBar() {
return this.bar;
}
public void setBar(String bar) {
this.bar = bar;
}
public int getBaz() {
return this.baz;
}
public void setBaz(int baz) {
this.baz = baz;
}
@Override
public boolean equals(Object o) {
return o instanceof FooServiceConfig that
&& Objects.equals(this.bar, that.bar)
&& this.baz == that.baz;
}
@Override
public int hashCode() {
return Objects.hash(this.bar, this.baz);
}
}
A Java record has no default mechanism: every component is read as a required value unless it is marked
@Nullable. Use an interface with default methods when a record would need defaults.
Validation¶
A configuration type can additionally be checked by validation constraints. Annotate it with @Valid
and put the constraints on the fields: the generated mapper calls the Validator right after the object is built,
so an invalid configuration fails the application on startup rather than at the first use.
Relaxed key names¶
Configuration keys are matched with relaxed naming. A method name is compared against the key in the file not only in
its exact form, but also in its kebab-case and snake_case variants. This means a method someBarString() resolves
equally from someBarString, some-bar-string, or some_bar_string in the configuration file, so teams that prefer
kebab-case or snake_case keys can keep their style without renaming methods.
All three key spellings below are read into someBarString():
Digits and consecutive capitals are treated as separate parts as well, so someFieldWithCAPSAnd42Numbers() also reads
some-field-with-caps-and-42-numbers and some_field_with_caps_and_42_numbers.
Recommended style¶
It is usually more convenient to describe configuration as a separate type for a concrete integration or subsystem: an HTTP client, an external service connection, a queue handler, and so on. Such a type should clearly separate required values, optional values, and values that come from environment variables.
In the example below:
baseUrlis a required value from the configuration fileclientNameis an optional value from theORDERS_CLIENT_NAMEenvironment variabletokenis a required value from theORDERS_API_TOKENenvironment variablerequestTimeouthas the2sdefault value and can be overridden by the optionalORDERS_REQUEST_TIMEOUTenvironment variable
This keeps the configuration structure readable: required settings are visible in the configuration type, secrets can be passed through environment variables, and safe defaults stay directly in the configuration file.
Injecting configuration¶
You can inject the base class io.koraframework.config.common.Config, which represents the configuration tree and gives
access to values through the get(...) method. The resulting configuration consists of several layers:
- Environment variables
Javasystem properties- Configuration file
Layers are merged so that an earlier layer wins over a later one: an environment variable overrides a system property,
and a system property overrides a value from the configuration file. Environment variables become flat keys named exactly
as the variable (ORDERS_API_TOKEN), while system properties are split on . into a tree, so -Dservices.foo.bar=value
overrides the services.foo.bar configuration key directly.
After merging, Kora resolves the ${...} references across the whole tree. Values that came from environment variables
are never re-resolved, so a $ inside a secret is safe. Resolving values that came from system properties can be turned
off with the KORA_SYSTEM_PROPERTIES_RESOLVE_ENABLED environment variable or the kora.system.properties.resolve.enabled
system property (default: true).
Environment variables¶
If you need to inject configuration that contains only environment variables,
use the @EnvironmentConfig annotation as a tag for the configuration class:
System properties¶
If you need to inject configuration that contains only Java system properties,
use the @SystemPropertiesConfig annotation as a tag for the configuration class:
Configuration file¶
If you need to inject application configuration that consists only of the configuration file,
use the @ApplicationConfig annotation as a tag for the configuration class:
Resulting configuration¶
If you need to inject the complete resulting application configuration, which consists of the configuration file, environment variables and system properties, simply inject the configuration class without a tag:
Reading raw Config values¶
When a raw Config is injected, values are read through the get(...) method, which returns a ConfigValue<?> node
for the requested path. ConfigValue<?> is a sealed type with typed accessors: asString(), asNumber(),
asBoolean(), asObject(), asArray(), and isNull(). If the value has an unexpected type, the accessor throws
ConfigValueException. A missing path never throws by itself — it returns ConfigValue.NullValue.
Array elements are addressed with an index inside the path, for example config.get("services.foo.propObjects[0].p1").
As noted in Recommended style, prefer typed custom configurations over
reading a raw Config.
Use the raw read API only for dynamic or generic access when there is no other choice, and use ValueOf<Config> to avoid refreshing the component on every configuration change.
Attention
We do not recommend using io.koraframework.config.common.Config directly as a dependency in components,
because when configuration is updated, all graph components that use it will be updated as well.
We recommend always creating custom configurations.
Config Watcher¶
By default, Kora has a configuration file watcher that checks the application file for changes and starts dependency
graph refresh if the file changes. The check runs every 1000 milliseconds on a virtual thread.
For HOCON, the watcher also tracks files included through include inside the main configuration file.
If such an included file changes, the configuration is reread and the dependency graph is refreshed as well.
The watcher works only for file-based configuration that has a trackable source. If configuration came from a resource
inside an archive or was built without an application file, there is nothing on disk to update.
Replacing the symlink a configuration file points at counts as a change too, which is what makes mounted secrets and
ConfigMap updates visible without a restart.
You can disable the watcher by using:
- Environment variable
KORA_CONFIG_WATCHER_ENABLED(default:true) - System property
kora.config.watcher.enabled(default:true)
Supported types¶
Configuration mappers provide an extensive list of supported types that covers most values you may need in custom
configurations. If the standard conversion is not enough, the behavior can be extended with a custom
ConfigValueMapper<T> component.
List of supported types
- boolean / Boolean
- int / Integer
- long / Long
- double / Double
- float / Float
- double[]
- String
- BigInteger
- BigDecimal
- Period
- Duration
- Duration[]
- Size
- Properties
- Pattern
- UUID
- LocalDate
- LocalTime
- LocalDateTime
- OffsetTime
- OffsetDateTime
- ConfigValue.ObjectValue
- Enum (any custom
enum; mapping can be overridden throughtoString()) Optional<T>(whereTis any supported type)List<T>(whereTis any supported type)Set<T>(whereTis any supported type)Map<String, V>orMap<K, V>(whereKandVare supported by corresponding mappers)Either<A, B>(whereAandBare any supported types)
List<T> and Set<T> accept both an array and a comma-separated string, so ["v1", "v2"] and "v1, v2" produce the
same value. Map<K, V> is read from an object: keys are taken as strings and passed through the key mapper, values
through the value mapper.
Custom mapper¶
If there is no standard conversion for a type or special parsing logic is required, add a custom
ConfigValueMapper<T> component. The map(...) method receives the configuration value as ConfigValue<?>
and must return the ready value of the required type, or null when the value is absent.
Registered this way, the mapper is used for every configuration field of type Token.
If a specific mapper should be used only for one field, specify it through @Mapping:
Where the mapper instance comes from depends on its declaration. If the class is final (Java) / not open
(Kotlin), has a public no-argument constructor and carries no @Tag, Kora instantiates it itself for that field.
In any other case — constructor dependencies, an open class, or a @Tag next to @Mapping — the mapper is taken from
the dependency graph and therefore has to be registered there. A mapper used without @Mapping, as the mapper for its
type across the whole graph, is always taken from the graph and must be a component.
Duration¶
Duration can be set as a number or a string.
If a number is specified, it is treated as milliseconds.
If a string is specified, the java.time.Duration format is supported, for example PT10S, as well as HOCON style:
500ms10 seconds2 minutes1h1d
Supported unit suffixes are ns / nanos / nanoseconds, us / micros / microseconds, ms / millis / milliseconds,
s / seconds, m / minutes, h / hours, d / days. A value without a suffix is read as milliseconds.
Period¶
Period can be set as a number or a string.
If a number is specified, it is treated as days.
If a string is specified, these units are supported:
d/daysw/weeksm/mo/monthsy/years
For example, 7d, 2 weeks, 3mo, or 1 year. A value without a suffix is read as days.
Size¶
Size is a special type that allows specifying byte sizes in a human-friendly notation: according to the
IEEE 1541-2002 standard (binary) or the
SI standard (decimal).
Example values:
1Mb- 1 megabyte (1.000.000bytes)1Mib- 1 mebibyte (1.048.576bytes)1024b- 1024 bytes1024- 1024 bytes
If just a number without a suffix is specified, it is considered that bytes are specified.
Suffixes are matched case-insensitively and cover b, kb / kib, mb / mib, gb / gib, tb / tib,
pb / pib, eb / eib.
Either¶
Either<A, B> lets a single field accept two alternative shapes. The mapper tries the left type A first, and if
mapping fails with any exception, it falls back to the right type B. This is useful when a value may be either a
plain scalar or a structured object.
Both of these forms are valid for the endpoint field:
Use isLeft() / isRight() to check which side was resolved, and left() / right() to read the value.