Kora облачно ориентированный серверный фреймворк написанный на Java для написания Java / Kotlin приложений с упором на производительность, эффективность, прозрачность сделанный выходцами из Т-Банк / Тинькофф

Kora is a cloud-oriented server-side Java framework for writing Java / Kotlin applications with a focus on performance, efficiency and transparency

Skip to content
V1 V2

YAML Configuration Management with Kora

This guide introduces type-safe configuration with Kora and YAML. It covers how configuration values are mapped from application.yaml into typed interfaces, how required and defaulted values are represented in Java and Kotlin code, and how one reusable config shape can be bound to several sections without duplicating the whole block. You will also see how environment variables and printed runtime output make resolved configuration easy to inspect.

If you want to check your progress along the way, use the finished working example: Kora Java Config YAML App.

If you want to check your progress along the way, use the finished working example: Kora Kotlin Config YAML App.

What You'll Build

You'll build a small runnable Kora application that:

  • binds app.name, app.version, and app.environment through @ConfigSource
  • treats APP_VERSION as required and APP_NAME as an environment override with a default
  • defines one reusable LibConfig with endpoint and requestTimeout
  • maps that same LibConfig for lib1 and lib2
  • reuses one shared YAML section and overrides only one field for the second library
  • prints all resolved values to stdout during startup

What You'll Need

Kora 2.0 artifacts are compiled for Java 25, so the JDK that compiles the application must be 25 or newer.

Prerequisites

Required: Complete Getting Started

This guide assumes you have completed Creating Your First Kora App and already have a runnable Kora project with the application plugin and a generated application graph.

If you haven't created that baseline yet, complete the getting started guide first, because this guide focuses on typed configuration rather than initial project setup.

Overview

Configuration is how runtime environments influence application behavior without changing code. Ports, credentials, feature switches, timeouts, and external service addresses should live outside compiled classes, but application code still needs a type-safe way to read them.

The main lesson is that configuration should be explicit at the application boundary. Components should not search environment variables or parse files by themselves; they should receive typed configuration from the graph.

YAML and Type-Safe Mapping

Kora can read YAML configuration through SnakeYAML and map it into Java or Kotlin interfaces. Instead of passing raw strings and maps through the application, components receive typed configuration objects. This makes required values explicit and lets the compiler help with configuration usage.

This guide uses two complementary mapping styles:

  • @ConfigSource("app") maps one fixed config section to a type-safe dependency
  • @ConfigMapper maps a reusable config shape that can be bound to different paths

Use @ConfigSource when a component needs one stable section of application configuration. Use @ConfigMapper when the same structure appears in several places and you want one reusable mapping rule whose path is chosen in a module factory.

Both annotations generate a ConfigValueMapper<T> implementation at compile time. The difference is only who picks the path: @ConfigSource bakes it into a generated module, while @ConfigMapper leaves it to you. The mapping layer itself is format-independent, so everything you learn here applies unchanged to HOCON.

Required and Default Values

YAML has no substitution syntax of its own, so references are resolved by Kora after all configuration layers are merged. Three forms are supported:

  • ${path} — required: an unresolved reference fails the application 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 to defaultValue

The same forms work for environment variables and for references to other configuration keys, because environment variables and system properties are configuration layers themselves. These features let one configuration file stay readable while still adapting to local development, tests, and deployed environments.

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.

On the code side the rule is just as short: every method of a config interface is a required value unless you mark it nullable or give it a default implementation. Like the protobuf contract in gRPC or the cache contract in caching, a config type is a boundary contract. It says which runtime values the application expects and what shape those values must have.

Configuration as a Graph Dependency

In Kora, configuration is part of the dependency graph. A component can request a typed config object in its constructor just like it requests a repository or client. That makes configuration dependencies visible and testable. It also keeps configuration parsing at the boundary of the graph instead of scattered across application code.

The practical flow is:

  1. add the YAML configuration module
  2. define a fixed application config source
  3. bind required and defaulted values
  4. define a reusable config mapper
  5. reuse one config shape for multiple library settings
  6. run the app and inspect resolved configuration

Dependencies

Add the YAML module to your existing project and keep logging enabled so startup behavior is visible while you learn.

Update build.gradle:

build.gradle
plugins {
    id "application"
}

dependencies {
    implementation "io.koraframework:config-yaml"
    implementation "io.koraframework:logging-logback"
}

Update build.gradle.kts:

build.gradle.kts
plugins {
    id("application")
}

dependencies {
    implementation("io.koraframework:config-yaml")
    implementation("io.koraframework:logging-logback")
}

Why this matters:

  • config-yaml enables YAML file loading in the application graph
  • logging-logback keeps startup and troubleshooting visible while the app runs

Versions come from the io.koraframework:kora-bom platform the project already imports, so no explicit version is needed here. Use either config-yaml or config-hocon, not both: each one supplies the application configuration for the graph.

Modules

Start with the smallest possible application graph that can load YAML config and run a Kora application.

At this point we are not adding application-specific configuration yet. We are only preparing the graph so later steps can bind typed config and print resolved values.

Create src/main/java/io/koraframework/guide/config/yaml/Application.java:

package io.koraframework.guide.config.yaml;

import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.yaml.YamlConfigModule;
import io.koraframework.logging.logback.LogbackModule;

@KoraApp
public interface Application extends
        YamlConfigModule,  // <----- Connected module
        LogbackModule {

    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}

Create src/main/kotlin/io/koraframework/guide/config/yaml/Application.kt:

package io.koraframework.guide.config.yaml

import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.yaml.YamlConfigModule
import io.koraframework.logging.logback.LogbackModule

@KoraApp
interface Application :
    YamlConfigModule,  // <----- Connected module
    LogbackModule

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}

Why this matters:

  • YamlConfigModule activates YAML-based configuration loading
  • LogbackModule adds basic startup and troubleshooting logs
  • the graph stays minimal for now: it can start the application and read the config file

YamlConfigModule also decides which file to read. With no system property set it looks for application.yaml on the classpath and merges every reference.yaml found there underneath it; config.resource and config.file override the application file, and we use the first of them at the end of this guide.

Typed sections are introduced gradually: first the application section, then a reusable library shape, and only after that the explicit mapping from libs.lib1 and libs.lib2 to two instances of the same type.

If you want more background on graph wiring and factories, see the Container documentation.

Application Configuration

Now introduce the first typed config contract: a stable application section named app.

This is the simplest and most common config pattern in Kora. Instead of reading keys manually, you declare the shape once and inject it wherever it is needed.

Create src/main/java/io/koraframework/guide/config/yaml/AppConfig.java:

package io.koraframework.guide.config.yaml;

import io.koraframework.config.common.annotation.ConfigSource;

@ConfigSource("app")
public interface AppConfig {

    String name();

    String version();

    String environment();
}

Create src/main/kotlin/io/koraframework/guide/config/yaml/AppConfig.kt:

package io.koraframework.guide.config.yaml

import io.koraframework.config.common.annotation.ConfigSource

@ConfigSource("app")
interface AppConfig {
    fun name(): String
    fun version(): String
    fun environment(): String
}

Why this matters:

  • @ConfigSource("app") makes the app section a first-class dependency
  • the contract stays close to the code that consumes it
  • refactoring config keys becomes safer because the structure is explicit in one place

All three methods return non-nullable types, so all three values are required. Making one of them optional is a code change, not a file change: mark it @Nullable in Java or return a nullable type in Kotlin. Method names are matched leniently, so someBarString() also reads some-bar-string and some_bar_string from the file.

Required Values

With AppConfig defined, we can now decide which values are mandatory and which can fall back to defaults.

Update src/main/resources/application.yaml:

src/main/resources/application.yaml
app:
  name: ${APP_NAME:Task Management App}
  version: ${APP_VERSION}
  environment: "development"

What this means:

  • version: ${APP_VERSION} is required, so startup fails if APP_VERSION is missing
  • name: ${APP_NAME:Task Management App} uses APP_NAME when it exists and otherwise falls back to the default value
  • environment stays a normal static value because this guide does not need to vary it yet

This is an important YAML pattern: make critical values fail fast, but keep cosmetic or environment-specific values easy to override.

Unlike HOCON, YAML does not express a default by assigning the same key twice — the second mapping entry would simply replace the first. The default belongs inside the substitution, which is why the ${VAR:default} form exists.

For more on substitution rules and supported value types, see the Configuration documentation.

Library Configuration

Next, create a reusable config shape for one library.

Imagine that an abstract library needs two settings:

  • endpoint
  • requestTimeout

Instead of keeping those as raw keys, define them once as a type.

Create src/main/java/io/koraframework/guide/config/yaml/LibConfig.java:

package io.koraframework.guide.config.yaml;

import java.time.Duration;
import io.koraframework.config.common.annotation.ConfigMapper;

@ConfigMapper
public interface LibConfig {

    String endpoint();

    Duration requestTimeout();
}

Create src/main/kotlin/io/koraframework/guide/config/yaml/LibConfig.kt:

package io.koraframework.guide.config.yaml

import io.koraframework.config.common.annotation.ConfigMapper
import java.time.Duration

@ConfigMapper
interface LibConfig {
    fun endpoint(): String
    fun requestTimeout(): Duration
}

Now that LibConfig exists, return to the application graph and show explicitly where the two library configs come from.

@ConfigMapper generates a ConfigValueMapper<LibConfig> for the shape but binds no path, and the graph methods choose concrete branches of the config file. This gives Kora two different instances of the same type: one for libs.lib1 and one for libs.lib2.

Update src/main/java/io/koraframework/guide/config/yaml/Application.java:

package io.koraframework.guide.config.yaml;

import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.common.annotation.Tag;
import io.koraframework.config.common.Config;
import io.koraframework.config.common.mapper.ConfigValueMapper;
import io.koraframework.config.yaml.YamlConfigModule;
import io.koraframework.logging.logback.LogbackModule;

@KoraApp
public interface Application extends
        YamlConfigModule,  // <----- Connected module
        LogbackModule {

    final class Lib1Tag {}

    final class Lib2Tag {}

    @Tag(Lib1Tag.class)
    default LibConfig lib1Config(Config config, ConfigValueMapper<LibConfig> mapper) {
        return mapper.mapOrThrow(config.get("libs.lib1"));
    }

    @Tag(Lib2Tag.class)
    default LibConfig lib2Config(Config config, ConfigValueMapper<LibConfig> mapper) {
        return mapper.mapOrThrow(config.get("libs.lib2"));
    }

    static void main(String[] args) {
        KoraApplication.run(ApplicationGraph::graph);
    }
}

Update src/main/kotlin/io/koraframework/guide/config/yaml/Application.kt:

package io.koraframework.guide.config.yaml

import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.common.annotation.Tag
import io.koraframework.config.common.Config
import io.koraframework.config.common.mapper.ConfigValueMapper
import io.koraframework.config.yaml.YamlConfigModule
import io.koraframework.logging.logback.LogbackModule

@KoraApp
interface Application :
    YamlConfigModule,  // <----- Connected module
    LogbackModule {

    class Lib1Tag private constructor()
    class Lib2Tag private constructor()

    @Tag(Lib1Tag::class)
    fun lib1Config(config: Config, mapper: ConfigValueMapper<LibConfig>): LibConfig {
        return mapper.mapOrThrow(config.get("libs.lib1"))
    }

    @Tag(Lib2Tag::class)
    fun lib2Config(config: Config, mapper: ConfigValueMapper<LibConfig>): LibConfig {
        return mapper.mapOrThrow(config.get("libs.lib2"))
    }
}

fun main() {
    KoraApplication.run(ApplicationGraph::graph)
}

What happens here:

  • Lib1Tag and Lib2Tag distinguish two LibConfig instances in the graph
  • config.get("libs.lib1") and config.get("libs.lib2") select different config branches
  • ConfigValueMapper<LibConfig> converts each branch into a typed object

ConfigValueMapper<T> offers two reading methods. map(...) may return null, while mapOrThrow(...) turns that null into a ConfigValueException. Factory methods normally use mapOrThrow(...), because a missing library section is a startup error rather than a valid state.

Both factories are now part of the graph, so both sections have to exist. Add them to application.yaml:

src/main/resources/application.yaml
app:
  name: ${APP_NAME:Task Management App}
  version: ${APP_VERSION}
  environment: "development"

libs:
  lib1:
    endpoint: "https://integration.local/api"
    requestTimeout: "5s"
  lib2:
    endpoint: "https://integration-2.local/api"
    requestTimeout: "5s"

At this stage the application starts, and Kora converts "5s" directly into Duration. But the two sections are almost identical, and that duplication is what the next step removes.

Configuration file

Both libraries need exactly the same shape, and right now the shared values are copied twice.

YAML has no object-reuse operator of its own, but Kora substitutions work between configuration keys, so shared values can live in one section and be referenced from the others.

Update application.yaml again:

src/main/resources/application.yaml
app:
  name: ${APP_NAME:Task Management App}
  version: ${APP_VERSION}
  environment: "development"

commonLib:
  endpoint: "https://integration.local/api"
  requestTimeout: "5s"

libs:
  lib1:
    endpoint: ${commonLib.endpoint}
    requestTimeout: ${commonLib.requestTimeout}
  lib2:
    endpoint: "https://integration-2.local/api"
    requestTimeout: ${commonLib.requestTimeout}

What changed:

  • commonLib stores shared scalar values once
  • libs.lib1 references both shared values
  • libs.lib2 overrides only endpoint
  • libs.lib2.requestTimeout still reuses the shared timeout

References are resolved after all layers are merged, so the order of sections in the file does not matter, and a reference may point at a key that an environment variable ultimately supplies.

This is the payoff of combining YAML references with @ConfigMapper: one config shape, multiple mapped instances, minimal duplication.

Resolved Values

The last step is to prove that everything was injected correctly.

Instead of adding an HTTP endpoint, this guide uses a small @Root component that prints all resolved values to standard output during startup. This mirrors the console-style validation used in the dependency injection guide.

Create src/main/java/io/koraframework/guide/config/yaml/ConfigRunner.java:

package io.koraframework.guide.config.yaml;

import java.util.LinkedHashMap;
import java.util.Map;
import io.koraframework.application.graph.Lifecycle;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Root;
import io.koraframework.common.annotation.Tag;

@Root
@Component
public final class ConfigRunner implements Lifecycle {

    private final AppConfig appConfig;
    private final LibConfig lib1Config;
    private final LibConfig lib2Config;

    public ConfigRunner(
        AppConfig appConfig,
        @Tag(Application.Lib1Tag.class) LibConfig lib1Config,
        @Tag(Application.Lib2Tag.class) LibConfig lib2Config
    ) {
        this.appConfig = appConfig;
        this.lib1Config = lib1Config;
        this.lib2Config = lib2Config;
    }

    public Map<String, String> snapshot() {
        Map<String, String> values = new LinkedHashMap<>();
        values.put("name", this.appConfig.name());
        values.put("version", this.appConfig.version());
        values.put("environment", this.appConfig.environment());
        values.put("lib1.endpoint", this.lib1Config.endpoint());
        values.put("lib1.requestTimeout", this.lib1Config.requestTimeout().toString());
        values.put("lib2.endpoint", this.lib2Config.endpoint());
        values.put("lib2.requestTimeout", this.lib2Config.requestTimeout().toString());
        return values;
    }

    @Override
    public void init() {
        System.out.println("Config guide start");
        this.snapshot().forEach((key, value) -> System.out.println(key + "=" + value));
    }

    @Override
    public void release() {
        System.out.println("Application shutdown");
    }
}

Create src/main/kotlin/io/koraframework/guide/config/yaml/ConfigRunner.kt:

package io.koraframework.guide.config.yaml

import io.koraframework.application.graph.Lifecycle
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Root
import io.koraframework.common.annotation.Tag

@Root
@Component
class ConfigRunner(
    private val appConfig: AppConfig,
    @Tag(Application.Lib1Tag::class) private val lib1Config: LibConfig,
    @Tag(Application.Lib2Tag::class) private val lib2Config: LibConfig,
) : Lifecycle {

    fun snapshot(): Map<String, String> {
        return linkedMapOf(
            "name" to appConfig.name(),
            "version" to appConfig.version(),
            "environment" to appConfig.environment(),
            "lib1.endpoint" to lib1Config.endpoint(),
            "lib1.requestTimeout" to lib1Config.requestTimeout().toString(),
            "lib2.endpoint" to lib2Config.endpoint(),
            "lib2.requestTimeout" to lib2Config.requestTimeout().toString(),
        )
    }

    override fun init() {
        println("Config guide start")
        snapshot().forEach { (key, value) -> println("$key=$value") }
    }

    override fun release() {
        println("Application shutdown")
    }
}

Why this matters:

  • @Root ensures the runner is actually created when the application starts
  • Lifecycle gives you a natural place to print or validate injected values
  • snapshot() keeps the runtime output and the tests aligned around one contract

The same @Tag markers that disambiguated the two factories now select which LibConfig each constructor parameter receives. Without them the graph could not tell the two components apart.

Generated Configuration Code

Like the rest of Kora, configuration mapping is generated at compile time. After ./gradlew clean classes, look at what the processor produced:

guides/java/kora-java-guide-config-yaml-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/config/yaml/AppConfigModule.java
guides/java/kora-java-guide-config-yaml-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/config/yaml/$AppConfig_ConfigValueMapper.java
guides/java/kora-java-guide-config-yaml-app/build/generated/sources/annotationProcessor/java/main/io/koraframework/guide/config/yaml/$LibConfig_ConfigValueMapper.java
guides/kotlin/kora-kotlin-guide-config-yaml-app/build/generated/ksp/main/kotlin/io/koraframework/guide/config/yaml/AppConfigModule.kt
guides/kotlin/kora-kotlin-guide-config-yaml-app/build/generated/ksp/main/kotlin/io/koraframework/guide/config/yaml/$AppConfig_ConfigValueMapper.kt
guides/kotlin/kora-kotlin-guide-config-yaml-app/build/generated/ksp/main/kotlin/io/koraframework/guide/config/yaml/$LibConfig_ConfigValueMapper.kt

@ConfigSource produced an entire module, and it looks exactly like the factories you wrote by hand for LibConfig:

@Module
public interface AppConfigModule {
  default AppConfig appConfig(Config config, ConfigValueMapper<AppConfig> mapper) {
    return mapper.mapOrThrow(config.get("app"));
  }
}
@Module
public interface AppConfigModule {
  public fun appConfig(config: Config, mapper: ConfigValueMapper<AppConfig>): AppConfig = mapper.mapOrThrow(config.get("app"))
}

That is the whole difference between the two annotations: @ConfigSource writes this module for you at a fixed path, @ConfigMapper does not, which is why you wrote two tagged factories instead.

The mapper itself shows where required values are enforced:

private String parse_endpoint(ConfigValue.ObjectValue config) {
  var value = config.get(_endpoint_path);
  if (value instanceof ConfigValue.NullValue nullValue) {
    throw ConfigValueException.missingValue(nullValue);
  }
  return value.asString();
}
private fun parse_endpoint(config: ConfigValue.ObjectValue): String {
  val value = config.get(_endpoint_path)
  if (value is ConfigValue.NullValue) {
    throw ConfigValueException.missingValue(value)
  }
  return value.asString()
}

Nothing in this generated code mentions YAML. The same mapper would be produced for a HOCON project, because the format is parsed into one common configuration tree before mapping starts.

requestTimeout is handled differently: instead of a hand-written branch, the generated mapper takes a ConfigValueMapper<Duration> as a constructor dependency. Every supported value type reaches the mapper the same way, which is how a custom type can be added later without touching the config interface.

Run Application

Use the standard guide flow:

./gradlew clean classes
./gradlew test

app.version is required, so APP_VERSION must be present before the application starts:

APP_VERSION=1.0.0 ./gradlew run

In the runnable Java sample, the run task already injects APP_VERSION from koraVersion in gradle.properties, so a plain ./gradlew run works there out of the box.

If you want to override the application name too, add APP_NAME before startup:

APP_VERSION=1.0.0 APP_NAME="Custom Task App" ./gradlew run

Application Output

When the application starts, it prints:

Config guide start
name=Task Management App
version=1.0.0
environment=development
lib1.endpoint=https://integration.local/api
lib1.requestTimeout=PT5S
lib2.endpoint=https://integration-2.local/api
lib2.requestTimeout=PT5S

PT5S is the ISO-8601 form of Duration.ofSeconds(5), which confirms that "5s" was mapped to a real Duration and not to a string. If you provide APP_NAME, the printed name= line reflects the override:

name=Custom Task App

Prod configuration

A common next step is to keep separate config files for different environments such as development, staging, or production.

For example, create src/main/resources/application-prod.yaml:

src/main/resources/application-prod.yaml
app:
  name: ${APP_NAME:Task Management App}
  version: ${APP_VERSION}
  environment: "production"

commonLib:
  endpoint: "https://integration.local/api"
  requestTimeout: "5s"

libs:
  lib1:
    endpoint: ${commonLib.endpoint}
    requestTimeout: ${commonLib.requestTimeout}
  lib2:
    endpoint: "https://integration-2.local/api"
    requestTimeout: ${commonLib.requestTimeout}

YAML has no include directive, so an alternative application file replaces application.yaml rather than layering on top of it. That is why this file repeats the whole shape and changes only the environment value: whichever file is selected must be complete enough to start the app on its own.

Select the alternative file with the config.resource system property. The Gradle application plugin does not forward -D flags from the command line to the application process, so declare it on the run task:

build.gradle
run {
    jvmArgs += [
            "-Dconfig.resource=application-prod.yaml"
    ]
}
build.gradle.kts
tasks.named<JavaExec>("run") {
    jvmArgs("-Dconfig.resource=application-prod.yaml")
}

A built distribution takes the same property through JAVA_OPTS:

JAVA_OPTS="-Dconfig.resource=application-prod.yaml" ./bin/application

With that override in place, the startup output prints:

environment=production

Use config.file instead of config.resource to read a file from disk rather than from the classpath. Only one of the two may be set: if both are, the application fails at startup with Application config source is ambiguous.

Values that are genuinely shared by every environment belong in reference.yaml instead, which is merged underneath whichever application file is selected. Each reference.yaml must resolve on its own, so give its references a literal default or make them optional.

For more on file resolution and external config files, see the Configuration documentation.

Best Practices

  • Use @ConfigSource for stable application-level config that belongs to one well-known section.
  • Use @ConfigMapper when the same config shape is reused under multiple paths, and pick the path in a module factory.
  • Prefer mapOrThrow(...) over map(...) in factories, so a missing section fails at startup instead of producing null.
  • Keep required values explicit with ${VAR_NAME} and defaults explicit with ${VAR_NAME:default}.
  • Never write ${?VAR_NAME:default}; the two forms cannot be combined.
  • Prefer shared YAML sections plus Kora substitutions over copying the same scalar values across several sections.
  • Keep startup diagnostics simple while exploring configuration behavior; System.out.println(...) is enough for learning flows.

Summary

You now have a working YAML-based Kora application that binds configuration in two ways. AppConfig maps a stable app section, while LibConfig is mapped twice from two different paths with different tags. YAML references keep the file compact, and one override changes only the second library endpoint.

Key Concepts

@ConfigSource:

  • maps one fixed config section to a type-safe interface
  • generates a module that calls mapOrThrow(config.get("app")) for you
  • works well for application settings like app.name and app.environment

Required vs Defaulted Values:

  • every interface method is required unless it is nullable or has a default implementation
  • ${APP_VERSION} is required and fails fast when missing
  • ${APP_NAME:Task Management App} uses an environment value when present and otherwise falls back to the configured default
  • ${?APP_NAME} yields no value at all when the variable is missing, and cannot carry a default

@ConfigMapper and Reuse:

  • generates a ConfigValueMapper<T> without binding a path
  • one config shape can be mapped from multiple paths, disambiguated with @Tag
  • a shared section such as commonLib can hold scalar defaults once
  • substitutions such as ${commonLib.requestTimeout} reuse those scalar defaults in multiple typed config sections

Troubleshooting

Application fails at startup with an unresolved substitution:

app.version: ${APP_VERSION} is mandatory. In the runnable Java sample, run provides it automatically from koraVersion. Otherwise you must set APP_VERSION before startup.

Startup fails with Config expected value, but got null at path: '...':

A required value is missing from the resulting configuration. Either add the key to the file, or make the method optional with @Nullable in Java or a nullable return type in Kotlin, or give it a default implementation.

APP_NAME does not change the default name:

Use the defaulted substitution form:

name: ${APP_NAME:Task Management App}

A ${?APP_NAME} on its own leaves the key absent instead of falling back, and ${?APP_NAME:Task Management App} is read as one long reference name and resolves to nothing.

Library config values are duplicated across sections:

Move the shared scalars into one section such as commonLib and reference them with ${commonLib.endpoint} instead of copying the same literals into both libraries.

Reference config ... cannot be resolved without external application config:

A reference.yaml contains a reference that only the application file can satisfy. Give the key a literal default, make the reference optional with ${?path}, or use ${path:defaultValue}.

Application config source is ambiguous:

Both config.resource and config.file are set. Remove one of the two system properties.

Build hangs or fails unexpectedly:

Stop Gradle daemons and retry:

./gradlew --stop
./gradlew clean classes

AccessDeniedException in the Gradle cache on Windows:

If cached files are locked by another process, retry with a fresh session cache:

GRADLE_USER_HOME=.gradle-user-home ./gradlew test

What's Next?

Help

If you encounter issues: