Building Kora Applications with Dependency Injection¶
This guide introduces practical application assembly with Kora's compile-time dependency injection. It covers how @KoraApp, @Module, and @Component describe a dependency graph, how interfaces
and implementations are bound into that graph, and how lifecycle-aware services are started and stopped by the container. You will also see how module boundaries keep a complete application
understandable as it grows.
If you want to check your progress along the way, use the finished working example: Kora Java Dependency Injection App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin Dependency Injection App.
What You'll Build¶
You'll build a complete notification system application that demonstrates all major Kora dependency injection features:
- Multi-module project structure with proper separation of concerns
- Component-based architecture with external library modules
- Tagged dependencies for multiple implementations of the same interface
- Collection injection to inject all implementations at once
- Submodules for organizing related components across Gradle modules
- Generic factories for type-safe component creation
- Factory modules for module instances that are themselves graph components
- Nullable dependencies for graceful handling of missing components
- ValueOf
pattern to prevent cascading component refreshes
What You'll Need¶
- JDK 25 or later
- Gradle 9+
- A text editor or IDE
- Basic understanding of Java or Kotlin
- Familiarity with dependency injection concepts (see Dependency Injection with Kora)
Prerequisites¶
Recommended: Read the DI Introduction First
This tutorial assumes you have read Dependency Injection with Kora and understand the basic dependency injection concepts used by Kora.
If you haven't read the introduction yet, do that first, because this guide moves quickly into a complete multi-module application and focuses on applying DI patterns rather than defining them from scratch.
You also need basic Java or Kotlin familiarity.
This tutorial builds a complete Kora application from scratch, introducing dependency injection concepts progressively. Each step adds new functionality while demonstrating a specific DI pattern. By the end, you'll have a fully functional application showcasing all major Kora DI features.
Overview¶
This guide moves from DI concepts to practical application assembly. The sample domain is a notification system, but the important topic is how a real Kora graph stays understandable when it has multiple modules, implementations, optional dependencies, and lifecycle concerns.
The guide keeps one domain model while adding more graph features around it. That mirrors production work: you rarely learn DI features in isolation; you use them because an application needs module boundaries, overrides, multiple implementations, or resource lifecycle control.
Application Graph¶
A Kora application graph is more than a list of classes. It is a typed structure that describes which components exist, which dependencies each component needs, and
how those components are created. @KoraApp is the graph root, @Module groups factories and imports, and @Component classes become managed graph nodes.
All of these annotations live in one package, io.koraframework.common.annotation, and all of them are read at compile time only. Nothing in this guide is resolved by reflection at startup: the
annotation processor (Java) or the symbol processor (Kotlin) reads the annotations and writes an ApplicationGraph class next to your Application type.
Good graph design keeps responsibilities visible:
- application modules describe the application's own components
- library modules expose reusable defaults
- interfaces define replacement points
- factories create values that need custom construction
Component Setup¶
Real applications often need more than one implementation of an interface. Tags let Kora distinguish dependencies that share the same Java type but have different roles. Overrides let an application replace a library default with project-specific behavior. Optional dependencies let a component adapt when another component is not present.
These features are powerful because they solve wiring problems without hiding them. The dependency graph still shows which implementation is used and why.
Lifecycle¶
Some components own resources: clients, schedulers, connections, or background workers. Kora can manage lifecycle-aware components so startup and shutdown happen in graph order. The Lifecycle
contract for that lives in io.koraframework.application.graph and declares exactly two methods, init() and release(). The guide also introduces ValueOf<T> as a way to depend on a component
reference without eagerly forcing all downstream refresh behavior.
By the end of this guide, the notification app should feel like a working example of graph design: module boundaries, external defaults, overrides, tags, optional dependencies, generic factories, and lifecycle control all serve one application instead of appearing as isolated features.
The practical flow is:
- create a multi-module Kora project
- import external module defaults
- override selected components
- use tags for multiple implementations of one type
- model optional dependencies
- organize related components with submodules
- add generic factories and lifecycle-aware behavior
Dependencies¶
This guide uses a dedicated settings.gradle at the top level and keeps the shared Gradle configuration inside guide-dependency-injection/build.gradle. In the reference repository there is one
additional level above this tutorial directory because multiple guide applications live in the same workspace.
Create the project directories:
mkdir -p guide-dependency-injection
mkdir -p guide-dependency-injection/guide-dependency-injection-common guide-dependency-injection/guide-dependency-injection-lib guide-dependency-injection/guide-dependency-injection-app
Kora modules are published for Java 25, and the reference applications pin a Java 25 toolchain, so install Eclipse Temurin JDK 25 and run Gradle on it.
On Ubuntu/Debian, add the Adoptium repository and install Temurin JDK:
sudo apt update
sudo apt install -y wget gpg
wget -O - https://packages.adoptium.net/artifactory/api/gpg/key/public | sudo gpg --dearmor -o /usr/share/keyrings/adoptium.gpg
echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb $(. /etc/os-release && echo $VERSION_CODENAME) main" | sudo tee /etc/apt/sources.list.d/adoptium.list
sudo apt update
sudo apt install -y temurin-25-jdk
If Homebrew is installed, install Temurin JDK through cask:
If winget is installed, install Temurin JDK from PowerShell:
If winget is not available, download the Windows installer from the Eclipse Temurin downloads page, choose JDK 25 for your CPU
architecture, run the installer, and enable the option that updates JAVA_HOME and PATH when it is offered.
Open a new terminal after installation so environment variables are refreshed.
Check that the JDK is available:
The output should show Java 25.
Prepare Gradle Wrapper in the same directory. This guide creates the multi-module project manually, so there is no gradle init step that would generate wrapper files for you.
Step 1. Create gradle-wrapper.properties.
mkdir -p gradle/wrapper
cat > gradle/wrapper/gradle-wrapper.properties << 'EOF'
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
EOF
mkdir -p gradle/wrapper
cat > gradle/wrapper/gradle-wrapper.properties << 'EOF'
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
EOF
New-Item -ItemType Directory -Force gradle/wrapper
@'
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
'@ | Set-Content -Encoding UTF8 gradle/wrapper/gradle-wrapper.properties
Step 2. Download gradle-wrapper.jar.
Step 3. Download the wrapper launcher script.
Project Setup¶
Now set up the multi-module Gradle configuration. This guide is not a single-module application: it demonstrates how Kora builds an application graph from several modules, so the project layout is part of the lesson.
Gradle has to do several things here:
- register the tutorial submodules
- configure the JDK used to compile every submodule
- make the Kora BOM versions available to the required Gradle configurations
- enable Kora code generation in every module that declares graph elements
- apply common compile and test rules
Module Structure¶
Create the following directory structure. The file extensions differ between Gradle Groovy DSL and Gradle Kotlin DSL, but the module boundaries stay the same:
guide-dependency-injection-common holds shared contracts, guide-dependency-injection-lib emulates a reusable library, and guide-dependency-injection-app contains the runnable application with
@KoraApp. A fourth module, guide-dependency-injection-submodule, is added later when the guide reaches @KoraSubmodule. This separation is what lets later steps demonstrate overrides, tags,
optional dependencies, and cross-module graph discovery.
Root Settings¶
Edit the top-level Gradle settings file. It names the Gradle build and tells Gradle which submodules belong to it:
plugins {
id "org.gradle.toolchains.foojay-resolver-convention" version "1.0.0"
}
rootProject.name = "kora-guide"
include "guide-dependency-injection:guide-dependency-injection-common"
include "guide-dependency-injection:guide-dependency-injection-lib"
include "guide-dependency-injection:guide-dependency-injection-app"
pluginManagement {
plugins {
id("org.jetbrains.kotlin.jvm") version "2.4.10" //(1)!
id("com.google.devtools.ksp") version "2.3.11" //(2)!
}
}
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
}
rootProject.name = "kora-guide"
include("guide-dependency-injection:guide-dependency-injection-common")
include("guide-dependency-injection:guide-dependency-injection-lib")
include("guide-dependency-injection:guide-dependency-injection-app")
- Kotlin JVM plugin version, declared once for the whole build so module build files can apply the plugin without repeating the version.
- KSP plugin version. It is tied to the Kotlin version, so the two are always raised together.
The foojay-resolver-convention plugin supports Java toolchains: it helps Gradle find or download the requested JDK. The include lines register nested modules through Gradle paths, such as
:guide-dependency-injection:guide-dependency-injection-app, so Gradle can run tasks for a specific module.
Gradle Properties¶
Add gradle.properties so Gradle can detect installed JDKs, download the required Temurin toolchain when JDK 25 is not available locally, and share the Kora and JUnit versions across all modules:
The first two properties make the tutorial build less dependent on the local machine. koraVersion and junitVersion are ordinary Gradle project properties: every module build file reads them as
$koraVersion and $junitVersion, so a version bump happens in exactly one place. The Kotlin-specific validation flag mirrors the reference applications: when the Kotlin compiler cannot target the
toolchain JVM version exactly, it reports the fallback as a warning instead of failing the build.
Shared Build File¶
Create a shared build file under guide-dependency-injection/. It applies to the nested modules common, lib, app, and later submodule, so the toolchain, repositories, and test setup do not
have to be duplicated in every module.
Start with imports and an empty subprojects block:
mavenCentral() is where Kora, Logback, HOCON, and their transitive dependencies are downloaded from.
Kora BOM¶
Kora is split into many modules. Instead of writing a version on every dependency, import a BOM (Bill of Materials) named io.koraframework:kora-bom. It aligns the versions of all Kora modules and
of the third-party libraries Kora ships with. Java and Kotlin wire that BOM in differently, and the difference is worth understanding before writing the rest of the build file.
In Java the BOM goes into a dedicated koraBom configuration declared once in subprojects {}. Nothing resolves it yet; the next sections make the real configurations extend it:
In Kotlin there is no shared BOM configuration. Each module imports the platform straight into implementation, which testImplementation already extends:
The ksp configuration does not extend implementation, so the Kora symbol processor is the one dependency that always carries an explicit version.
JDK Toolchain¶
Configure the JDK after the java plugin is enabled in a submodule. Gradle may run on one JDK while compiling the project with another, so the toolchain makes the tutorial reproducible. Kora modules
are compiled for Java 25, so the toolchain must be Java 25 or newer.
subprojects {
plugins.withId("org.jetbrains.kotlin.jvm") {
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension>("kotlin") {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(25))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
}
}
plugins.withId("java") {
extensions.configure<JavaPluginExtension>("java") {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
}
}
}
Kotlin needs both blocks: jvmToolchain drives the Kotlin compiler, and the java toolchain drives javac for the Java sources KSP and Gradle still compile in the same module.
Classpath Configurations¶
Kora code generation runs on its own classpath, separate from the application classpath. In Java that is annotationProcessor; in Kotlin it is the ksp configuration added by the KSP plugin. Both
need the aligned Kora versions.
Make the BOM available to the configurations used by application code, compile-time APIs, annotation processing, public library APIs, and tests:
subprojects {
plugins.withId("java") {
configurations.annotationProcessor.extendsFrom(configurations.koraBom)
configurations.compileOnly.extendsFrom(configurations.koraBom)
configurations.implementation.extendsFrom(configurations.koraBom)
configurations.testImplementation.extendsFrom(configurations.koraBom)
configurations.testAnnotationProcessor.extendsFrom(configurations.koraBom)
}
plugins.withId("java-library") {
configurations.api.extendsFrom(configurations.koraBom)
}
}
annotationProcessor and testAnnotationProcessor receive the BOM separately because Kora annotation processors are resolved on their own classpath. The api configuration matters for common
and lib, where types become part of the public API consumed by other modules.
Kotlin does not need a shared extendsFrom block. Every module that declares graph elements applies the KSP plugin and declares the processor with an explicit version:
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp")
id("java-library")
}
dependencies {
implementation(platform("io.koraframework:kora-bom:$koraVersion"))
ksp("io.koraframework:symbol-processors:$koraVersion")
}
kotlin {
sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
}
The build/generated/ksp/main/kotlin source directory matters for IDEs and for compilation, because KSP writes Kora-generated Kotlin code there. Modules that also generate code for test sources
add sourceSets.test { kotlin.srcDir("build/generated/ksp/test/kotlin") }.
Kora Version¶
Now import the BOM itself. The $koraVersion variable comes from gradle.properties; after this line, individual modules can declare Kora dependencies without explicit versions.
Because implementation, annotationProcessor, compileOnly, testImplementation, testAnnotationProcessor, and api all extend koraBom, a single line covers every module.
Final File¶
The final shared build file contains the same decisions together: repositories, the JDK toolchain, classpath wiring, the Kora BOM, and common test behavior.
import org.gradle.jvm.toolchain.JavaLanguageVersion
import org.gradle.jvm.toolchain.JvmVendorSpec
subprojects {
repositories {
mavenCentral()
}
configurations {
koraBom
}
plugins.withId("java") {
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
vendor = JvmVendorSpec.ADOPTIUM
}
}
configurations.annotationProcessor.extendsFrom(configurations.koraBom)
configurations.compileOnly.extendsFrom(configurations.koraBom)
configurations.implementation.extendsFrom(configurations.koraBom)
configurations.testImplementation.extendsFrom(configurations.koraBom)
configurations.testAnnotationProcessor.extendsFrom(configurations.koraBom)
}
plugins.withId("java-library") {
configurations.api.extendsFrom(configurations.koraBom)
}
dependencies {
koraBom platform("io.koraframework:kora-bom:$koraVersion")
}
tasks.withType(JavaCompile).configureEach {
options.encoding = "UTF-8"
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
testLogging {
showStandardStreams(true)
events("passed", "skipped", "failed")
exceptionFormat("full")
}
}
}
import org.gradle.api.plugins.JavaPluginExtension
import org.gradle.jvm.toolchain.JavaLanguageVersion
import org.gradle.jvm.toolchain.JvmVendorSpec
subprojects {
repositories {
mavenCentral()
}
plugins.withId("org.jetbrains.kotlin.jvm") {
extensions.configure<org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension>("kotlin") {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(25))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
}
}
plugins.withId("java") {
extensions.configure<JavaPluginExtension>("java") {
toolchain {
languageVersion.set(JavaLanguageVersion.of(25))
vendor.set(JvmVendorSpec.ADOPTIUM)
}
}
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
testLogging {
showStandardStreams = true
events("passed", "skipped", "failed")
exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL
}
}
}
Application Base¶
Goal: Create the shared contract module and the runnable application module that the next steps will extend.
What this step introduces: the minimal @KoraApp entry point, a shared contract module, and the initial multi-module layout. This is the baseline graph before we start layering more DI features
on top of it.
Why we need it: we first establish what belongs to the application module and what belongs to reusable modules. This mirrors the separation described in Dependency Injection with Kora: @KoraApp, @Root and Container documentation: Container.
What we are emulating: a real application root that owns startup and a shared API module that other modules can depend on without pulling in application-specific behavior.
This guide uses the package io.koraframework.guide.dependencyinjection, the same package as the reference applications. Keeping the package stable makes it easier to compare your project with the
finished example and to find Kora-generated sources later.
Create shared contracts (guide-dependency-injection/guide-dependency-injection-common/src/main/java/io/koraframework/guide/dependencyinjection/common/
or guide-dependency-injection/guide-dependency-injection-common/src/main/kotlin/io/koraframework/guide/dependencyinjection/common/):
Build the Shared Module¶
First, create the build file for guide-dependency-injection-common. This module contains only interfaces and shared types, so it needs a library-oriented JVM plugin and test dependencies, but not the
application plugin or Kora code generation.
The java-library plugin is the right fit for modules with a public API:
Other modules will depend on common, so Gradle should distinguish between internal implementation dependencies and types that are part of the public API.
Add test dependencies:
dependencies {
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
junit-bom aligns JUnit versions, junit-jupiter adds JUnit 5, and test-junit5 adds Kora testing utilities. This first step may not have tests yet, but the module is ready for contract and
component checks. test-junit5 needs no version because the shared build file already made testImplementation extend koraBom.
The final common module build.gradle is:
The Kotlin JVM plugin compiles Kotlin code into JVM classes that the app and lib modules can use, and java-library separates the public API from implementation dependencies:
Neither plugin carries a version here: both versions were declared once in settings.gradle.kts.
Add the Kora BOM and test dependencies:
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
junit-bom aligns JUnit versions, junit-jupiter adds JUnit 5, and test-junit5 adds Kora testing utilities. testImplementation extends implementation, so the Kora BOM imported above is what
lets test-junit5 be declared without a version.
The final common module build.gradle.kts is:
plugins {
id("org.jetbrains.kotlin.jvm")
id("java-library")
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
Then create the interfaces:
Notifier is declared as a fun interface in Kotlin so that module factories can return it as a lambda later in the guide.
Create the main application (guide-dependency-injection/guide-dependency-injection-app/src/main/java/io/koraframework/guide/dependencyinjection/
or guide-dependency-injection/guide-dependency-injection-app/src/main/kotlin/io/koraframework/guide/dependencyinjection/):
Build the Application¶
Create the build file for guide-dependency-injection-app. This module is runnable, contains @KoraApp, and must enable Kora graph generation, so its Gradle setup is more involved than the shared
contract module.
Start with plugins:
The application plugin applies java for you and adds ./gradlew run plus main-class configuration, so no separate id "java" line is needed.
Add the Kora annotation processor:
annotationProcessor reads @KoraApp and generates ApplicationGraph. Without this line, Java compilation can reach the generated class reference, but the application graph itself will not be
produced.
Now add application dependencies:
dependencies {
implementation project(":guide-dependency-injection:guide-dependency-injection-common")
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
}
common provides the shared Notifier interface, config-hocon provides configuration, and logging-logback adds logging. The lib and submodule project dependencies are added in the steps
that create those modules.
Add test setup:
dependencies {
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
testAnnotationProcessor is only needed when test sources declare their own @KoraApp or Kora annotations that must be processed. test-junit5 adds the Kora JUnit 5 extension.
Configure application startup:
application {
applicationName = "application"
mainClass = "io.koraframework.guide.dependencyinjection.Application"
applicationDefaultJvmArgs = ["-Dfile.encoding=UTF-8"]
}
This block belongs to the Gradle application plugin. It is not part of Kora's DI container directly, but it connects the Kora-generated graph to the normal JVM application launch path:
applicationName = "application"sets the short application name in the Gradle distribution. Gradle uses it to create startup scripts such asbin/application.mainClasspoints to the class that containsmain. In Java this is the sourceApplicationinterface, not the generatedApplicationGraph: yourmainmethod callsKoraApplication.run(ApplicationGraph::graph).applicationDefaultJvmArgssets JVM arguments used by./gradlew runand written into generated startup scripts.
The important detail is that mainClass points to ordinary source code. ApplicationGraph exists only after annotationProcessor runs, so the classes task validates Java compilation, annotation
processing, and Kora graph generation together.
Add a stable distribution archive name:
distTar is a task added by the Gradle application plugin. It builds a tar archive containing the application classes, runtime dependencies, and startup scripts. By default, the archive name is
derived from the project name and version, which can be long and inconvenient in a multi-module tutorial project.
archiveFileName = "application.tar" makes the artifact name stable. That is useful for tests, CI, and later guide steps because they can reference one predictable file instead of reconstructing
the Gradle project name and version.
The final application build.gradle is:
plugins {
id "application"
}
dependencies {
annotationProcessor "io.koraframework:annotation-processors"
implementation project(":guide-dependency-injection:guide-dependency-injection-common")
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
application {
applicationName = "application"
mainClass = "io.koraframework.guide.dependencyinjection.Application"
applicationDefaultJvmArgs = ["-Dfile.encoding=UTF-8"]
}
distTar {
archiveFileName = "application.tar"
}
Start with plugins:
org.jetbrains.kotlin.jvm compiles Kotlin code, com.google.devtools.ksp runs the Kora symbol processor, and application adds ./gradlew run.
Add the Kora BOM and the KSP processor:
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
}
KSP reads @KoraApp and generates ApplicationGraph. Without this dependency, the application will not get the generated graph. The ksp configuration is not covered by the BOM, so it keeps an
explicit version.
Now add application dependencies:
dependencies {
implementation(project(":guide-dependency-injection:guide-dependency-injection-common"))
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:logging-logback")
}
common provides the shared Notifier interface, config-hocon provides HOCON configuration, and logging-logback adds logging. The lib and submodule project dependencies are added in the
steps that create those modules.
Add test dependencies:
dependencies {
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
There is no kspTest(...) line here. It is only needed when test sources declare their own @KoraApp or other Kora annotations that must be processed; tests that reuse the main Application
graph through @KoraAppTest do not.
Register the KSP output directories and configure startup:
kotlin {
sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
sourceSets.test { kotlin.srcDir("build/generated/ksp/test/kotlin") }
}
application {
applicationName = "application"
mainClass.set("io.koraframework.guide.dependencyinjection.ApplicationKt")
applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8")
}
The application block tells Gradle how to launch the Kotlin application:
applicationNamesets the distribution application name and startup script name.mainClass.set(...)points to the class that containsmain. In Kotlin, a top-levelmainfunction fromApplication.ktis compiled into the JVM classApplicationKt, so the main class isApplicationKt.applicationDefaultJvmArgssets JVM arguments for./gradlew runand generated startup scripts.
The -Dfile.encoding=UTF-8 argument fixes runtime encoding. This avoids differences between Windows, Linux, and macOS when the app writes text to logs or reads string resources.
Add a stable tar archive name:
distTar builds an executable distribution containing classes, runtime dependencies, and startup scripts. The fixed application.tar name is useful for tests, CI, and later guide steps that need
to reference one predictable artifact.
The final application build.gradle.kts is:
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp")
id("application")
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation(project(":guide-dependency-injection:guide-dependency-injection-common"))
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:logging-logback")
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
kotlin {
sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
sourceSets.test { kotlin.srcDir("build/generated/ksp/test/kotlin") }
}
application {
applicationName = "application"
mainClass.set("io.koraframework.guide.dependencyinjection.ApplicationKt")
applicationDefaultJvmArgs = listOf("-Dfile.encoding=UTF-8")
}
tasks.distTar {
archiveFileName.set("application.tar")
}
Then create the application:
package io.koraframework.guide.dependencyinjection;
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends HoconConfigModule, LogbackModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
}
package io.koraframework.guide.dependencyinjection
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.KoraApp
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application : HoconConfigModule, LogbackModule
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
KoraApplication.run(...) accepts a Supplier<ApplicationGraphDraw>, and the generated ApplicationGraph class provides exactly that through its static graph() method, which is why the method
reference ApplicationGraph::graph fits. The generated class is always named after the @KoraApp type plus the Graph suffix, so an Application interface produces ApplicationGraph. It does not
exist until annotation processing or KSP has run once.
Build and run:
Expected Output: The application starts and shuts down cleanly. Kora logs Application initialized in ...ms and, on Ctrl+C, Application shutdown.... The graph has no root component yet, so
nothing else happens; the next steps add components and modules.
External Modules¶
Goal: Create reusable library modules that provide default implementations.
What this step introduces: external module factories and @DefaultComponent. The EmailModule lives outside the application module and exposes defaults that the application can adopt or replace
later.
Why we need it: external modules are how reusable Kora libraries publish components to applications, but they are not auto-discovered and must be connected explicitly. This follows Dependency Injection with Kora: @Module, @DefaultComponent and Container documentation: External module factory.
What we are emulating: a library that ships a default email notifier implementation and configuration contract, while still allowing the application to override presentation details later.
First, create the library module build file. Unlike common, this module declares a @ConfigMapper type, so it needs Kora code generation of its own:
guide-dependency-injection/guide-dependency-injection-lib/build.gradle
plugins {
id "java-library"
}
dependencies {
annotationProcessor "io.koraframework:annotation-processors"
api project(":guide-dependency-injection:guide-dependency-injection-common")
implementation "io.koraframework:config-common"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
guide-dependency-injection/guide-dependency-injection-lib/build.gradle.kts
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp")
id("java-library")
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
api(project(":guide-dependency-injection:guide-dependency-injection-common"))
implementation("io.koraframework:config-common")
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
kotlin {
sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
}
api project(...) is deliberate: Notifier appears in the signatures this module exposes, so consumers of lib must see it too. config-common brings the configuration contracts Config and
ConfigValueMapper without forcing a specific configuration format on the library — the application decides between HOCON and YAML.
Then register the new module in the root settings file and add it to the application classpath:
settings.gradle already contains the module, and guide-dependency-injection-app/build.gradle now depends on it:
settings.gradle.kts already contains the module, and guide-dependency-injection-app/build.gradle.kts now depends on it:
Create EmailConfig (guide-dependency-injection/guide-dependency-injection-lib/src/main/java/io/koraframework/guide/dependencyinjection/email/
or guide-dependency-injection/guide-dependency-injection-lib/src/main/kotlin/io/koraframework/guide/dependencyinjection/email/):
@ConfigMapper is the library-side configuration annotation: it tells Kora to generate a ConfigValueMapper<EmailConfig> without binding the type to a fixed configuration path. The module method
below is what chooses the path, so the same config type can be reused under different sections. For an application-owned configuration type bound to one path, use @ConfigSource instead — see
Configuration.
Create EmailModule (same package):
package io.koraframework.guide.dependencyinjection.email;
import java.util.function.Supplier;
import io.koraframework.common.annotation.DefaultComponent;
import io.koraframework.common.annotation.Tag;
import io.koraframework.config.common.Config;
import io.koraframework.config.common.mapper.ConfigValueMapper;
import io.koraframework.guide.dependencyinjection.common.Notifier;
public interface EmailModule {
final class EmailTag {
private EmailTag() {}
}
default EmailConfig config(Config config, ConfigValueMapper<EmailConfig> extractor) {
return extractor.mapOrThrow(config.get("notifier.email")); //(1)!
}
@Tag(EmailTag.class)
@DefaultComponent //(2)!
default Supplier<String> emailNotifierHeaderSupplier() {
return () -> "[EMAIL DEFAULT] ";
}
@Tag(EmailTag.class)
default Notifier emailNotifier(EmailConfig emailConfig, @Tag(EmailTag.class) Supplier<String> headerSupplier) {
return (user, message) -> System.out.println(headerSupplier.get() + emailConfig.topic() + " [USER:" + user + "]: " + message);
}
}
mapOrThrowfails the graph build with a configuration error when the section is missing or cannot be mapped. Usemapinstead if a missing section should producenull.- Marks the factory as a default: the application may declare its own factory for the same type and tag, and Kora will prefer the application one.
package io.koraframework.guide.dependencyinjection.email
import java.util.function.Supplier
import io.koraframework.common.annotation.DefaultComponent
import io.koraframework.common.annotation.Tag
import io.koraframework.config.common.Config
import io.koraframework.config.common.mapper.ConfigValueMapper
import io.koraframework.guide.dependencyinjection.common.Notifier
interface EmailModule {
class EmailTag private constructor()
fun config(config: Config, extractor: ConfigValueMapper<EmailConfig>): EmailConfig {
return extractor.mapOrThrow(config["notifier.email"]) //(1)!
}
@Tag(EmailTag::class)
@DefaultComponent //(2)!
fun emailNotifierHeaderSupplier(): Supplier<String> {
return Supplier { "[EMAIL DEFAULT] " }
}
@Tag(EmailTag::class)
fun emailNotifier(
emailConfig: EmailConfig,
@Tag(EmailTag::class) headerSupplier: Supplier<String>
): Notifier {
return Notifier { user, message ->
println("${headerSupplier.get()}${emailConfig.topic} [USER:$user]: $message")
}
}
}
mapOrThrowfails the graph build with a configuration error when the section is missing or cannot be mapped. Usemapinstead if a missing section should producenull.- Marks the factory as a default: the application may declare its own factory for the same type and tag, and Kora will prefer the application one.
EmailTag is an ordinary nested class used only as a compile-time marker. It never gets instantiated, which is why it can have a private constructor. Tag classes must be visible from every place that
references them, so a package-private or private top-level tag will not work across modules.
Update Application to include the email module:
Create application.conf (guide-dependency-injection/guide-dependency-injection-app/src/main/resources/):
For the full configuration reference, see Configuration.
The application module depends on config-hocon, so application.conf is what actually gets read. Switch the dependency to io.koraframework:config-yaml and the module to YamlConfigModule if you
prefer the YAML file instead.
Build and run - Application still has no root component, so it just starts and stops.
Key Concept: @DefaultComponent provides library defaults that applications can override.
Module registration rule: if a type is annotated with @Module, do not also wire it through extends on @KoraApp or another module. A module should be registered in exactly one way: either
inherited with extends, or discovered because it is annotated with @Module and is compiled together with the current @KoraApp / @KoraSubmodule. @KoraSubmodule itself is the case where
inheritance is expected, because the processor looks for @KoraSubmodule only among the interfaces the @KoraApp type extends.
Note that @Module may only be applied to interfaces. Applying it to a class fails compilation with @Module can only be applied to interfaces.
What Kora generates for EmailModule: after ./gradlew clean classes, ApplicationGraph will not necessarily contain the exact same componentN numbers shown below, because those names are
internal generator details. The structure is the important part: Kora creates a configuration node, a default value node, and the notifier node.
Java: generated graph fragment for EmailModule
private final Node<EmailConfig> component8;
private final Node<Supplier<String>> component9;
private final Node<Notifier> component10;
component8 = graphDraw.addNode(_type_of_component8,
null,
null,
List.of(component6, component7),
List.of(component6, component7),
List.of(),
g -> impl.config(
g.get(ApplicationGraph.holder0.component6),
g.get(ApplicationGraph.holder0.component7)
));
component9 = graphDraw.addNode(_type_of_component9,
EmailModule.EmailTag.class,
null,
List.of(),
List.of(),
List.of(),
g -> impl.emailNotifierHeaderSupplier());
component10 = graphDraw.addNode(_type_of_component10,
EmailModule.EmailTag.class,
null,
List.of(component8, component9),
List.of(component8, component9),
List.of(),
g -> impl.emailNotifier(
g.get(ApplicationGraph.holder0.component8),
g.get(ApplicationGraph.holder0.component9)
));
This shows why EmailModule must be connected through extends: only then do its factory methods become part of the application graph.
component8readsnotifier.emailand turns HOCON configuration into typedEmailConfig.component9is a taggedSupplier<String>withEmailTag. This lets Kora distinguish the email header from other possibleSupplier<String>components.component10is a taggedNotifierthat depends onEmailConfigand the taggedSupplier<String>.- The second argument of
addNodeis the tag, the third is an optional@Conditionalpredicate, and the twoList.of(...)arguments are the create-time and refresh-time dependencies. @DefaultComponentonemailNotifierHeaderSupplier()means the library provides a default value, and the application can replace it in the next section.
Kotlin: generated graph fragment for EmailModule
public val component8: Node<EmailConfig>
public val component9: Node<Supplier<String>>
public val component10: Node<Notifier>
component8 = graphDraw.addNode(map["component8"],
null,
null,
listOf(component6, component7),
listOf(component6, component7),
listOf(),
{ impl.config(
it.get(holder0.component6),
it.get(holder0.component7)
) }
)
component9 = graphDraw.addNode(map["component9"],
EmailModule.EmailTag::class.java,
null,
listOf(),
listOf(),
listOf(),
{ impl.emailNotifierHeaderSupplier() }
)
component10 = graphDraw.addNode(map["component10"],
EmailModule.EmailTag::class.java,
null,
listOf(component8, component9),
listOf(component8, component9),
listOf(),
{ impl.emailNotifier(
it.get(holder0.component8),
it.get(holder0.component9)
) }
)
Kotlin/KSP generates the same meaning in Kotlin code:
EmailConfigbecomes a separate graph node.EmailTagis passed as the node tag for bothSupplier<String>andNotifier.emailNotifier(...)receives dependencies from the graph instead of creating them itself.- In the next section, the application overrides
emailNotifierHeaderSupplier(), and Kora substitutes the new node for the library@DefaultComponent.
Component Override¶
Goal: Show how applications can override library defaults.
What this step introduces: component override of a @DefaultComponent factory from an external module. The application replaces only the header supplier and keeps the rest of the library behavior
intact.
Why we need it: libraries should provide safe defaults, but applications must keep final control over business-facing behavior. This matches Dependency Injection with Kora: Standard factory, @DefaultComponent and Container documentation: Standard factory.
What we are emulating: application-specific customization of a shared library notifier without forking or rewriting the entire module.
Create NotifyRunner (guide-dependency-injection/guide-dependency-injection-app/src/main/java/io/koraframework/guide/dependencyinjection/
or guide-dependency-injection/guide-dependency-injection-app/src/main/kotlin/io/koraframework/guide/dependencyinjection/):
package io.koraframework.guide.dependencyinjection;
import io.koraframework.application.graph.All;
import io.koraframework.application.graph.Lifecycle;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Root;
import io.koraframework.common.annotation.Tag;
import io.koraframework.guide.dependencyinjection.common.Notifier;
@Root //(1)!
@Component
public final class NotifyRunner implements Lifecycle {
private final All<Notifier> allNotifiers;
public NotifyRunner(@Tag(Tag.Any.class) All<Notifier> allNotifiers) { //(2)!
this.allNotifiers = allNotifiers;
}
@Override
public void init() {
System.out.println("DI tutorial step 3 start");
for (var notifier : allNotifiers) {
notifier.notify("Alice", "Welcome!");
}
}
@Override
public void release() {
System.out.println("Application shutdown");
}
}
- Nothing depends on
NotifyRunner, so without@RootKora would prune it from the graph and it would never be created. @Tag(Tag.Any.class)widens the claim to everyNotifierregardless of tag. Without it, an untaggedAll<Notifier>claim matches untagged notifiers only.
package io.koraframework.guide.dependencyinjection
import io.koraframework.application.graph.All
import io.koraframework.application.graph.Lifecycle
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Root
import io.koraframework.common.annotation.Tag
import io.koraframework.guide.dependencyinjection.common.Notifier
@Root //(1)!
@Component
class NotifyRunner(
@Tag(Tag.Any::class) private val allNotifiers: All<Notifier> //(2)!
) : Lifecycle {
override fun init() {
println("DI tutorial step 3 start")
for (notifier in allNotifiers) {
notifier.notify("Alice", "Welcome!")
}
}
override fun release() {
println("Application shutdown")
}
}
- Nothing depends on
NotifyRunner, so without@RootKora would prune it from the graph and it would never be created. @Tag(Tag.Any::class)widens the claim to everyNotifierregardless of tag. Without it, an untaggedAll<Notifier>claim matches untagged notifiers only.
Lifecycle comes from io.koraframework.application.graph and declares exactly two methods, init() and release(). Kora calls init() in graph order during startup and release() in reverse
order during shutdown, so a component is always initialized after everything it depends on and released before them.
Update Application to override the email header:
import java.util.function.Supplier;
import io.koraframework.common.annotation.Tag;
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
EmailModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
@Tag(EmailModule.EmailTag.class)
@Override
default Supplier<String> emailNotifierHeaderSupplier() {
return () -> "[EMAIL OVERRIDDEN] ";
}
}
import java.util.function.Supplier
import io.koraframework.common.annotation.Tag
@KoraApp
interface Application :
HoconConfigModule,
LogbackModule,
EmailModule { // <----- Connected module
@Tag(EmailModule.EmailTag::class)
override fun emailNotifierHeaderSupplier(): Supplier<String> {
return Supplier { "[EMAIL OVERRIDDEN] " }
}
}
fun main() {
KoraApplication.run(ApplicationGraph::graph)
}
The override is an ordinary Java or Kotlin method override, so the compiler already guarantees the signature matches. @Tag must be repeated on the override: the tag is part of the component identity,
not something inherited from the overridden method. Note also that the override intentionally drops @DefaultComponent, which is what makes the application factory win over the library default.
Build and run:
Key Concept: Applications can override @DefaultComponent implementations by providing their own factory methods.
Tagged Dependencies¶
Goal: Demonstrate how tags allow multiple implementations of the same interface, while All<T> lets you consume all matching notifiers at once.
What this step introduces: @Tag for distinguishing multiple Notifier implementations and All<T> for broadcasting across them. SmsModule is an internal @Module, so it is discovered
automatically from the application module instead of being inherited through extends.
Why we need it: once one contract has multiple implementations, plain type-based injection is no longer enough. Tags make the graph explicit, and All<T> gives us a natural way to fan out
notifications.
See Dependency Injection with Kora: @Tag, Dependency Claims and Resolution: All, Tags System
and Container documentation: Tag any.
What we are emulating: a notification service that can send the same message through every available channel instead of choosing only one implementation.
Create the SMS provider contract in the library module
(guide-dependency-injection/guide-dependency-injection-lib/src/main/java/io/koraframework/guide/dependencyinjection/sms/
or guide-dependency-injection/guide-dependency-injection-lib/src/main/kotlin/io/koraframework/guide/dependencyinjection/sms/). Only the contract exists for now; nothing provides it yet:
Create SmsModule (guide-dependency-injection/guide-dependency-injection-app/src/main/java/io/koraframework/guide/dependencyinjection/sms/
or guide-dependency-injection/guide-dependency-injection-app/src/main/kotlin/io/koraframework/guide/dependencyinjection/sms/):
package io.koraframework.guide.dependencyinjection.sms;
import org.jspecify.annotations.Nullable;
import io.koraframework.common.annotation.Module;
import io.koraframework.common.annotation.Tag;
import io.koraframework.guide.dependencyinjection.common.Notifier;
@Module
public interface SmsModule {
final class SmsTag {
private SmsTag() {}
}
@Tag(SmsTag.class)
default Notifier smsNotifier(@Nullable SmsCellularProvider cellularProvider) {
return (user, message) -> {
if (cellularProvider == null) {
System.out.println("[SMS] " + user + "@" + message);
} else {
System.out.println("+" + cellularProvider.getCode() + " [SMS] " + user + "@" + message);
}
};
}
}
package io.koraframework.guide.dependencyinjection.sms
import io.koraframework.common.annotation.Module
import io.koraframework.common.annotation.Tag
import io.koraframework.guide.dependencyinjection.common.Notifier
@Module
interface SmsModule {
class SmsTag private constructor()
@Tag(SmsTag::class)
fun smsNotifier(cellularProvider: SmsCellularProvider?): Notifier {
return Notifier { user, message ->
if (cellularProvider == null) {
println("[SMS] $user@$message")
} else {
println("+${cellularProvider.getCode()} [SMS] $user@$message")
}
}
}
}
Java uses JSpecify org.jspecify.annotations.Nullable for optional dependencies. It comes transitively with any Kora module, so no extra dependency is needed. Kotlin has no
annotation at all: the ? on the parameter type is the whole declaration.
Application note: SmsModule is annotated with @Module and is compiled together with @KoraApp, so Kora discovers it automatically. Do not add it with extends on Application. The
Application interface stays exactly as it was in the previous step:
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
EmailModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
@Tag(EmailModule.EmailTag.class)
@Override
default Supplier<String> emailNotifierHeaderSupplier() {
return () -> "[EMAIL OVERRIDDEN] ";
}
}
Update NotifyRunner to iterate over all notifiers:
@Root
@Component
public final class NotifyRunner implements Lifecycle {
private final All<Notifier> allNotifiers;
public NotifyRunner(@Tag(Tag.Any.class) All<Notifier> allNotifiers) {
this.allNotifiers = allNotifiers;
}
@Override
public void init() {
System.out.println("DI tutorial step 4 start");
for (var notifier : allNotifiers) {
notifier.notify("Bob", "Hello!");
}
}
@Override
public void release() {
System.out.println("Application shutdown");
}
}
@Root
@Component
class NotifyRunner(
@Tag(Tag.Any::class) private val allNotifiers: All<Notifier>
) : Lifecycle {
override fun init() {
println("DI tutorial step 4 start")
for (notifier in allNotifiers) {
notifier.notify("Bob", "Hello!")
}
}
override fun release() {
println("Application shutdown")
}
}
Build and run:
DI tutorial step 4 start
[SMS] Bob@Hello!
[EMAIL OVERRIDDEN] USER [USER:Bob]: Hello!
Application shutdown
The SMS line has no provider code yet, because nothing in the graph provides SmsCellularProvider and the nullable parameter resolved to null. The next step fixes that.
Key Concept: @Tag allows multiple implementations of the same contract, and @Tag(Tag.Any.class) All<T> lets you broadcast to all of them.
Optional Dependencies¶
Goal: Add an optional collaborator for SMS without changing the Notifier contract.
What this step introduces: nullable dependencies for optional behavior. SmsModule can work with or without SmsCellularProvider, and SmsCellularModule adds the provider only when the
application chooses to inherit it.
Why we need it: some features should enrich an existing component rather than force a separate implementation branch. This follows Dependency Injection with Kora: Nullable and Container documentation: Optional dependencies.
What we are emulating: optional enrichment of SMS formatting with a provider code, where the notifier still functions even if that provider is not configured.
Create SmsCellularModule next to SmsCellularProvider in the library module
(guide-dependency-injection/guide-dependency-injection-lib/src/main/java/io/koraframework/guide/dependencyinjection/sms/
or guide-dependency-injection/guide-dependency-injection-lib/src/main/kotlin/io/koraframework/guide/dependencyinjection/sms/):
Update Application to include the provider module. SmsCellularModule is not annotated with @Module, so this one is intentionally connected through extends:
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
EmailModule, // <----- Connected module
SmsCellularModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
@Tag(EmailModule.EmailTag.class)
@Override
default Supplier<String> emailNotifierHeaderSupplier() {
return () -> "[EMAIL OVERRIDDEN] ";
}
}
Build and run:
DI tutorial step 5 start
+1 [SMS] Bob@Hello!
[EMAIL OVERRIDDEN] USER [USER:Bob]: Hello!
Application shutdown
Key Concept: @Nullable in Java and nullable types in Kotlin let a component keep working even when an optional dependency is absent. A missing required dependency is a compile-time error; a
missing optional one silently resolves to null, so keep the null branch meaningful.
Submodule¶
Goal: Demonstrate @KoraSubmodule for organizing related components.
What this step introduces: @KoraSubmodule as the boundary that turns another Gradle module into a DI-visible compilation unit. Inside that submodule, @Module and @Component declarations are
collected and exposed to the main @KoraApp through inheritance.
Why we need it: regular Gradle modules are not scanned by Kora unless they contain @KoraApp or @KoraSubmodule. This is the mechanism that lets us move messenger functionality into its own
module without losing DI discovery.
See Dependency Injection with Kora: @KoraSubmodule, Overview scope note
and Container documentation: Submodule factory.
What we are emulating: a larger codebase where a separate team or package owns messenger delivery, but the main application still composes it into one graph.
Now create and connect the submodule as the tutorial reaches the @KoraSubmodule part.
Update settings.gradle:
Update settings.gradle.kts:
Create the directory:
Create guide-dependency-injection/guide-dependency-injection-submodule/build.gradle:
plugins {
id "java-library"
}
dependencies {
annotationProcessor "io.koraframework:annotation-processors" //(1)!
api project(":guide-dependency-injection:guide-dependency-injection-common")
implementation "io.koraframework:common" //(2)!
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
- Required:
@KoraSubmoduleis processed in this module, not in the application module. The processor writes aMessengerModuleSubmoduleImplinterface here, and the application module later inherits it throughMessengerModule. io.koraframework:commoncarries the DI annotations and, transitively,application-graphwithAll,ValueOf, andLifecycle.
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp")
id("java-library")
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}") //(1)!
api(project(":guide-dependency-injection:guide-dependency-injection-common"))
implementation("io.koraframework:common") //(2)!
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
kotlin {
sourceSets.main { kotlin.srcDir("build/generated/ksp/main/kotlin") }
sourceSets.test { kotlin.srcDir("build/generated/ksp/test/kotlin") }
}
- Required:
@KoraSubmoduleis processed in this module, not in the application module. KSP writes aMessengerModuleSubmoduleImplinterface here, and the application module later inherits it throughMessengerModule. io.koraframework:commoncarries the DI annotations and, transitively,application-graphwithAll,ValueOf, andLifecycle.
Update guide-dependency-injection-app build file to add the new module dependency:
dependencies {
annotationProcessor "io.koraframework:annotation-processors"
implementation project(":guide-dependency-injection:guide-dependency-injection-common")
implementation project(":guide-dependency-injection:guide-dependency-injection-lib")
implementation project(":guide-dependency-injection:guide-dependency-injection-submodule")
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
testAnnotationProcessor "io.koraframework:annotation-processors"
testImplementation platform("org.junit:junit-bom:$junitVersion")
testImplementation "org.junit.jupiter:junit-jupiter"
testImplementation "io.koraframework:test-junit5"
}
dependencies {
implementation(platform("io.koraframework:kora-bom:${property("koraVersion")}"))
ksp("io.koraframework:symbol-processors:${property("koraVersion")}")
implementation(project(":guide-dependency-injection:guide-dependency-injection-common"))
implementation(project(":guide-dependency-injection:guide-dependency-injection-lib"))
implementation(project(":guide-dependency-injection:guide-dependency-injection-submodule"))
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:logging-logback")
testImplementation(platform("org.junit:junit-bom:${property("junitVersion")}"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("io.koraframework:test-junit5")
}
Create MessengerModule (guide-dependency-injection/guide-dependency-injection-submodule/src/main/java/io/koraframework/guide/dependencyinjection/messenger/
or guide-dependency-injection/guide-dependency-injection-submodule/src/main/kotlin/io/koraframework/guide/dependencyinjection/messenger/):
The interface body is almost empty on purpose. @KoraSubmodule is a marker: during compilation of this Gradle module, Kora collects every @Module and @Component declared in the same compilation
unit and writes them into a generated interface named MessengerModuleSubmoduleImpl. The application picks all of that up by extending MessengerModule.
Create Messenger interface:
Create SlackMessenger:
package io.koraframework.guide.dependencyinjection.messenger.slack;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Tag;
import io.koraframework.guide.dependencyinjection.messenger.Messenger;
@Tag(SlackMessenger.class) //(1)!
@Component
public final class SlackMessenger implements Messenger {
@Override
public void sendMessage(String message) {
System.out.println("Slack: " + message);
}
}
- A component can be its own tag. That is convenient when the only purpose of the tag is to identify one specific implementation.
package io.koraframework.guide.dependencyinjection.messenger.slack
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Tag
import io.koraframework.guide.dependencyinjection.messenger.Messenger
@Tag(SlackMessenger::class) //(1)!
@Component
class SlackMessenger : Messenger {
override fun sendMessage(message: String) {
println("Slack: $message")
}
}
- A component can be its own tag. That is convenient when the only purpose of the tag is to identify one specific implementation.
Create MessengerNotifier:
package io.koraframework.guide.dependencyinjection.messenger;
import io.koraframework.application.graph.All;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Tag;
import io.koraframework.guide.dependencyinjection.common.Notifier;
@Tag(MessengerModule.MessengerTag.class)
@Component
public final class MessengerNotifier implements Notifier {
private final All<Messenger> messengers;
public MessengerNotifier(@Tag(Tag.Any.class) All<Messenger> messengers) {
this.messengers = messengers;
}
@Override
public void notify(String user, String message) {
System.out.println("Broadcasting to messengers");
for (var messenger : messengers) {
messenger.sendMessage(user + "@" + message);
}
System.out.println("Messenger broadcast complete");
}
}
package io.koraframework.guide.dependencyinjection.messenger
import io.koraframework.application.graph.All
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Tag
import io.koraframework.guide.dependencyinjection.common.Notifier
@Tag(MessengerModule.MessengerTag::class)
@Component
class MessengerNotifier(
@Tag(Tag.Any::class) private val messengers: All<Messenger>
) : Notifier {
override fun notify(user: String, message: String) {
println("Broadcasting to messengers")
for (messenger in messengers) {
messenger.sendMessage("$user@$message")
}
println("Messenger broadcast complete")
}
}
Update Application to include the messenger submodule. MessengerModule is annotated with @KoraSubmodule, so this is the case where inheritance is expected:
@KoraApp
public interface Application extends
HoconConfigModule,
LogbackModule,
EmailModule, // <----- Connected module
SmsCellularModule, // <----- Connected module
MessengerModule { // <----- Connected module
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph);
}
@Tag(EmailModule.EmailTag.class)
@Override
default Supplier<String> emailNotifierHeaderSupplier() {
return () -> "[EMAIL OVERRIDDEN] ";
}
}
@KoraApp
interface Application :
HoconConfigModule,
LogbackModule,
EmailModule, // <----- Connected module
SmsCellularModule, // <----- Connected module
MessengerModule { // <----- Connected module
@Tag(EmailModule.EmailTag::class)
override fun emailNotifierHeaderSupplier(): Supplier<String> {
return Supplier { "[EMAIL OVERRIDDEN] " }
}
}
Kora submodule was not generated yet
If the application module fails with Kora submodule was not generated yet: expected type: ...MessengerModuleSubmoduleImpl, the submodule's own Gradle module did not run the Kora processor.
Check that guide-dependency-injection-submodule declares annotationProcessor "io.koraframework:annotation-processors" (Java) or ksp("io.koraframework:symbol-processors:...") (Kotlin), then
run ./gradlew clean classes so the generated interface exists before the application module is compiled.
Build and run:
+1 [SMS] Bob@Hello!
[EMAIL OVERRIDDEN] USER [USER:Bob]: Hello!
Broadcasting to messengers
Slack: Bob@Hello!
Messenger broadcast complete
Application shutdown
Key Concept: @KoraSubmodule groups related components and tags without forcing them into the main application interface file.
Generic Factory¶
Goal: Demonstrate generic factory methods for flexible component creation.
What this step introduces: generic factories that let one module create many strongly typed components. StorageModule produces Storage<T> instances from mapper functions instead of hardcoding
one concrete storage per type.
Why we need it: generic factories reduce duplication while keeping the graph type-safe. This aligns with Dependency Injection with Kora: Generic factory and Container documentation: Generic factory.
What we are emulating: infrastructure code that can persist different payload shapes using the same reusable storage pattern, with Kora selecting the right generic instantiation automatically.
Create Storage interface (guide-dependency-injection/guide-dependency-injection-app/src/main/java/io/koraframework/guide/dependencyinjection/storage/
or guide-dependency-injection/guide-dependency-injection-app/src/main/kotlin/io/koraframework/guide/dependencyinjection/storage/):
Create TempFileStorage:
package io.koraframework.guide.dependencyinjection.storage;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.function.Function;
public final class TempFileStorage<T> implements Storage<T> {
private final Function<T, byte[]> mapper;
public TempFileStorage(Function<T, byte[]> mapper) {
this.mapper = mapper;
}
@Override
public void save(T data) {
try {
Path tempFile = Files.createTempFile("storage-", ".tmp");
Files.write(tempFile, mapper.apply(data));
System.out.println("Saved to: " + tempFile.getFileName());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
package io.koraframework.guide.dependencyinjection.storage
import java.nio.file.Files
import java.util.function.Function
class TempFileStorage<T>(
private val mapper: Function<T, ByteArray>
) : Storage<T> {
override fun save(data: T) {
val tempFile = Files.createTempFile("storage-", ".tmp")
Files.write(tempFile, mapper.apply(data))
println("Saved to: ${tempFile.fileName}")
}
}
TempFileStorage is not annotated. It is created by the module factory below, and a @Component annotation here would add a second, conflicting provider for the same type.
Create StorageModule:
package io.koraframework.guide.dependencyinjection.storage;
import java.nio.charset.StandardCharsets;
import java.util.function.Function;
import io.koraframework.common.annotation.Module;
@Module
public interface StorageModule {
default Function<Integer, byte[]> intMapper() {
return i -> new byte[] {i.byteValue()};
}
default Function<String, byte[]> stringMapper() {
return s -> s.getBytes(StandardCharsets.UTF_8);
}
default <T> Storage<T> typedStorage(Function<T, byte[]> mapper) { //(1)!
return new TempFileStorage<>(mapper);
}
}
- A factory method with its own type parameter is a template. Kora does not instantiate it eagerly: it creates one node per concrete
Storage<T>actually requested by some other component, resolvingFunction<T, byte[]>for that sameT.
package io.koraframework.guide.dependencyinjection.storage
import java.nio.charset.StandardCharsets
import java.util.function.Function
import io.koraframework.common.annotation.Module
@Module
interface StorageModule {
fun intMapper(): Function<Int, ByteArray> {
return Function { i -> byteArrayOf(i.toByte()) }
}
fun stringMapper(): Function<String, ByteArray> {
return Function { s -> s.toByteArray(StandardCharsets.UTF_8) }
}
fun <T> typedStorage(mapper: Function<T, ByteArray>): Storage<T> { //(1)!
return TempFileStorage(mapper)
}
}
- A factory method with its own type parameter is a template. Kora does not instantiate it eagerly: it creates one node per concrete
Storage<T>actually requested by some other component, resolvingFunction<T, ByteArray>for that sameT.
The Kotlin version deliberately uses java.util.function.Function rather than a Kotlin function type such as (T) -> ByteArray. Kotlin function types compile to kotlin.jvm.functions.FunctionN,
which makes every one-argument function the same erased type in the graph, so a (Int) -> ByteArray and a (String) -> ByteArray become indistinguishable candidates. An explicit Function<T, R>
keeps both type arguments visible to the resolver.
Application note: No Application changes are required here. StorageModule is compiled together with @KoraApp and is annotated with @Module, so Kora discovers it as an application module
automatically.
Update NotifyRunner to use Storage<String>:
@Root
@Component
public final class NotifyRunner implements Lifecycle {
private final All<Notifier> allNotifiers;
private final Storage<String> stringStorage;
public NotifyRunner(@Tag(Tag.Any.class) All<Notifier> allNotifiers, Storage<String> stringStorage) {
this.allNotifiers = allNotifiers;
this.stringStorage = stringStorage;
}
@Override
public void init() {
System.out.println("DI tutorial step 7 start");
for (var notifier : allNotifiers) {
notifier.notify("Charlie", "Greetings!");
}
stringStorage.save("User data stored");
}
@Override
public void release() {
System.out.println("Application shutdown");
}
}
@Root
@Component
class NotifyRunner(
@Tag(Tag.Any::class) private val allNotifiers: All<Notifier>,
private val stringStorage: Storage<String>
) : Lifecycle {
override fun init() {
println("DI tutorial step 7 start")
for (notifier in allNotifiers) {
notifier.notify("Charlie", "Greetings!")
}
stringStorage.save("User data stored")
}
override fun release() {
println("Application shutdown")
}
}
Only Storage<String> is requested, so only stringMapper() and one Storage<String> node end up in the graph. intMapper() stays unused and, because nothing claims it, it is never instantiated.
Build and run:
DI tutorial step 7 start
+1 [SMS] Charlie@Greetings!
[EMAIL OVERRIDDEN] USER [USER:Charlie]: Greetings!
Broadcasting to messengers
Slack: Charlie@Greetings!
Messenger broadcast complete
Saved to: storage-123456.tmp
Application shutdown
Key Concept: Generic factory methods such as <T> Storage<T> allow Kora to build strongly typed components from reusable factories.
Factory Module¶
There is a second way to group factories, and it is worth knowing because it solves a different problem. A @Module interface is stateless: Kora instantiates an anonymous implementation of it and
calls its default methods. A factory module is an ordinary object that is itself a graph component and whose public methods are also treated as factories. That lets one module instance carry
construction state — a client, a prefix, a configuration object — and hand it to every component it creates.
The snippet below illustrates the shape. It is not part of the tutorial application, because it would provide a second Storage<String> and collide with the generic factory above:
public final class ArchiveFactory { //(1)!
private final Function<String, byte[]> mapper;
public ArchiveFactory(Function<String, byte[]> mapper) {
this.mapper = mapper;
}
public Archive archive() { //(2)!
return data -> mapper.apply(data).length;
}
}
@Module
public interface ArchiveModule {
@FactoryModule //(3)!
default ArchiveFactory archiveFactory(Function<String, byte[]> mapper) {
return new ArchiveFactory(mapper);
}
}
- A plain class, not an interface, and not annotated.
- Public methods of the returned object become component factories, exactly like default methods of a
@Moduleinterface. @FactoryModulefromio.koraframework.common.annotation. It registersArchiveFactoryitself as a graph node and also processes its methods as providers.
class ArchiveFactory( //(1)!
private val mapper: Function<String, ByteArray>
) {
fun archive(): Archive { //(2)!
return Archive { data -> mapper.apply(data).size }
}
}
@Module
interface ArchiveModule {
@FactoryModule //(3)!
fun archiveFactory(mapper: Function<String, ByteArray>): ArchiveFactory {
return ArchiveFactory(mapper)
}
}
- A plain class, not an interface, and not annotated.
- Public methods of the returned object become component factories, exactly like methods of a
@Moduleinterface. @FactoryModulefromio.koraframework.common.annotation. It registersArchiveFactoryitself as a graph node and also processes its methods as providers.
Two factory modules of the same type can coexist if they carry different tags, and inside such a module @Tag(Tag.Factory.class) means "the tag of the enclosing factory module". That is how one class
can be instantiated twice, each instance producing its own tagged set of components. Using @Tag.Factory outside a factory module is a compile error:
@Tag.Factory can only be used inside factory modules.
See Container documentation: Factory module and Dependency Injection with Kora: Factory Module for the full contract.
Update Management¶
Goal: Demonstrate ValueOf<T> for preventing unwanted cascading refreshes when dependencies are updated.
What this step introduces: ValueOf<T>, Wrapped<T>, and LifecycleWrapper for lifecycle-aware, indirectly accessed dependencies. ActivityService stays stable while ActivityRecorder remains
lazily accessible and lifecycle-managed.
Why we need it: some infrastructure dependencies are expensive or refreshable, and we do not want every consumer to be recreated just because that dependency changes. This follows Dependency Injection with Kora: ValueOf and Container documentation: Component lifecycle.
What we are emulating: a service that records activity through a managed connector which can be started, stopped, or refreshed independently from the business service using it.
Create ActivityRecorder interface (guide-dependency-injection/guide-dependency-injection-app/src/main/java/io/koraframework/guide/dependencyinjection/activity/
or guide-dependency-injection/guide-dependency-injection-app/src/main/kotlin/io/koraframework/guide/dependencyinjection/activity/):
Create ActivityService:
package io.koraframework.guide.dependencyinjection.activity;
import io.koraframework.application.graph.ValueOf;
import io.koraframework.common.annotation.Component;
@Component
public final class ActivityService {
private final ValueOf<ActivityRecorder> activityRecorder;
public ActivityService(ValueOf<ActivityRecorder> activityRecorder) {
this.activityRecorder = activityRecorder;
System.out.println("ActivityService created (ActivityRecorder not yet accessed)");
}
public void recordActivityByUserName(String user) {
System.out.println("Recording activity for: " + user);
ActivityRecorder recorder = activityRecorder.get(); //(1)!
recorder.recordUser(user);
System.out.println("Activity recorded successfully");
}
}
ValueOf.get()always returns the current instance from the graph. Call it at use time, never cache the result in a field, otherwise a refresh would leave a stale reference behind.
package io.koraframework.guide.dependencyinjection.activity
import io.koraframework.application.graph.ValueOf
import io.koraframework.common.annotation.Component
@Component
class ActivityService(
private val activityRecorder: ValueOf<ActivityRecorder>
) {
init {
println("ActivityService created (ActivityRecorder not yet accessed)")
}
fun recordActivityByUserName(user: String) {
println("Recording activity for: $user")
val recorder = activityRecorder.get() //(1)!
recorder.recordUser(user)
println("Activity recorded successfully")
}
}
ValueOf.get()always returns the current instance from the graph. Call it at use time, never cache the result in a property, otherwise a refresh would leave a stale reference behind.
Create ActivityModule:
package io.koraframework.guide.dependencyinjection.activity;
import io.koraframework.application.graph.LifecycleWrapper;
import io.koraframework.application.graph.Wrapped;
import io.koraframework.common.annotation.Module;
@Module
public interface ActivityModule {
default Wrapped<ActivityRecorder> activityRecorder() { //(1)!
var recorder = new ActivityRecorder() {
private boolean connected;
@Override
public void connect() {
if (!connected) {
System.out.println("Connecting to activity recorder");
connected = true;
System.out.println("Activity recorder connected");
}
}
@Override
public void disconnect() {
if (connected) {
System.out.println("Disconnecting from activity recorder");
connected = false;
}
}
@Override
public boolean isConnected() {
return connected;
}
@Override
public void recordUser(String user) {
if (!connected) {
connect();
}
System.out.println("Recording user activity: " + user);
}
};
return new LifecycleWrapper<>(recorder, r -> {}, ActivityRecorder::disconnect); //(2)!
}
}
- Returning
Wrapped<T>registers the node underWrapped<ActivityRecorder>but lets consumers claim plainActivityRecorder; Kora unwraps it automatically. LifecycleWrappertakes the value plus an init and a release action. Both areThrowingConsumer<T>, so they may declare checked exceptions.
package io.koraframework.guide.dependencyinjection.activity
import io.koraframework.application.graph.LifecycleWrapper
import io.koraframework.application.graph.Wrapped
import io.koraframework.common.annotation.Module
@Module
interface ActivityModule {
fun activityRecorder(): Wrapped<ActivityRecorder> { //(1)!
val recorder = object : ActivityRecorder {
private var connected = false
override fun connect() {
if (!connected) {
println("Connecting to activity recorder")
connected = true
println("Activity recorder connected")
}
}
override fun disconnect() {
if (connected) {
println("Disconnecting from activity recorder")
connected = false
}
}
override fun isConnected(): Boolean = connected
override fun recordUser(user: String) {
if (!connected) {
connect()
}
println("Recording user activity: $user")
}
}
return LifecycleWrapper(recorder, {}, ActivityRecorder::disconnect) //(2)!
}
}
- Returning
Wrapped<T>registers the node underWrapped<ActivityRecorder>but lets consumers claim plainActivityRecorder; Kora unwraps it automatically. LifecycleWrappertakes the value plus an init and a release action. Both areThrowingConsumer<T>, so they may throw.
Application note: No Application changes are required here either. ActivityModule is also discovered as an application module from the application compilation unit.
Update NotifyRunner to demonstrate the final scenario:
@Root
@Component
public final class NotifyRunner implements Lifecycle {
private final All<Notifier> allNotifiers;
private final Storage<String> stringStorage;
private final ActivityService activityService;
public NotifyRunner(@Tag(Tag.Any.class) All<Notifier> allNotifiers,
Storage<String> stringStorage,
ActivityService activityService) {
this.allNotifiers = allNotifiers;
this.stringStorage = stringStorage;
this.activityService = activityService;
}
@Override
public void init() {
System.out.println("DI tutorial complete scenario start");
for (var notifier : allNotifiers) {
notifier.notify("Diana", "Welcome to Kora DI!");
}
stringStorage.save("Scenario payload for Diana");
activityService.recordActivityByUserName("Diana");
System.out.println("DI tutorial complete scenario done");
}
@Override
public void release() {
System.out.println("Application shutdown");
}
}
@Root
@Component
class NotifyRunner(
@Tag(Tag.Any::class) private val allNotifiers: All<Notifier>,
private val stringStorage: Storage<String>,
private val activityService: ActivityService
) : Lifecycle {
override fun init() {
println("DI tutorial complete scenario start")
for (notifier in allNotifiers) {
notifier.notify("Diana", "Welcome to Kora DI!")
}
stringStorage.save("Scenario payload for Diana")
activityService.recordActivityByUserName("Diana")
println("DI tutorial complete scenario done")
}
override fun release() {
println("Application shutdown")
}
}
Build and run:
ActivityService created (ActivityRecorder not yet accessed)
DI tutorial complete scenario start
+1 [SMS] Diana@Welcome to Kora DI!
[EMAIL OVERRIDDEN] USER [USER:Diana]: Welcome to Kora DI!
Broadcasting to messengers
Slack: Diana@Welcome to Kora DI!
Messenger broadcast complete
Saved to: storage-789012.tmp
Recording activity for: Diana
Connecting to activity recorder
Activity recorder connected
Recording user activity: Diana
Activity recorded successfully
DI tutorial complete scenario done
Application shutdown
Disconnecting from activity recorder
Note the last two lines: NotifyRunner.release() runs before the recorder is disconnected, because release() walks the graph in reverse dependency order.
Key Concept: ValueOf<T> prevents cascading component refreshes. The ActivityService instance is stable, but it can still access the current ActivityRecorder lazily when needed.
Guide Summary¶
You've built a complete Kora application demonstrating all major dependency injection concepts:
- Project Structure - Multi-module organization
- External Modules - Library components with
@DefaultComponent - Component Override - Customizing library defaults
- Tagged Dependencies - Multiple implementations with
@TagandAll<T> - Nullable Dependencies - JSpecify
@Nullable/ nullable types for graceful degradation - Submodules -
@KoraSubmodulefor component organization across Gradle modules - Generic Factories -
<T>parameterized component creation, and@FactoryModulefor stateful module instances - Preventing Cascading Refreshes -
ValueOf<T>to control component refresh behavior
Each step builds upon the previous, showing how Kora's compile-time DI enables clean, modular, and performant applications.
Best Practices¶
- Keep components small and focused on one responsibility.
- Prefer constructor injection and explicit module boundaries.
- Use tags only when multiple implementations really need to coexist.
- Keep optional dependencies explicit with nullable types or JSpecify
@Nullable. - Use
ValueOf<T>when you need controlled component refresh behavior, and callget()at use time instead of caching the value. - Enable the Kora processor in every Gradle module that declares
@KoraApp,@KoraSubmodule,@ConfigMapper, or@ConfigSource— the processor only sees the module it runs in. - Put reusable defaults behind
@DefaultComponentso applications can override them without forking the library.
Summary¶
Congratulations! You've completed the comprehensive Kora Dependency Injection Guide. You've learned not just how to use dependency injection, but why it's such a powerful pattern for building maintainable software.
The guide covered the main building blocks of a Kora graph: @KoraApp, @Component, @Module, external modules, @DefaultComponent, tags, All<T>, nullable dependencies, submodules, generic
factories, @FactoryModule, and ValueOf<T>. Together they show how to compose an application from small explicit parts while keeping dependency resolution type-safe and visible at compile time.
The same patterns are used in production services to build:
- High-performance microservices
- Scalable web applications
- Complex enterprise systems
- Cloud-native architectures
They make code easier to test, maintain, extend, and understand because dependencies are declared in constructors and factory methods instead of hidden inside implementation code.
Next learning milestones:
- Explore Kora Examples: Study the
kora-examplesrepository for real-world patterns - Build Your First App: Create a simple REST API using the tutorial patterns
- Add Observability: Learn Kora's telemetry and monitoring features
- Database Integration: Connect your app to a real database
- Deploy to Production: Learn containerization and cloud deployment
Key Concepts¶
- how
@KoraApp,@Component, and@Moduleshape the application graph - how tags distinguish multiple implementations of the same contract
- how collection and nullable dependency claims affect graph resolution
- how submodules and external modules help organize larger applications
- how
ValueOf<T>gives controlled access to refreshable components
Troubleshooting¶
Kora reports wiring problems while compiling, and every message names the claim, the place that requires it, the dependency tree that led there, and a Fix: block. Read that block first: it is
generated from the actual graph state, not from a static template. See also Container documentation: Graph build errors.
Common Issues and Solutions:
Circular Dependencies:
Problem: Two or more components depend on each other directly or indirectly.
Symptoms:
- Compile-time error starting with
Circular dependency found:, followed by aDependency cycle:listing every declaration in the cycle and ending with[CYCLE] - The suggested fix mentions
ValueOf<T>orPromiseOf<T>
Solutions:
- Refactor to Interface Segregation:
// Instead of circular dependency
@Component
class ServiceA { ServiceA(ServiceB b) {} }
@Component
class ServiceB { ServiceB(ServiceA a) {} }
// Use interfaces
interface ServiceAInterface { void methodA(); }
interface ServiceBInterface { void methodB(); }
@Component
class AImpl implements ServiceAInterface { AImpl(ServiceBInterface b) {} }
@Component
class BImpl implements ServiceBInterface { BImpl(ServiceAInterface a) {} }
// Instead of circular dependency
@Component
class ServiceA(val b: ServiceB)
@Component
class ServiceB(val a: ServiceA)
// Use interfaces
interface ServiceAInterface { fun methodA() }
interface ServiceBInterface { fun methodB() }
@Component
class AImpl(val b: ServiceBInterface) : ServiceAInterface {
override fun methodA() {}
}
@Component
class BImpl(val a: ServiceAInterface) : ServiceBInterface {
override fun methodB() {}
}
- Use ValueOf for Indirect Dependencies:
Missing Dependencies:
Problem: Component requires a dependency that cannot be found.
Symptoms:
- Compile-time error starting with
No component found for dependency:followed by the claimed type and either(no tags)orwith @Tag(...) - A
Note:section listing components of the same type but with a different tag, when the tag was simply forgotten - A
Fix:section suggesting@Component, a module method, or including a module in@KoraApp
Solutions:
- Add Missing Component:
- Create Factory Method:
Configuration Issues:
Problem: Components can't access configuration values.
Symptoms:
- Startup failure with
ConfigValueException: Config expected value, but got null at path: '...' for origin '...' - A graph build error for
ConfigorConfigValueMapper<T>when no configuration module is connected
Solutions:
- Add Configuration Module:
- Map the Configuration Section into a Typed Class:
@ConfigSource generates the mapper and registers the resulting DatabaseConfig as a graph component, so any component may just declare it as a constructor parameter. Methods without a default value
and without @Nullable are required: a missing key fails at startup with the ConfigValueException above. Use @ConfigMapper instead when a library type must stay path-agnostic, as EmailConfig
does in this guide.
Tag Resolution Issues:
Problem: Tagged dependencies cannot be resolved.
Symptoms:
- Compile error starting with
Multiple components match dependency:followed by the list of candidate declarations - Or
No component found for dependency:with aNote:listing the same type under a different tag
Solutions:
- Use Correct Tag Annotation:
- Check Tag Class Definition:
- Or Make One Candidate the Fallback:
Module Import Issues:
Problem: Components from modules are not available.
Symptoms:
No component found for dependency:for a type you know is declared in some module- The module lives in another Gradle module and is neither
@KoraSubmodulenor inherited throughextends
Solutions:
- Include Module in Application:
- Check Module Kind and Visibility:
@Module only affects the compilation unit it is compiled in. A @Module interface that lives in a different Gradle module is invisible until you either inherit it with extends or place a
@KoraSubmodule marker interface in that Gradle module and inherit that instead.
Collection Injection Issues:
Problem: All<T> doesn't inject expected components.
Symptoms:
- Fewer components than expected in
All<T> - Tagged implementations missing from the collection
Solutions:
- Ensure All Implementations Are in the Graph:
- Match the Tags You Actually Want:
@Component
public final class MyService {
public MyService(All<MyInterface> untagged, //(1)!
@Tag(Tag.Any.class) All<MyInterface> everything, //(2)!
@Tag(MyTag.class) All<MyInterface> onlyMyTag) { //(3)!
// ...
}
}
- An untagged claim matches untagged components only. Tagged implementations are silently absent.
Tag.Anymatches every component of that type regardless of tag.- A concrete tag matches only components carrying exactly that tag.
@Component
class MyService(
val untagged: All<MyInterface>, //(1)!
@Tag(Tag.Any::class) val everything: All<MyInterface>, //(2)!
@Tag(MyTag::class) val onlyMyTag: All<MyInterface> //(3)!
) {
// ...
}
- An untagged claim matches untagged components only. Tagged implementations are silently absent.
Tag.Anymatches every component of that type regardless of tag.- A concrete tag matches only components carrying exactly that tag.
This is the single most common surprise with All<T>: an empty or short collection almost always means the claim and the providers disagree about tags, not that the components are missing from the
graph. See Container documentation: Tag any.
Optional Dependency Issues:
Problem: Optional dependencies behave unexpectedly.
Symptoms:
- The dependency is
nullwhen a value was expected NullPointerExceptionon first use
Solutions:
- Handle Nullable Correctly:
import org.jspecify.annotations.Nullable;
@Component
public final class MyService {
private final @Nullable Dependency optionalDep;
public MyService(@Nullable Dependency optionalDep) {
this.optionalDep = optionalDep;
}
public void doSomething() {
// Safe nullable usage
if (optionalDep != null) { optionalDep.doWork(); }
// Dangerous - can cause NPE
// optionalDep.doWork(); // Don't do this without a null check
}
}
- Ensure Nullable Component Exists:
JSpecify @Nullable is a type-use annotation in Java, so its position matters for nested and generic types: write List<@Nullable String>, String @Nullable [], and Outer.@Nullable Inner. Kotlin
carries no annotation at all — the ? on the type is the declaration.
Lifecycle Issues:
Problem: Components with lifecycle methods don't start or stop.
Symptoms:
init()orrelease()never called- Resources not cleaned up on shutdown
Solutions:
- Implement the Lifecycle Interface:
import io.koraframework.application.graph.Lifecycle; //(1)!
@Component
public final class MyService implements Lifecycle {
@Override
public void init() throws Exception {
// Initialize resources here
}
@Override
public void release() throws Exception { //(2)!
// Clean up resources here
}
}
Lifecyclelives inio.koraframework.application.graph, not in the annotation package.- The shutdown callback is
release(). There is nodestroy()method in the contract.
import io.koraframework.application.graph.Lifecycle //(1)!
@Component
class MyService : Lifecycle {
override fun init() {
// Initialize resources here
}
override fun release() { //(2)!
// Clean up resources here
}
}
Lifecyclelives inio.koraframework.application.graph, not in the annotation package.- The shutdown callback is
release(). There is nodestroy()method in the contract.
- Check That the Component Is Actually in the Graph:
- Wrap a Third-Party Object You Cannot Modify:
Generic Type Issues:
Problem: Generic components (<T>) don't resolve correctly.
Symptoms:
No component found for dependency:for a concrete parameterization such asStorage<String>Component provider returns a raw type:when a factory returns a raw generic type
Solutions:
- Use Concrete Type Arguments:
- Make Every Template Parameter Resolvable:
Raw types are rejected outright with Raw component types are forbidden because they make dependency resolution ambiguous., so always write the type arguments.
Build and Compilation Issues:
Problem: The Kora processor does not run, or the generated graph class is missing.
Symptoms:
cannot find symbol: class ApplicationGraphKora submodule was not generated yet: expected type: ...SubmoduleImpl- Everything compiles, but no component is ever created
Solutions:
- Check Processor Wiring:
dependencies {
annotationProcessor "io.koraframework:annotation-processors" //(1)!
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
}
- The processor goes into
annotationProcessor, never intoimplementation. It must be declared in every Gradle module that contains@KoraApp,@KoraSubmodule,@ConfigMapper, or@ConfigSource.
plugins {
id("org.jetbrains.kotlin.jvm")
id("com.google.devtools.ksp") //(1)!
}
dependencies {
implementation(platform("io.koraframework:kora-bom:2.0.0.RC1"))
ksp("io.koraframework:symbol-processors:2.0.0.RC1") //(2)!
implementation("io.koraframework:config-hocon")
implementation("io.koraframework:logging-logback")
}
- Without the KSP plugin the
kspconfiguration does not exist and nothing is generated. - The processor goes into
kspwith an explicit version, becausekspis not covered by the BOM.
- Clean Build:
- Check Java Version:
Kora modules are compiled for Java 25, so both the Gradle toolchain and the JDK running Gradle must be 25 or newer.
Testing Issues:
Problem: Components are hard to test, or the test graph does not start.
Symptoms:
- Difficulty injecting test doubles
@TestComponentfields leftnull
Solutions:
- Use Constructor Injection for Plain Unit Tests:
// Testable component: no framework needed to construct it
@Component
public final class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
}
@Test
void testUserService() {
UserRepository stubRepo = id -> null;
UserService service = new UserService(stubRepo);
// Test...
}
- Start the Real Graph With
@KoraAppTest:
package io.koraframework.guide.dependencyinjection;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import org.junit.jupiter.api.Test;
import io.koraframework.test.extension.junit5.KoraAppTest;
import io.koraframework.test.extension.junit5.TestComponent;
@KoraAppTest(Application.class) //(1)!
class DependencyInjectionGuideSmokeTest {
@TestComponent //(2)!
private NotifyRunner notifyRunner;
@Test
void graph_ShouldStart() {
assertNotNull(notifyRunner);
}
}
- Builds the real
Applicationgraph for the test, so any wiring mistake fails the test instead of production startup. - Injects a component from that graph into the test instance.
package io.koraframework.guide.dependencyinjection
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
import io.koraframework.test.extension.junit5.KoraAppTest
import io.koraframework.test.extension.junit5.TestComponent
@KoraAppTest(Application::class) //(1)!
class DependencyInjectionGuideSmokeTest {
@TestComponent //(2)!
private lateinit var notifyRunner: NotifyRunner
@Test
fun graphShouldStart() {
assertNotNull(notifyRunner)
}
}
- Builds the real
Applicationgraph for the test, so any wiring mistake fails the test instead of production startup. - Injects a component from that graph into the test instance.
Both need io.koraframework:test-junit5 on testImplementation. For replacing graph components with mocks, overriding configuration, and Testcontainers-backed integration tests, see
Testing with JUnit 5.
Common Beginner Mistakes:
- Forgetting @Component Annotation:
- Ambiguous Constructors:
@Component
public final class MyService {
private MyService() {} // Wrong: no public constructor at all
}
@Component
public final class MyService {
public MyService() {}
public MyService(Dependency dep) {} // Wrong: two public constructors
}
// Correct: exactly one public constructor
@Component
public final class MyService {
public MyService(Dependency dep) {}
}
Kora reports both cases with @Component class must have exactly one public constructor. and suggests keeping one public constructor or moving complex construction into a module method.
- Not Including Modules:
- Circular Dependencies:
@Component
class A { A(B b) {} }
@Component
class B { B(A a) {} } // Wrong: circular dependency
// Break the cycle with interfaces or restructuring
interface AInterface {}
interface BInterface {}
@Component
class AImpl implements AInterface { AImpl(BInterface b) {} }
@Component
class BImpl implements BInterface { BImpl(AInterface a) {} }
- Ignoring Nullable Results:
@Component
public final class MyService {
private final @Nullable Dependency dep;
public MyService(@Nullable Dependency dep) {
this.dep = dep;
}
public void doSomething() {
dep.work(); // Wrong: can throw NullPointerException
}
}
// Safe usage
public void doSomething() {
if (dep != null) dep.work(); // Safe
}
- Registering the Same Module Twice:
@Module //(1)!
public interface MyModule {
default MyComponent myComponent() { return new MyComponent(); }
}
@KoraApp
public interface Application extends MyModule { //(2)!
}
- Already discovered automatically, because it is compiled together with
@KoraApp. - Inheriting it as well registers the same factories twice and leads to
Multiple components match dependency:. Pick one registration path.
@Module //(1)!
interface MyModule {
fun myComponent(): MyComponent = MyComponent()
}
@KoraApp
interface Application : MyModule { //(2)!
}
- Already discovered automatically, because it is compiled together with
@KoraApp. - Inheriting it as well registers the same factories twice and leads to
Multiple components match dependency:. Pick one registration path.
Getting Help:
If you're still stuck:
- Check the Examples: Look at
kora-examplesfor working patterns - Read Documentation: Consult the Container documentation for the full container contract
- Simplify: Remove complexity and test with minimal components
- Community: Ask questions in Kora community channels
Remember: Most DI issues come from missing components, incorrect module imports, mismatched tags, or circular dependencies. Start simple and build up gradually!
What's Next?¶
- Create Your First Kora Application if you completed the DI-only tutorial before building a runnable HTTP app.
- Configuration with HOCON or Configuration with YAML after getting started, to learn how typed configuration enters the graph.
- JSON Processing after getting started, to prepare request and response DTOs before the full HTTP Server guide.
- Testing with JUnit 5 to cover the graph you just built with component tests.
Help¶
If you encounter issues:
- check the Container documentation
- compare with Kora Java Dependency Injection App and Kora Kotlin Dependency Injection App
- run
./gradlew clean classesand read theFix:block of the first Kora error before changing code structure - verify that components are annotated with
@Componentor provided by a module, and that the Kora processor is enabled in that Gradle module