Dependency Injection with Kora¶
This guide introduces dependency injection and inversion of control through Kora's compile-time container. It covers how application objects declare dependencies through constructors, how @Component
and @Module make those objects available to the graph, how @Root decides what actually gets built, and how Kora validates wiring during compilation instead of discovering missing dependencies at
runtime. You will also see why compile-time DI changes startup behavior, type safety, and testability.
If you want to check your progress along the way, use the finished working example: Kora Java Dependency Injection Introduction App.
If you want to check your progress along the way, use the finished working example: Kora Kotlin Dependency Injection Introduction App.
What You'll Learn¶
You'll learn the fundamental concepts of dependency injection and understand:
- Core DI Concepts: What dependency injection is and why it matters
- Kora's Architecture: How compile-time DI works and its advantages
- Graph Roots: Why
@Rootdecides which components are actually created - Component Lifecycle: How components are created, initialized, and released
- Module System: How to organize and structure your application components
- Tags and Collections: How to disambiguate implementations and collect extension points
- Best Practices: Patterns for writing maintainable, testable code
What You'll Need¶
- JDK 25 or later, because Kora 2.0 itself is compiled for Java 25
- Gradle 9 or later
- A text editor or IDE
- Basic understanding of Java or Kotlin
Prerequisites¶
No Prerequisites Required
This guide is designed for beginners and does not require prior knowledge of dependency injection or Kora.
You only need basic Java or Kotlin familiarity, because the guide introduces Kora dependency injection concepts from first principles before showing framework-specific patterns.
Overview¶
Dependency injection is a way to assemble an application from explicit dependencies instead of letting objects create everything they need by themselves. A dependency is simply "something this class needs in order to work": a repository, a client, a configuration object, a cache, a clock, or another service.
For a tiny program, it is natural to write new everywhere. A controller can create a service, the service can create a repository, and the repository can create whatever it needs. But as soon as the
program grows, this becomes hard to maintain:
- classes know too much about how other classes are built
- tests become hard because dependencies are created inside the class
- replacing one implementation requires editing many places
- startup logic spreads across the codebase
- configuration and infrastructure details leak into business code
Dependency injection fixes this by changing the rule: a class should not build its own collaborators. It should declare what it needs, usually through a constructor, and let the application graph provide those objects.
Small Example¶
Without DI, a service might create its repository directly:
public final class UserService {
private final UserRepository repository = new InMemoryUserRepository();
}
That looks simple, but UserService is now tied to one repository implementation. A test cannot easily replace it. A future database repository cannot be plugged in without editing the service.
With constructor injection, the service only declares the dependency:
public final class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
}
Now UserService does not care whether the repository is in-memory, JDBC-backed, mocked in a test, or wrapped with caching. That decision moves to the application graph.
Object Graphs¶
An application is not just a pile of classes. It is a graph of objects connected by dependencies. For example:
This is called a dependency graph or object graph. Each arrow means "this object needs that object". Kora's main job is to build this graph correctly, start lifecycle-aware components in the right order, and fail the build when the graph cannot be assembled.
Thinking in graphs is one of the most important Kora concepts. When you add a controller, repository, HTTP client, cache, or configuration object, you are adding a node or edge to the graph.
Inversion of Control¶
The deeper idea behind dependency injection is inversion of control. Instead of a service deciding how to construct its repository, client, cache, or configuration, it only declares that it needs them. Object creation moves out of the service and into the application graph.
That changes the shape of application code:
- constructors describe required collaborators
- interfaces make replacement points explicit
- tests can provide mocks or alternate implementations
- startup wiring becomes a separate concern from business logic
Dependency Injection with Kora¶
Kora's compile-time container implements dependency injection at compile time. The @KoraApp interface marks the graph root, @Component marks graph-managed classes,
@Root marks the entry points that must always exist, and @Module contributes factories or framework capabilities. During compilation, Kora analyzes the graph and generates plain Java or Kotlin code
that creates and connects components. Nothing is discovered by reflection at runtime.
This gives Kora a different failure model from runtime DI frameworks. Missing dependencies, ambiguous bindings, dependency cycles, and unreachable roots are reported during the build rather than during application startup.
For beginners, the most important annotations are:
@KoraApp: the root interface of the application graph@Component: a class Kora can create automatically@Module: a collection of component factories or imported framework modules@Root: a component that must be created even if nothing depends on it
You can think of @KoraApp as the map of the application, @Component as a graph node, @Root as the places where the map is entered, and constructor parameters as arrows between nodes.
All of these annotations live in one package, io.koraframework.common.annotation:
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.DefaultComponent;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.common.annotation.KoraSubmodule;
import io.koraframework.common.annotation.Module;
import io.koraframework.common.annotation.Root;
import io.koraframework.common.annotation.Tag;
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.DefaultComponent
import io.koraframework.common.annotation.KoraApp
import io.koraframework.common.annotation.KoraSubmodule
import io.koraframework.common.annotation.Module
import io.koraframework.common.annotation.Root
import io.koraframework.common.annotation.Tag
The runtime types that describe graph relationships - All, ValueOf, PromiseOf, TypeRef, Lifecycle, and the KoraApplication entry point - live in io.koraframework.application.graph.
Compile-Time Injection¶
Compile-time DI means Kora checks and generates wiring during the build. That matters because many DI mistakes are structural mistakes:
- a required dependency has no provider
- two providers match the same dependency and Kora cannot choose
- a module was not imported into the application
- a component depends on another component that cannot be built
- the application declares no root at all, so there is nothing to build
In a runtime DI framework, some of these errors may appear only when the app starts. In Kora, the build fails earlier, before the application is packaged or deployed. This makes feedback faster and keeps production startup more predictable.
The generated graph is ordinary bytecode. There is no classpath scanning, no reflective constructor lookup, and no proxy generation at startup, which keeps startup time short and makes the application friendly to ahead-of-time compilation.
Discovery Scope¶
Kora does not blindly scan every class on the classpath. Components are discovered in Gradle modules that contain @KoraApp or @KoraSubmodule interfaces. Components from external libraries are also
not automatically available just because they exist in a JAR. A library normally exposes a module interface, and your application imports that module by extending it from @KoraApp.
This explicitness is important: it keeps the graph predictable, makes module boundaries visible, and avoids accidental component registration.
The practical learning flow is:
- understand why manual object creation becomes painful
- learn what a dependency is
- introduce constructor injection
- connect dependency injection to object graphs and inversion of control
- compare runtime DI with Kora's compile-time graph
- learn how Kora discovers components and modules
- see why generated graph code improves wiring feedback
DI Basics¶
This guide provides a comprehensive introduction to dependency injection (DI) and inversion of control (IoC) principles using the Kora framework. Whether you're new to these concepts or looking to deepen your understanding, this section will systematically build your knowledge from fundamental principles to practical implementation.
What Is Dependency Injection?¶
Dependency Injection is a fundamental design pattern that addresses how software components acquire and manage their dependencies. At its core, DI is about separating the creation of dependencies from their usage, allowing for more flexible and maintainable code architecture.
Core Concept: Instead of a component creating its own dependencies, those dependencies are provided (injected) from an external source. This external source is typically a dependency injection framework or container.
Basic Example:
// Traditional approach - component creates its own dependencies
public class OrderProcessor {
private Database database = new Database(); // Component creates dependency
private EmailService emailService = new EmailService();
public void processOrder(Order order) {
database.save(order);
emailService.sendConfirmation(order.getCustomerEmail());
}
}
// Dependency injection approach - dependencies are provided
public class OrderProcessor {
private final Database database;
private final EmailService emailService;
// Dependencies are injected through constructor
public OrderProcessor(Database database, EmailService emailService) {
this.database = database;
this.emailService = emailService;
}
public void processOrder(Order order) {
database.save(order);
emailService.sendConfirmation(order.getCustomerEmail());
}
}
// Traditional approach - component creates its own dependencies
class OrderProcessor {
private val database = Database() // Component creates dependency
private val emailService = EmailService()
fun processOrder(order: Order) {
database.save(order)
emailService.sendConfirmation(order.customerEmail)
}
}
// Dependency injection approach - dependencies are provided
class OrderProcessor(
private val database: Database,
private val emailService: EmailService
) {
// Dependencies are injected through primary constructor
fun processOrder(order: Order) {
database.save(order)
emailService.sendConfirmation(order.customerEmail)
}
}
Key Terminology:
- Dependency: Any object or service that a component requires to function
- Injection: The process of providing dependencies to a component
- Injector/Container: The mechanism responsible for creating and injecting dependencies
- Dependency claim: In Kora, the concrete request a constructor parameter makes - a type, an optional tag, and an optional wrapper such as
All<T>orValueOf<T>
Traditional Approach Problems¶
To understand the necessity of dependency injection, let's examine the challenges that arise without it and how DI provides solutions.
The Problem: Tight Coupling
Tight coupling occurs when components are directly dependent on specific implementations, making the system rigid and difficult to maintain. Consider this common pattern:
Problems with Tight Coupling:
- Testing Difficulties: The
UserServicecannot be tested in isolation because it directly instantiatesDatabaseConnection - Implementation Lock-in: Changing to a different database requires modifying the
UserServicecode - Hidden Dependencies: The constructor reveals nothing about what the service actually needs
- Resource Management Issues: Each instance creates its own database connection
- Configuration Problems: No way to configure the database connection externally
Dependency Injection Benefits¶
The Dependency Injection Solution:
public class UserService {
private final DatabaseConnection connection;
// Dependencies are explicitly declared
public UserService(DatabaseConnection connection) {
this.connection = connection;
}
public User findUserById(long id) {
return connection.query("SELECT * FROM users WHERE id = ?", id);
}
}
Key Benefits of Dependency Injection:
- Testability: Components can be tested with mock dependencies
- Flexibility: Different implementations can be injected based on environment
- Explicit Dependencies: Constructor parameters clearly document requirements
- Resource Management: Connection lifecycle can be managed externally
- Configuration: Database settings can be configured at the application level
Understanding Inversion of Control¶
Inversion of Control is the architectural principle that underlies dependency injection. IoC represents a fundamental shift in how control flow is managed in software systems.
Traditional Control Flow:
Inverted Control Flow:
Framework/Container -> Creates Objects -> Injects Dependencies -> Application Code Executes Business Logic
The Inversion Principle:
In traditional programming, your application code is responsible for:
- Creating all necessary objects
- Managing object lifecycles
- Coordinating between components
- Handling configuration
With IoC, these responsibilities are inverted:
- The framework creates objects
- The framework manages lifecycles
- The framework coordinates components
- The framework handles configuration
IoC Implementation Patterns:
- Factory Pattern: Centralized object creation
- Service Locator: Components request dependencies from a central registry
- Dependency Injection: Dependencies are pushed into components
Why IoC Matters:
IoC enables several important architectural benefits:
- Separation of Concerns: Business logic is separated from infrastructure concerns
- Modularity: Components can be developed and tested independently
- Maintainability: Changes to infrastructure don't affect business logic
- Testability: Components can be easily isolated for testing
In Code:
// Traditional approach - you control all object creation
public class Application {
public static void main(String[] args) {
Database db = new Database(); // You create
EmailService email = new EmailService(); // You create
OrderService service = new OrderService(db, email); // You create
service.processOrder(order); // You control
}
}
When Old Approaches Break¶
While the traditional approach of manually creating and managing dependencies works perfectly well for small applications with just a few classes, it becomes increasingly problematic as your application grows to dozens or hundreds of components.
Why Scale Matters:
The traditional approach requires you to manually instantiate and wire together every object in your application. For a small app with 3-5 classes, this is straightforward. But when your application contains 20, 50, or 100+ classes, this manual approach becomes a maintenance nightmare.
Example: A 20+ Class Application (Traditional Approach)
Imagine building an application with the following components:
public class EcommerceApplication {
public static void main(String[] args) {
// Infrastructure Layer (8 classes)
DatabaseConfig dbConfig = new DatabaseConfig("localhost", "ecommerce", "user", "pass");
DatabaseConnection dbConnection = new DatabaseConnection(dbConfig);
RedisConfig redisConfig = new RedisConfig("localhost", 6379);
RedisConnection redisConnection = new RedisConnection(redisConfig);
EmailConfig emailConfig = new EmailConfig("smtp.example.com", 587, "user@example.com");
EmailService emailService = new EmailService(emailConfig);
PaymentGatewayConfig paymentConfig = new PaymentGatewayConfig("payment_key_123");
PaymentGateway paymentGateway = new PaymentGateway(paymentConfig);
// Data Access Layer (6 classes)
UserRepository userRepository = new UserRepository(dbConnection);
ProductRepository productRepository = new ProductRepository(dbConnection);
OrderRepository orderRepository = new OrderRepository(dbConnection);
CartRepository cartRepository = new CartRepository(redisConnection);
AuditRepository auditRepository = new AuditRepository(dbConnection);
InventoryRepository inventoryRepository = new InventoryRepository(dbConnection);
// Business Logic Layer (8 classes)
UserService userService = new UserService(userRepository, emailService);
ProductService productService = new ProductService(productRepository, inventoryRepository);
CartService cartService = new CartService(cartRepository, productService);
OrderService orderService = new OrderService(orderRepository, paymentGateway, emailService);
PaymentService paymentService = new PaymentService(paymentGateway, orderRepository);
InventoryService inventoryService = new InventoryService(inventoryRepository, productRepository);
AuditService auditService = new AuditService(auditRepository);
NotificationService notificationService = new NotificationService(emailService);
// Presentation Layer (4 classes)
UserController userController = new UserController(userService, auditService);
ProductController productController = new ProductController(productService, auditService);
OrderController orderController = new OrderController(orderService, cartService, auditService);
CartController cartController = new CartController(cartService, auditService);
// Application Bootstrap (2 classes)
// ... and more
}
}
fun main() {
// Infrastructure Layer (8 classes)
val dbConfig = DatabaseConfig("localhost", "ecommerce", "user", "pass")
val dbConnection = DatabaseConnection(dbConfig)
val redisConfig = RedisConfig("localhost", 6379)
val redisConnection = RedisConnection(redisConfig)
val emailConfig = EmailConfig("smtp.example.com", 587, "user@example.com")
val emailService = EmailService(emailConfig)
val paymentConfig = PaymentGatewayConfig("payment_key_123")
val paymentGateway = PaymentGateway(paymentConfig)
// Data Access Layer (6 classes)
val userRepository = UserRepository(dbConnection)
val productRepository = ProductRepository(dbConnection)
val orderRepository = OrderRepository(dbConnection)
val cartRepository = CartRepository(redisConnection)
val auditRepository = AuditRepository(dbConnection)
val inventoryRepository = InventoryRepository(dbConnection)
// Business Logic Layer (8 classes)
val userService = UserService(userRepository, emailService)
val productService = ProductService(productRepository, inventoryRepository)
val cartService = CartService(cartRepository, productService)
val orderService = OrderService(orderRepository, paymentGateway, emailService)
val paymentService = PaymentService(paymentGateway, orderRepository)
val inventoryService = InventoryService(inventoryRepository, productRepository)
val auditService = AuditService(auditRepository)
val notificationService = NotificationService(emailService)
// Presentation Layer (4 classes)
val userController = UserController(userService, auditService)
val productController = ProductController(productService, auditService)
val orderController = OrderController(orderService, cartService, auditService)
val cartController = CartController(cartService, auditService)
// Application Bootstrap (2 classes)
// ... and more
}
With 100+ Classes, This Becomes Impossible:
- Your main method would be 1000+ lines long
- Understanding the dependency graph requires a separate diagram
- You must manually ensure components are created in the correct order
- Adding a new feature requires touching dozens of files
- A change to one component requires understanding its entire dependency chain
- Testing any component requires instantiating hundreds of objects and becomes a nightmare
- A single configuration change cascades through the entire application
- Adding a new feature requires updating the main method, potentially breaking existing initialization order
The Dependency Injection Solution:
With DI, you declare dependencies at the component level, and the framework handles all the complexity:
@KoraApp
public interface EcommerceApplication extends
InfrastructureModule, DataAccessModule, BusinessLogicModule, PresentationModule {
static void main(String[] args) {
KoraApplication.run(EcommerceApplicationGraph::graph);
}
}
// Each component just declares what it needs
@Component
public final class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final EmailService emailService;
public OrderService(OrderRepository orderRepository,
PaymentGateway paymentGateway,
EmailService emailService) {
this.orderRepository = orderRepository;
this.paymentGateway = paymentGateway;
this.emailService = emailService;
}
}
@KoraApp
interface EcommerceApplication :
InfrastructureModule, DataAccessModule, BusinessLogicModule, PresentationModule
fun main() {
KoraApplication.run(EcommerceApplicationGraph::graph)
}
// Each component just declares what it needs
@Component
class OrderService(
private val orderRepository: OrderRepository,
private val paymentGateway: PaymentGateway,
private val emailService: EmailService
)
The framework automatically:
- Creates all objects in the correct order
- Manages resource lifecycles
- Handles configuration injection
- Provides dependency resolution
- Enables easy testing with mocks
This is why dependency injection becomes essential as applications grow beyond a handful of classes.
// IoC/DI (framework controls object creation)
@KoraApp
public interface Application {
// Framework creates and injects everything reachable from a root
@Root
default OrderService orderService(OrderRepository repository) {
return new OrderService(repository);
}
static void main(String[] args) {
// Framework handles all object creation and injection
KoraApplication.run(ApplicationGraph::graph);
}
}
// IoC/DI (framework controls object creation)
@KoraApp
interface Application {
// Framework creates and injects everything reachable from a root
@Root
fun orderService(repository: OrderRepository): OrderService = OrderService(repository)
}
fun main() {
// Framework handles all object creation and injection
KoraApplication.run(ApplicationGraph::graph)
}
Benefits Comparison:
| Aspect | Traditional | Dependency Injection |
|---|---|---|
| Testing | Hard (uses real services) | Easy (inject mocks) |
| Flexibility | Low (hardcoded dependencies) | High (inject any implementation) |
| Reusability | Low (tied to specific implementations) | High (works with any compatible service) |
| Maintainability | Low (changes affect multiple places) | High (change injection, not code) |
| Clarity | Low (dependencies hidden) | High (constructor shows needs) |
Now that you understand the fundamentals, let's explore how Kora implements these concepts with compile-time dependency injection!
Kora Architecture¶
Kora uses compile-time dependency injection, which means:
- Build-time Analysis: Dependencies are analyzed during compilation by an annotation processor (Java) or a KSP symbol processor (Kotlin)
- Component Discovery: Classes annotated with
@Component,@Moduleinterfaces, and factory methods reachable from@KoraAppare collected - Root Selection: Resolution starts from
@Rootdeclarations and walks the dependency edges from there - Dependency Resolution: The processor resolves every dependency claim, detects cycles, and builds an acyclic graph
- Code Generation: An
<AppName>Graphclass is generated as ordinary Java/Kotlin source that produces anApplicationGraphDraw - Runtime Performance: No reflection and no classpath scanning - everything is resolved at compile time
Important Scope Limitation: Kora's processors only scan Gradle modules that contain
@KoraAppor@KoraSubmoduleinterfaces. Components in regular Gradle modules without these interfaces will not be discovered or processed by the DI system.
How It Works in Kora¶
- Annotation Processing:
@KoraAppinterfaces are processed at compile time byKoraAppProcessor - Component Discovery: Collects
@Componentclasses,@Moduleinterfaces, methods inherited by@KoraApp, and generated submodules within the current Gradle module - Dependency Resolution:
GraphBuilderresolves every dependency claim starting from the root set and detects cycles - Graph Generation: Generates the
<AppName>Graphclass holding oneNodeper component plus their initialization logic - Runtime Execution:
KoraApplication.run(...)initializes components in dependency order and installs a shutdown hook
Critical Scope Limitation: Kora's processors only process Gradle modules that contain
@KoraAppor@KoraSubmoduleinterfaces. Components in regular Gradle modules without these interfaces will be completely ignored by the DI system.
Architectural Benefits of Explicit Control: This deliberate design choice gives you complete control over your application's dependency graph. Unlike frameworks that automatically instantiate everything on the classpath, Kora ensures you explicitly declare what components you want. This prevents:
- Resource waste from unwanted component instantiation
- Security risks from transitive dependency components being activated
- Debugging complexity from unknown running components
- Performance overhead from classpath scanning
- Unpredictable behavior when dependencies change
With Kora, your @KoraApp interface serves as an explicit manifest of everything running in your application.
Generated Code¶
When you annotate an interface with @KoraApp, the processor generates two types next to it:
$<AppName>Impl- a class implementing your application interface, used to invoke yourdefaultfactory methods<AppName>Graph- aSupplier<ApplicationGraphDraw>that builds the graph description, with astatic graph()accessor used as the entry point
A simplified sketch of what is generated for an interface called Application:
// Generated at compile time, in the same package as Application
public class ApplicationGraph implements Supplier<ApplicationGraphDraw> {
private static final ApplicationGraphDraw graphDraw;
private static final ComponentHolder0 holder0;
static {
var impl = new $ApplicationImpl(); //(1)!
graphDraw = new ApplicationGraphDraw(Application.class);
holder0 = new ComponentHolder0(graphDraw, impl); //(2)!
}
public static ApplicationGraphDraw graph() { //(3)!
return graphDraw;
}
@Override
public ApplicationGraphDraw get() {
return graphDraw;
}
public static final class ComponentHolder0 {
private final Node<MessageFormatter> component0; //(4)!
private final Node<EmailNotifier> component1;
// one Node per component in the graph
}
}
- Implementation of your
@KoraAppinterface; it is what actually calls yourdefaultfactory methods. - Components are registered in numbered holder classes, 500 components per class, so that very large graphs still compile.
- The static method referenced as
ApplicationGraph::graphwhen starting the application. - Each component becomes a
Node<T>that knows its factory, its create dependencies, and its refresh dependencies.
// Generated at compile time, in the same package as Application
class ApplicationGraph : Supplier<ApplicationGraphDraw> {
override fun get(): ApplicationGraphDraw = graphDraw
companion object {
val graphDraw: ApplicationGraphDraw
init {
val impl = `$ApplicationImpl`() //(1)!
graphDraw = ApplicationGraphDraw(Application::class.java)
holder0 = ComponentHolder0(graphDraw, impl) //(2)!
}
fun graph(): ApplicationGraphDraw = graphDraw //(3)!
}
class ComponentHolder0(graphDraw: ApplicationGraphDraw, impl: `$ApplicationImpl`) {
val component0: Node<MessageFormatter> //(4)!
val component1: Node<EmailNotifier>
// one Node per component in the graph
}
}
- Implementation of your
@KoraAppinterface; it is what actually calls your interface factory functions. - Components are registered in numbered holder classes, 500 components per class, so that very large graphs still compile.
- The function referenced as
ApplicationGraph::graphwhen starting the application. - Each component becomes a
Node<T>that knows its factory, its create dependencies, and its refresh dependencies.
You never write this class by hand, but knowing that it exists is useful: it is regular source code you can open in build/generated, step through in a debugger, and read when a wiring decision is
unclear.
Compile Time and Runtime¶
Compile Time (Annotation Processing):
- Analyzes source code for components and dependencies within
@KoraApp/@KoraSubmodulemodules only - Validates the dependency graph (no cycles, all dependencies available, at least one root)
- Generates optimized initialization code
- Provides compile-time error checking
Runtime (Application Execution):
- Executes generated initialization code
- Initializes each node on its own virtual thread, respecting dependency order, so independent branches start in parallel
- Manages component lifecycle through
Lifecycle - Handles graceful shutdown through a JVM shutdown hook installed by
KoraApplication.run(...) - Supports component refresh via
ValueOf<T>when a source such as the configuration watcher signals a change
Scope Critical: Compile-time processing only occurs in Gradle modules containing
@KoraAppor@KoraSubmoduleinterfaces. Code in regular modules is not analyzed or processed at compile time.
Application code stays synchronous. Kora 2.0 runs blocking code on virtual threads instead of asking you to model everything as reactive streams or coroutines, so a component method is a plain method and a constructor is a plain constructor.
Annotation Processors¶
Kora's compile-time processing consists of:
KoraAppProcessor: main processor handling@KoraApp,@Module,@ComponentKoraSubmoduleProcessor: generates the<Name>SubmoduleImplinterface for every@KoraSubmoduleGraphBuilder: resolves dependency claims from the root set, detects cycles, and orders componentsComponentDependencyHelper: parses dependency claims from constructor and factory method parameters- Extensions: a pluggable mechanism that generates components on demand (JSON readers/writers, repositories, HTTP clients, config extractors, validators, mappers)
The processors are wired into the build as:
Scope Limitation: Kora's processors only activate and process code within Gradle modules that contain
@KoraAppor@KoraSubmoduleinterfaces. Code in regular Gradle modules is completely invisible to these processors.
Component Discovery Order¶
Before anything is resolved, the processor gathers every declaration it can see for the current @KoraApp:
- Classes annotated with
@Componentin the current Gradle module - Factory methods declared by the
@KoraAppinterface itself and by every interface it extends, whether or not those interfaces are annotated with@Module - Factory methods of
@Module-annotated interfaces found in the current Gradle module, including their inherited methods - Factory methods of
<Name>SubmoduleImplinterfaces generated from@KoraSubmoduleand extended by@KoraApp - Factory methods of nested modules contributed with
@FactoryModule - Components generated on demand by extensions while the graph is being resolved
Declarations with generic type parameters are kept separately as component templates and are only materialized when a concrete type is requested.
Scope Note: Component discovery only occurs within Gradle modules containing
@KoraAppor@KoraSubmoduleinterfaces. Components in regular Gradle modules will not be discovered regardless of their annotations.
Dependency Resolution Algorithm¶
- Root Set: collect every declaration annotated with
@Root; if the set is empty the build fails with@KoraApp has no root components - Claim Parsing: every constructor or factory method parameter becomes a
DependencyClaim- a type, an optional tag, and a claim kind such as required, nullable,ValueOf,PromiseOf, orAll - Component Matching: find declarations whose type is assignable to the claim type and whose tag matches the claim tag
- Conflict Resolution: if several match, candidates without
@DefaultComponentwin; if more than one remains, the build fails - Cycle Detection: a cycle fails the build unless the cycle edge is declared through an interface (or a non-final class), in which case Kora generates a delegating proxy for it
- Code Generation: emit node registration in topological order so that every dependency is registered before its consumer
Only components reachable from the root set end up in the graph. A factory method nobody depends on is never called, which is why entry points such as servers and consumers need @Root.
Core Annotations¶
Kora provides several key annotations for dependency injection, all in io.koraframework.common.annotation:
@KoraApp¶
Marks the main application interface and serves as the core of Kora's dependency container. This annotation labels the interface within which factory methods for creating components and module dependencies are defined. Each such interface produces its own dependency graph, and an application normally declares exactly one.
What @KoraApp Does:
- Container Entry Point: Defines the root of your application's dependency container
- Component Registry: Registers all factory methods and component accessors
- Module Integration: Connects external modules through interface inheritance
- Application Bootstrap: Provides the starting point for
KoraApplication.run(...)
Requirements:
- Must be an interface, not a class - applying
@KoraAppto a class fails with@KoraApp can only be applied to interfaces - One per application graph; the processor generates a separate graph class for every
@KoraAppinterface it finds - Can extend multiple module interfaces
- Must reach at least one
@Rootdeclaration, otherwise the build fails with@KoraApp has no root components
Container Building Process:
At compile time, Kora uses the @KoraApp interface to:
- Discover all factory methods and component dependencies
- Validate the dependency graph for cycles and missing components
- Generate optimized initialization code
- Create the
<AppName>Graphclass used at runtime
Why Interfaces? Multiple Inheritance and Factory Override Control
Kora requires @KoraApp and all modules to be interfaces rather than classes for architectural reasons that enable powerful dependency injection capabilities.
Multiple Inheritance: Interfaces support multiple inheritance, allowing your application to compose functionality from multiple modules:
Factory Method Override: Interface default methods can be overridden, giving you control over dependency injection at the language level:
// Library provides default implementation
@Module
public interface CacheModule {
@DefaultComponent
default Cache cache() {
return new InMemoryCache(); // Default implementation
}
}
// Your application can override with custom implementation
@KoraApp
public interface Application extends CacheModule { // <----- Connected module
@Override
default Cache cache() {
return new RedisCache(); // Override with Redis
}
}
// Library provides default implementation
@Module
interface CacheModule {
@DefaultComponent
fun cache(): Cache = InMemoryCache() // Default implementation
}
// Your application can override with custom implementation
@KoraApp
interface Application : CacheModule { // <----- Connected module
override fun cache(): Cache = RedisCache() // Override with Redis
}
Component as Factory Method: Components aren't limited to classes - they can also be defined as factory methods in interfaces, giving you declarative control over IoC:
@KoraApp
public interface Application {
// Component defined as factory method (not a class)
default UserService userService(UserRepository repository, EmailService email) {
// You control exactly how UserService is created
var service = new UserService(repository, email);
service.setTimeout(Duration.ofSeconds(30)); // Custom configuration
return service;
}
// Another component as factory method
default OrderProcessor orderProcessor(UserService userService, PaymentService payment) {
return new OrderProcessor(userService, payment, new OrderValidator());
}
}
@KoraApp
interface Application {
// Component defined as factory method (not a class)
fun userService(repository: UserRepository, email: EmailService): UserService {
// You control exactly how UserService is created
val service = UserService(repository, email)
service.setTimeout(Duration.ofSeconds(30)) // Custom configuration
return service
}
// Another component as factory method
fun orderProcessor(userService: UserService, payment: PaymentService): OrderProcessor =
OrderProcessor(userService, payment, OrderValidator())
}
Why This Design Matters:
- Intuitive Language-Level Control: IoC behavior is controlled using familiar language constructs (interfaces, default methods) rather than XML or reflection-driven configuration
- Type-Safe Configuration: Factory methods are checked at compile time, preventing runtime configuration errors
- Easy Testing: Factory methods can be overridden in tests to inject mocks without complex test frameworks
- Modular Composition: Multiple inheritance allows clean separation of concerns across different modules
- Override Flexibility: Change implementations by overriding a method, with no framework-specific configuration needed
This interface-based approach makes dependency injection feel like a natural extension of the language, giving you powerful IoC capabilities while maintaining simplicity and type safety.
Why Explicit Control Matters¶
Kora's design philosophy prioritizes explicit control over implicit magic. Unlike traditional DI frameworks that automatically scan the classpath and instantiate everything they find, Kora requires you to explicitly declare what dependencies you want in your application.
The Problem with Automatic Discovery:
- Unpredictable Behavior: You never know what will be instantiated just by adding a JAR to your classpath
- Hidden Dependencies: Components can be created without your knowledge, consuming resources
- Debugging Nightmares: When something goes wrong, you have to figure out what unwanted components are running
- Security Risks: Vulnerable components might be instantiated automatically
- Performance Issues: Every JAR on the classpath gets scanned, even if not needed
Kora's Explicit Approach:
@KoraApp
public interface Application extends
io.koraframework.http.server.undertow.UndertowPublicHttpServerModule, // Explicitly included
io.koraframework.database.jdbc.JdbcDatabaseModule, // Explicitly included
// io.koraframework.cache.caffeine.CaffeineCacheModule, // Commented out = not included
com.example.MyCustomModule { // Your custom module
}
@KoraApp
interface Application :
io.koraframework.http.server.undertow.UndertowPublicHttpServerModule, // Explicitly included
io.koraframework.database.jdbc.JdbcDatabaseModule, // Explicitly included
// io.koraframework.cache.caffeine.CaffeineCacheModule, // Commented out = not included
com.example.MyCustomModule // Your custom module
Benefits of Explicit Control:
- Predictable Dependencies: You know exactly what's running in your application
- Resource Efficiency: Only instantiate what you actually need
- Clear Dependency Graph: Easy to understand and debug component relationships
- Security by Design: No surprise instantiations from transitive dependencies
- Performance: No classpath scanning overhead - everything is resolved at compile time
- Maintainability: Changes to dependencies are explicit and tracked in code
Real-World Impact:
With automatic frameworks, developers often spend hours debugging why their application is slow or consuming unexpected resources. With Kora, if a component isn't reachable from a root in
your @KoraApp graph, it simply does not exist in your application - no surprises, no hidden costs.
@Component¶
Marks a class as a component (dependency) in the dependency container. All components in Kora are singletons - classes that have only one instance created throughout the application lifecycle.
Components are created only if they are root components (marked with @Root) or if they are required as dependencies by something reachable from a root.
What Components Are:
- Singleton Instances: One instance per application lifecycle
- Dependency Providers: Can be injected into other components
- Conditional Initialization: Created only if required by other components or marked with
@Root - Shared: The same instance is provided to all injection points
Important Scope Limitation: @Component classes can only be discovered and used within Gradle modules that contain either:
- A
@KoraAppinterface (main application module) - A
@KoraSubmoduleinterface (component discovery module)
Components in regular Gradle modules without these annotations will not be processed by Kora's processor.
Requirements:
- The class must not be abstract -
@Componenton an abstract class or an interface is ignored, and concrete implementations are used instead - It must have exactly one public constructor, otherwise the build fails with
@Component class must have exactly one public constructor - Constructor parameters become dependency claims
- Declaring the class
finalis the normal choice; a class that uses AOP aspects must not befinal, because Kora generates a proxy subclass for it - The class must live in a Gradle module with
@KoraAppor@KoraSubmodule
- The class must not be abstract -
@Componenton an abstract class or an interface is ignored, and concrete implementations are used instead - It must have a primary constructor, otherwise the build fails with
@Component class must have a primary constructor - Primary constructor parameters become dependency claims
- Classes are final by default, which is the normal choice; a class that uses AOP aspects must be declared
open, because Kora generates a proxy subclass for it - The class must live in a Gradle module with
@KoraAppor@KoraSubmodule
Component Lifecycle:
- Discovery: found by the processor during compilation
- Validation: dependencies checked at compile time
- Creation: instance created at application startup if reachable from a root
- Injection: the same instance provided to all dependent components
- Destruction:
Lifecycle#releaseis called by the container during shutdown, in reverse dependency order
@Module¶
Groups related component factories together and marks interfaces as modules to be injected into the dependency container at compile time. A module is an interface that contains factory methods for creating components. All factory methods within a module become available to the dependency container.
What Modules Do:
- Factory Collection: Group related component factories in one place
- Code Organization: Separate concerns across different modules
- Reusability: Modules can be shared across applications
- Override Support: Factory methods can be overridden in extending interfaces
Scope: @Module interfaces are processed within Gradle modules that contain @KoraApp or @KoraSubmodule interfaces. External modules from libraries are inherited through interface extension.
Module Types:
- Internal Modules:
@Moduleinterfaces defined in the same Gradle module as@KoraApp; they are picked up automatically, without being extended - Mixed-in Modules: any interface extended by
@KoraApp, even without@Module; its factory methods join the graph - External Modules: provided by libraries and connected by extending them from
@KoraApp - Submodules:
<Name>SubmoduleImplinterfaces generated from@KoraSubmodulein another Gradle module
Module Requirements:
- Must be an interface, not a class -
@Moduleon a class fails with@Module can only be applied to interfaces - Factory methods must have a body (
defaultin Java, a regular function body in Kotlin) - Factory methods must return a reference type; primitives are rejected
- Must be in the same Gradle module as
@KoraAppor@KoraSubmoduleto be discovered automatically
Factory Method Rules:
- Must return a component, and a raw generic return type is rejected
- Can take other components as parameters
- Parameters become dependency claims
- Parameters may be optional components (
@Nullablein Java,T?in Kotlin) - Methods are called in dependency order at startup
External Library Components: Components and modules from external libraries are not automatically discovered by Kora's processor. Even if a library contains
@Componentclasses or@Moduleinterfaces, they will be invisible to your application unless you explicitly extend their module interfaces in your@KoraAppinterface. This is a deliberate design choice for explicit dependency management.
@KoraSubmodule¶
Marks an interface for which to build a module for the current compilation module. The generated interface contains all components marked with @Module and @Component annotations found in that
Gradle module's source code. This annotation is particularly useful for multi-module Gradle applications where different modules contain different pieces of functionality, and the main @KoraApp
application is built in a separate module.
What @KoraSubmodule Does:
- Component Discovery: Scans the current Gradle module for
@Moduleand@Componentannotations - Module Generation: Creates a
<Name>SubmoduleImplinterface with all discovered modules and components - Multi-Module Support: Enables component sharing across Gradle modules
- Boundary Definition: Defines where Kora's processor scans for components
- Build Optimization: Enables Gradle's build caching and incremental compilation by isolating functionality into separate modules
Scope: @KoraSubmodule interfaces define the boundaries where Kora's processor will scan for components. Components outside these boundaries are not processed.
How It Works:
- Discovery: Finds all
@Moduleinterfaces and@Componentclasses in the current Gradle module - Inheritance: The generated
ApplicationModulesSubmoduleImplinterface inherits from all discovered@Moduleinterfaces - Factory Generation: Creates default methods for all discovered
@Componentclasses - Integration: Your
@KoraAppextends the@KoraSubmoduleinterface, and Kora resolves the generated…SubmoduleImplbehind it
Use Cases:
- Multi-Module Projects: Share components across Gradle modules
- Library Development: Expose components from a library module
- Modular Architecture: Separate concerns across different build modules
- Component Organization: Group related components by functionality
- Large Single Applications: Organize complex monolithic applications into isolated Gradle modules for better build performance and maintainability
- Build Optimization: Leverage Gradle's build caching by separating functionality into independent modules that can be built and cached separately
If the generated interface is not on the classpath yet, the build reports
Kora submodule was not generated yet. That normally means the module providing the@KoraSubmodulehas not been compiled, or the Kora processor is not applied to it.
@Root¶
Marks components that must always be created when the application starts, even if nothing else depends on them. Root components are the entry points of the graph: Kora starts dependency resolution from the root set and only builds what is reachable from it.
What @Root Does:
- Guaranteed Initialization: Component is always created at startup
- Graph Entry Point: Everything the root needs is pulled into the graph
- Lifecycle Management: The component participates in application startup and shutdown
- Entry Points: Perfect for servers, consumers, schedulers, and background services
Common Use Cases:
- HTTP Servers: Web servers that need to start listening immediately
- Message Consumers: Kafka consumers, queue processors
- Background Services: Cache warmers, health checkers, schedulers
- Bootstrap Components: anything that only produces side effects, such as preparing external state
@Root vs Regular Components:
- Regular Components: created only if reachable from a root through dependency edges
@RootComponents: always created at startup
When to Use @Root:
- The component provides a service that should always be running
- The component needs to start processing immediately (servers, consumers)
- The component performs critical initialization (schema preparation, cache warming, bucket creation)
- The component collects metrics or monitoring data
At least one root is required
A @KoraApp with no reachable @Root fails to compile with @KoraApp has no root components. Framework modules usually contribute their own roots - for example, an HTTP server module marks its
server component as a root - but an application that only defines plain components must mark at least one of them itself.
The same rule explains a subtle class of bugs: a Lifecycle component that only prepares external state and that nobody injects is dropped from the graph, and everything it pulled in disappears
with it. Mark it @Root.
@DefaultComponent¶
Marks factories or components that provide default implementations intended to be overridden by users. If another component of the same type and tag exists in the graph without this annotation, it
takes precedence during injection over the @DefaultComponent one.
What @DefaultComponent Does:
- Default Provision: Provides fallback implementations for components
- Override Support: Allows users to replace defaults without modifying library code
- Library-Friendly: Enables libraries to provide sensible defaults
- Priority System: Lower priority than non-annotated factories
Use Cases:
- Library Defaults: Libraries provide default implementations that users can override
- Configuration Options: Different implementations based on environment
- Extension Points: Allow users to customize behavior without changing library code
Override Behavior:
There are two ways to replace a default. You can override the method itself, or you can simply declare another factory of the same type without @DefaultComponent:
@KoraApp
public interface Application extends CacheModule { // <----- Connected module
// Option 1: override the method - the override carries no @DefaultComponent, so it wins
@Override
default Cache defaultCache() {
return new RedisCache();
}
}
@KoraApp
public interface OtherApplication extends CacheModule {
// Option 2: a different method providing the same type without @DefaultComponent also wins
default Cache applicationCache() {
return new RedisCache();
}
}
@KoraApp
interface Application : CacheModule { // <----- Connected module
// Option 1: override the function - the override carries no @DefaultComponent, so it wins
override fun defaultCache(): Cache = RedisCache()
}
@KoraApp
interface OtherApplication : CacheModule {
// Option 2: a different function providing the same type without @DefaultComponent also wins
fun applicationCache(): Cache = RedisCache()
}
Resolution Rule:
- Candidates are matched by type and tag
- If more than one matches, candidates without
@DefaultComponentare preferred - If exactly one non-default candidate remains, it is used
- If several non-default candidates remain, the build fails with
Multiple components match dependency
Best Practices:
- Use for library-provided defaults that users might want to customize
- Don't use for application-specific components
- Clearly document what defaults are available for override
@Tag¶
Allows differentiation of multiple implementations of the same type and provides selective injection based on tags. A tag is a class reference rather than a string, which gives better refactoring support and type safety. A component is registered with a specific tag and injected at points that request exactly the same tag.
What Tags Do:
- Implementation Selection: Choose specific implementations of interfaces
- Multiple Instances: Support multiple instances of the same type in one graph
- Type Safety: Uses class references instead of strings
- Refactoring Safe: The IDE can track tag usage across the codebase
The annotation carries exactly one class:
Basic Usage:
// Tag classes (usually empty marker classes)
public final class RedisTag {
private RedisTag() {}
}
public final class InMemoryTag {
private InMemoryTag() {}
}
// Tagged implementations
@Tag(RedisTag.class)
@Component
public final class RedisCache implements Cache {
// Redis implementation
}
@Tag(InMemoryTag.class)
@Component
public final class InMemoryCache implements Cache {
// In-memory implementation
}
// Selective injection
@Component
public final class UserService {
public UserService(@Tag(RedisTag.class) Cache cache) {
// Injects RedisCache specifically
}
}
// Tag classes (usually empty marker classes)
class RedisTag private constructor()
class InMemoryTag private constructor()
// Tagged implementations
@Tag(RedisTag::class)
@Component
class RedisCache : Cache {
// Redis implementation
}
@Tag(InMemoryTag::class)
@Component
class InMemoryCache : Cache {
// In-memory implementation
}
// Selective injection
@Component
class UserService(@Tag(RedisTag::class) private val cache: Cache) {
// Injects RedisCache specifically
}
Tag Application:
- On Classes:
@Tag(MyTag.class) @Component final class MyClass - On Factory Methods:
@Tag(MyTag.class) default MyClass myClass() - On Parameters:
MyService(@Tag(MyTag.class) Dependency dep) - On Annotations: a custom annotation meta-annotated with
@Tag(MyTag.class)behaves like that tag
Special Tags:
@Tag(Tag.Any.class): matches every component of the requested type, tagged or untagged@Tag(Tag.Factory.class): inside a nested module contributed with@FactoryModule, means "use the same tag as the enclosing module method"
Tag Matching Rules:
- An untagged claim matches only untagged components
- A tagged claim matches only components carrying exactly that tag class
Tag.Anymatches everything of the requested type- Matching compares the tag class itself; a tag hierarchy is not taken into account
Custom Tag Annotations:
@Tag(RedisTag.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
public @interface RedisCache {}
@Tag(InMemoryTag.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
public @interface InMemoryCache {}
// Usage
@RedisCache
@Component
public final class RedisCacheImpl implements Cache {}
@Component
public final class UserService {
public UserService(@RedisCache Cache cache) {/* ... */}
}
@Tag(RedisTag::class)
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class RedisCache
@Tag(InMemoryTag::class)
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class InMemoryCache
// Usage
@RedisCache
@Component
class RedisCacheImpl : Cache
@Component
class UserService(@RedisCache private val cache: Cache)
@Conditional¶
Makes a component's presence in the running graph depend on a condition evaluated during graph initialization. The condition itself is an ordinary graph component of type GraphCondition, registered
under a tag, so it can depend on configuration or on any other component.
import io.koraframework.application.graph.GraphCondition;
import io.koraframework.common.annotation.Conditional;
public final class ExportEnabled {
private ExportEnabled() {}
}
@KoraApp
public interface Application {
@Tag(ExportEnabled.class)
default GraphCondition exportEnabled(ExportConfig config) { //(1)!
return () -> config.enabled()
? GraphCondition.ConditionResult.matched("export.enabled = true")
: GraphCondition.ConditionResult.failed("export.enabled = false");
}
@Root
@Conditional(tag = ExportEnabled.class) //(2)!
default ExportJob exportJob(ExportClient client) {
return new ExportJob(client);
}
}
- A
GraphConditioncomponent registered under theExportEnabledtag. Exactly one such component must exist for that tag. - The component is created only when the condition reports
Matched; otherwise the node stays uninitialized and reading it throws.
import io.koraframework.application.graph.GraphCondition
import io.koraframework.common.annotation.Conditional
class ExportEnabled private constructor()
@KoraApp
interface Application {
@Tag(ExportEnabled::class)
fun exportEnabled(config: ExportConfig): GraphCondition = GraphCondition { //(1)!
if (config.enabled()) GraphCondition.ConditionResult.matched("export.enabled = true")
else GraphCondition.ConditionResult.failed("export.enabled = false")
}
@Root
@Conditional(tag = ExportEnabled::class) //(2)!
fun exportJob(client: ExportClient): ExportJob = ExportJob(client)
}
- A
GraphConditioncomponent registered under theExportEnabledtag. Exactly one such component must exist for that tag. - The component is created only when the condition reports
Matched; otherwise the node stays uninitialized and reading it throws.
Rules worth remembering:
- Exactly one
GraphConditionmay carry a given tag, otherwise the build fails withMultiple GraphCondition components match condition tag - A missing condition provider fails the build with
Component condition cannot be resolved - Conditions cascade: everything that exists only because of a conditional component is disabled together with it
- When two candidates of the same type are both conditional, Kora keeps both in the graph and lets the conditions choose at startup
Component Discovery Priority¶
When Kora needs to satisfy one dependency claim, it walks a fixed sequence of strategies. Understanding this order is essential for debugging dependency resolution issues and ensuring the correct implementations are used.
Resolution order for one claim (type + tag):
- Concrete declarations already known to the processor -
@Componentclasses, factory methods of the@KoraAppinterface and everything it extends,@Moduleinterfaces of the current Gradle module, generated submodules, nested@FactoryModulemodules, and components previously generated by extensions - Component templates - generic factory methods whose type parameters can be bound to the requested type
- Nullable fallback - if the claim is nullable, the dependency resolves to
null Optional<T>fallback - if the claim isOptional<T>and nothing providesT, an emptyOptionalis supplied- Extensions - an extension generates the component on demand (JSON readers and writers,
@Repositoryimplementations,@HttpClientimplementations, config extractors, validators, mappers) - Failure - otherwise the build fails with
No component found for dependency
Within step 1, when several declarations match the same type and tag:
- Candidates without
@DefaultComponentare preferred over@DefaultComponentones - If exactly one candidate remains, it is used
- If all remaining candidates are
@Conditional, all of them are kept and the condition decides at startup - Otherwise the build fails with
Multiple components match dependency
What This Means:
- A concrete factory method always wins over a generic template of the same type
- A
@DefaultComponentfrom a library is replaced simply by declaring your own factory of the same type and tag - Extensions are a last resort, which is why a hand-written
JsonWriter<Foo>component silently replaces the generated one - Nothing is created "just in case" - only what a root needs is built
Practical Example:
// Library default - lowest priority
@Module
public interface UserModule {
@DefaultComponent
default UserService userService() {
return new DefaultUserService();
}
}
// Application factory - wins, because it carries no @DefaultComponent
@KoraApp
public interface Application extends UserModule {
default UserService customUserService() {
return new CustomUserService();
}
}
// Library default - lowest priority
@Module
interface UserModule {
@DefaultComponent
fun userService(): UserService = DefaultUserService()
}
// Application factory - wins, because it carries no @DefaultComponent
@KoraApp
interface Application : UserModule {
fun customUserService(): UserService = CustomUserService()
}
Declaring Components¶
Components in Kora can be declared in multiple ways, each with different use cases. All component declaration methods require the code to be within Gradle modules that
contain @KoraApp or @KoraSubmodule interfaces - Kora's processor only scans these designated modules.
Automatic Factory (@Component)¶
Classes annotated with @Component are automatically registered if they meet the requirements:
Requirements:
- Not abstract
- Exactly one public constructor (Java) or a primary constructor (Kotlin)
- Not final only when AOP aspects are applied, because Kora subclasses the component to install them
- Constructor parameters become dependency claims
Basic Factory Methods¶
Methods with a body in @KoraApp or @Module interfaces that return components:
Use a factory method whenever construction needs a decision you want to keep in one place: choosing an implementation, wiring a third-party builder, or applying settings that do not belong in the component's own constructor.
Module Factory¶
Factory methods within @Module interfaces:
A @Module interface declared in the same Gradle module as @KoraApp joins the graph automatically - you do not have to extend it. Extending it from @KoraApp is still useful when you want to
override one of its methods.
External Module Factory¶
Modules from external dependencies, inherited through interface extension:
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule;
import io.koraframework.json.common.JsonModule;
@KoraApp
public interface Application extends
HoconConfigModule,
UndertowPublicHttpServerModule,
JsonModule {
// Inherits all factory methods from external modules
}
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.http.server.undertow.UndertowPublicHttpServerModule
import io.koraframework.json.common.JsonModule
@KoraApp
interface Application :
HoconConfigModule,
UndertowPublicHttpServerModule,
JsonModule {
// Inherits all factory methods from external modules
}
Explicit Import Required: External library components are not automatically available. You must explicitly extend the library's module interfaces in your
@KoraAppinterface. Simply adding a library to your classpath is not enough - the module interface extension makes the components available for dependency injection.
This explicit approach prevents the common problems of automatic frameworks:
- No surprise instantiation of unwanted components
- Clear visibility into what dependencies are actually used
- Better security through intentional inclusion
- Easier debugging and maintenance
Submodule Factory¶
Generated modules from @KoraSubmodule interfaces:
// Gradle module :persistence
@Module
public interface PersistenceModule {
default UserRepository userRepository() {
return new InMemoryUserRepository();
}
}
@KoraSubmodule
public interface PersistenceSubmodule {
// Generates factory methods for all @Module and @Component in this Gradle module
}
// Gradle module :application
@KoraApp
public interface Application extends PersistenceSubmodule { // <----- Connected submodule
// All components from the submodule are available
}
// Gradle module :persistence
@Module
interface PersistenceModule {
fun userRepository(): UserRepository =
InMemoryUserRepository()
}
@KoraSubmodule
interface PersistenceSubmodule {
// Generates factory methods for all @Module and @Component in this Gradle module
}
// Gradle module :application
@KoraApp
interface Application : PersistenceSubmodule { // <----- Connected submodule
// All components from the submodule are available
}
Generic Factory¶
Methods with generic type parameters that can create components of any matching type. These declarations are kept as component templates and are materialized only when a concrete type is requested. Generic factories are particularly useful for creating type-safe components that work with different generic types.
@Module
public interface ValidatorModule {
// Generic factory for List validators
default <T> Validator<List<T>> listValidator(Validator<T> validator, TypeRef<T> valueRef) {
return new IterableValidator<>(validator);
}
// Generic factory for Set validators
default <T> Validator<Set<T>> setValidator(Validator<T> validator, TypeRef<T> valueRef) {
return new IterableValidator<>(validator);
}
// Generic factory for Collection validators
default <T> Validator<Collection<T>> collectionValidator(Validator<T> validator, TypeRef<T> valueRef) {
return new IterableValidator<>(validator);
}
}
@Module
interface ValidatorModule {
// Generic factory for List validators
fun <T> listValidator(validator: Validator<T>, valueRef: TypeRef<T>): Validator<List<T>> =
IterableValidator(validator)
// Generic factory for Set validators
fun <T> setValidator(validator: Validator<T>, valueRef: TypeRef<T>): Validator<Set<T>> =
IterableValidator(validator)
// Generic factory for Collection validators
fun <T> collectionValidator(validator: Validator<T>, valueRef: TypeRef<T>): Validator<Collection<T>> =
IterableValidator(validator)
}
How It Works:
- The
<T>type parameter allows creating validators for any element type TypeRef<T>provides the concrete generic type information the factory was materialized for- Kora can create
Validator<List<String>>,Validator<Set<User>>, and so on - A concrete factory always wins over a template for the same type
- Raw types are rejected: a claim such as
Validatorwithout type arguments fails the build
Extension Mechanism¶
Extensions generate components on demand while the graph is being resolved. They run only when nothing else in the graph can satisfy a claim, which means a hand-written component of the same type always takes precedence.
Extensions ship with the corresponding Kora processors and cover, among others:
JsonReader<T>andJsonWriter<T>implementations for@Jsonannotated classes- Implementations of
@Repositoryinterfaces for JDBC and Cassandra - Implementations of
@HttpClientinterfaces - gRPC client stubs
- Config extractors for
@ConfigSourceand@ConfigMapperinterfaces Validator<T>implementations for@Validannotated types- MapStruct and Konvert mapper implementations
@KoraApp
public interface Application extends JsonModule {
@Root
default UserPrinter userPrinter(JsonWriter<User> writer) { //(1)!
return new UserPrinter(writer);
}
}
@Json
public record User(String name, int age) {}
- Nothing declares a
JsonWriter<User>; the JSON extension generates it while the graph is being resolved.
@DefaultComponent Factory¶
Default implementations that can be overridden:
@Module
public interface CacheModule {
@DefaultComponent
default Cache cache() {
return new InMemoryCache();
}
}
// Can be overridden in the application:
@KoraApp
public interface Application extends CacheModule { // <----- Connected module
default Cache primaryCache() {
return new RedisCache(); // Overrides the default
}
}
@DefaultComponent also works on classes, not only on factory methods, so a @Component class can be declared as a replaceable default.
Factory Module¶
@FactoryModule marks a module method whose return value is itself a module. The returned object becomes a component in the graph, and its public methods are treated as component factories. This
is how you register the same set of factories several times with different configuration.
public final class MessengerModule { //(1)!
private final String header;
public MessengerModule(String header) {
this.header = header;
}
@Tag(Tag.Factory.class) //(2)!
public Messenger messenger(@Tag(Tag.Factory.class) Transport transport) {
return new Messenger(this.header, transport);
}
}
@KoraApp
public interface Application {
@Tag(SlackTag.class)
@FactoryModule
default MessengerModule slackModule() { //(3)!
return new MessengerModule("SLACK");
}
@Tag(SignalTag.class)
@FactoryModule
default MessengerModule signalModule() {
return new MessengerModule("SIGNAL");
}
@Tag(SlackTag.class)
default Transport slackTransport() {
return new HttpTransport("https://slack.example.com");
}
@Tag(SignalTag.class)
default Transport signalTransport() {
return new HttpTransport("https://signal.example.com");
}
@Root
default Dispatcher dispatcher(@Tag(SlackTag.class) Messenger slack,
@Tag(SignalTag.class) Messenger signal) {
return new Dispatcher(slack, signal);
}
}
- A plain class whose public methods act as component factories.
@Tag(Tag.Factory.class)means "the tag of the enclosing@FactoryModulemethod", so each instance produces its own tagged components and consumes its own tagged dependencies.- The returned
MessengerModuleis registered in the graph under theSlackTagtag.
class MessengerModule(private val header: String) { //(1)!
@Tag(Tag.Factory::class) //(2)!
fun messenger(@Tag(Tag.Factory::class) transport: Transport): Messenger =
Messenger(header, transport)
}
@KoraApp
interface Application {
@Tag(SlackTag::class)
@FactoryModule
fun slackModule(): MessengerModule = MessengerModule("SLACK") //(3)!
@Tag(SignalTag::class)
@FactoryModule
fun signalModule(): MessengerModule = MessengerModule("SIGNAL")
@Tag(SlackTag::class)
fun slackTransport(): Transport = HttpTransport("https://slack.example.com")
@Tag(SignalTag::class)
fun signalTransport(): Transport = HttpTransport("https://signal.example.com")
@Root
fun dispatcher(
@Tag(SlackTag::class) slack: Messenger,
@Tag(SignalTag::class) signal: Messenger
): Dispatcher = Dispatcher(slack, signal)
}
- A plain class whose public functions act as component factories.
@Tag(Tag.Factory::class)means "the tag of the enclosing@FactoryModulefunction", so each instance produces its own tagged components and consumes its own tagged dependencies.- The returned
MessengerModuleis registered in the graph under theSlackTagtag.
@Tag(Tag.Factory.class) is only valid inside a type reached through @FactoryModule; using it elsewhere fails the build with @Tag.Factory can only be used inside factory modules.
No Automatic Creation¶
Kora never instantiates a class just because it happens to be instantiable. A type must be provided by one of the mechanisms above - a @Component class, a factory method, a template, or an
extension. If nothing provides it, the build fails:
This is deliberate. It means a graph never silently grows an object you did not ask for, and every node in the running application can be traced back to a declaration you wrote or a module you connected.
When a dependency genuinely may be absent, say so explicitly instead of relying on implicit creation:
Dependency Claims and Resolution¶
Kora turns every constructor and factory method parameter into a dependency claim: the requested type, an optional tag, and a claim kind derived from the wrapper type and nullability. This is the point where parameters stop being just Java or Kotlin types and become graph requirements.
Understanding dependency claims helps you read compiler errors. When Kora says that a dependency is missing, ambiguous, or cyclic, it is describing the claim it tried to resolve and the component candidates it found in the graph.
The claim kinds Kora recognises are:
| Parameter shape | Meaning |
|---|---|
T |
exactly one required component |
@Nullable T (Java) / T? (Kotlin) |
one optional component, null when absent |
Optional<T> |
one optional component, empty Optional when absent |
ValueOf<T> |
handle that reads the current value of the component |
PromiseOf<T> |
handle resolved after graph initialization, breaks cycles |
All<T> |
every matching component |
All<ValueOf<T>> / All<PromiseOf<T>> |
every matching component, each wrapped |
TypeRef<T> |
the concrete generic type a template was materialized for |
Graph / RefreshableGraph |
the graph itself, for infrastructure components |
Node<T> |
the graph node of a component, for infrastructure components |
Basic Dependency Types¶
Most Kora dependencies are expressed directly in constructors or factory method parameters. The shape of the parameter tells Kora whether the component is required, optional, lazily accessed, or a collection of implementations. These shapes let you model the relationship between components without adding container APIs to your business code.
Use the simplest shape that matches the domain rule. If the service cannot work without a repository, request the repository directly. If an integration is optional, mark it nullable. If you need all
implementations of an extension point, request All<T>. If you want to avoid refresh cascades or delay access to the actual component, request ValueOf<T>.
Required¶
Single required dependency that must exist. This is the default and most common dependency form. A required parameter means the application graph is invalid unless exactly one matching component is available. It is the right choice for core collaborators such as repositories, services, validators, configuration interfaces, and clients that are part of the normal application flow.
Required dependencies make failures explicit. If you forget to import a module or define a component, the build fails while Kora generates the graph instead of letting the application start with a partially configured runtime.
Optional¶
Single optional dependency that may be absent.
Optional dependencies are useful for optional features, optional integrations, or library defaults where the application may provide an extra component but does not have to. Kora still resolves the
dependency by type and tag, but absence is allowed and the generated graph passes null.
In Java, optionality is expressed with the JSpecify annotation org.jspecify.annotations.Nullable. In Kotlin it is expressed by the nullable type itself - no annotation is needed.
Use this deliberately. A nullable dependency should mean "the component can operate without this collaborator", not "I am unsure whether the graph is correct". Business code that receives a nullable dependency should branch explicitly and keep the degraded behavior easy to see.
Optional<T> expresses the same idea with a container instead of null, and is handy when the value is passed straight into an API that already speaks Optional:
ValueOf¶
Access to a component's current value.
ValueOf<T> is a handle to a component rather than the component itself. It lets a component read the current value when it needs it instead of capturing the instance once. This matters when the
dependency may be refreshed, for example after a configuration change: components that depend on T directly are recreated, while components that hold a ValueOf<T> are not.
In ordinary request-processing code you usually do not need ValueOf<T>. Prefer a direct dependency for simple service collaboration. Reach for ValueOf<T> when the lifecycle behavior matters:
configuration refresh, expensive components, or components that should not force their consumers to be recreated at the same time.
import io.koraframework.application.graph.ValueOf;
@Component
public final class OrderService {
private final ValueOf<UserService> userService;
public OrderService(ValueOf<UserService> userService) {
this.userService = userService;
}
public void process(Order order) {
this.userService.get().enrich(order); //(1)!
}
}
get()always returns the current instance, even after the component has been refreshed.
Can also be @Nullable when the wrapped component is optional:
PromiseOf¶
Deferred access to a component that may not exist yet when the consumer is created.
PromiseOf<T> returns an Optional<T> and is resolved after graph initialization. Use it for the rare case where a component must reference something that is created later in the graph, typically to
break a dependency cycle that cannot be restructured.
import io.koraframework.application.graph.PromiseOf;
@Component
public final class MetricsReporter {
private final PromiseOf<HttpServer> server;
public MetricsReporter(PromiseOf<HttpServer> server) {
this.server = server;
}
public void report() {
this.server.get().ifPresent(s -> log(s.port())); //(1)!
}
}
get()returns an emptyOptionaluntil the referenced component has been initialized.
All¶
All matching implementations of a type.
All<T> models extension points. Instead of choosing one implementation, Kora injects every matching implementation. This is useful for handlers, validators, listeners, interceptors, exporters, or
any place where the application should compose several independent contributions.
All<T> is an Iterable<T>, so you iterate it directly rather than treating it as a list.
The important design point is that every element in All<T> is still a graph component. Kora validates each implementation, applies tags if requested, and wires the collection at compile time. That
keeps plugin-like composition type-safe and visible in the generated graph.
import io.koraframework.application.graph.All;
@Component
public final class NotificationService {
private final All<Notifier> notifiers;
public NotificationService(All<Notifier> notifiers) {
this.notifiers = notifiers;
}
public void broadcast(String message) {
for (var notifier : this.notifiers) { //(1)!
notifier.notifyUser(message);
}
}
}
All<T>extendsIterable<T>, so a for-each loop is the natural way to consume it.
Elements can also be wrapped:
An untagged All<T> collects only untagged components
Tag matching applies to All<T> exactly as it applies to a single dependency. If your implementations carry tags, request @Tag(Tag.Any.class) All<T> to collect all of them, or request the
specific tag to collect one group. See @Tag.Any and Tagged All.
TypeRef¶
The concrete generic type a template was materialized for.
TypeRef<T> carries generic type information through type erasure. It is useful when a generic factory needs to know not just the raw class, but the full generic type requested by the graph. JSON
mappers, configuration extractors, serializers, and other generated infrastructure often need this kind of type token.
Most application services do not need to inject TypeRef<T> directly. Treat it as an infrastructure tool for code that creates or adapts components based on generic types. When you do use it, the type
parameter should describe the exact model shape the component is responsible for.
Wrapper Type Contract¶
Wrapper types are Kora's way to express dependency behavior without changing the component being requested. ValueOf<T> says "give me a handle to this component", PromiseOf<T> says "give me a handle
that is resolved later", and All<T> says "give me all matching components". The wrapped T is still the business type; the wrapper changes how Kora resolves and exposes it.
This distinction keeps APIs readable. A constructor that takes UserRepository needs one repository. A constructor that takes ValueOf<UserRepository> needs controlled access to a repository. A
constructor that takes All<Notifier> needs a collection of notifier implementations. Those signatures document the graph relationship directly in code.
There is also Wrapped<T>: a component declared as Wrapped<T> satisfies claims for T, which lets a module attach lifecycle handling to a third-party object without leaking the wrapper into
consumer signatures. LifecycleWrapper<T> is the ready-made implementation for exactly that.
Dependency Resolution Rules¶
Kora resolves dependencies in a predictable order. First it identifies the requested type shape, then applies tags and wrappers, then chooses the matching declaration. If the result is missing, ambiguous, or cyclic, graph generation fails with a compile-time error.
This is why explicit component declarations matter. Adding a dependency to the build file is not enough to make every component in that library appear in the graph. The application must import the right module, define the right component, or request the right tag. The generated graph is the final source of truth for what actually runs.
- Type Matching: dependencies are matched by type; a component whose type is assignable to the claim type is a candidate
- Tag Filtering: an untagged claim matches only untagged components, a tagged claim matches only that exact tag, and
Tag.Anymatches everything - Default Preference: candidates without
@DefaultComponentwin over@DefaultComponentones - Cycle Detection: circular dependencies are detected at compile time
- Nullability:
@NullableandOptional<T>mark optional dependencies and resolve to absence instead of failing - Raw Types Rejected: a claim on a raw generic type fails the build, because it makes resolution ambiguous
Indirect Dependencies¶
Use ValueOf<T> to avoid cascading component refreshes when dependencies get updated:
@Module
public interface ServiceModule {
default ServiceA serviceA() {
return new ServiceA();
}
default ServiceB serviceB() {
return new ServiceB();
}
default ServiceC serviceC(ServiceA serviceA, ValueOf<ServiceB> serviceB) {
// ServiceC depends on ServiceA directly (refreshes cascade to ServiceC)
// ServiceC depends on ServiceB indirectly via ValueOf<T> (prevents cascading refreshes)
return new ServiceC(serviceA, serviceB);
}
}
@Module
interface ServiceModule {
fun serviceA(): ServiceA = ServiceA()
fun serviceB(): ServiceB = ServiceB()
fun serviceC(serviceA: ServiceA, serviceB: ValueOf<ServiceB>): ServiceC {
// ServiceC depends on ServiceA directly (refreshes cascade to ServiceC)
// ServiceC depends on ServiceB indirectly via ValueOf<T> (prevents cascading refreshes)
return ServiceC(serviceA, serviceB)
}
}
Why ValueOf<T> matters: every node in the generated graph records both its create dependencies and its refresh dependencies. A direct dependency appears in both lists, so refreshing the
dependency recreates the consumer. A ValueOf<T> dependency appears only in the create list, so the consumer keeps working with the same instance and simply reads the new value through get().
Refreshes are triggered by the framework - for example by the configuration file watcher - not by application code.
Tag System¶
Tags allow multiple implementations of the same interface to coexist and be differentiated during dependency injection. Tags use class references instead of strings, which keeps renames safe and makes usages findable in the IDE.
Using Tags¶
// Tag classes (usually empty marker classes)
public final class RedisTag {
private RedisTag() {}
}
public final class InMemoryTag {
private InMemoryTag() {}
}
// Tagged implementations
@Tag(RedisTag.class)
@Component
public final class RedisCache implements Cache {
// Redis implementation
}
@Tag(InMemoryTag.class)
@Component
public final class InMemoryCache implements Cache {
// In-memory implementation
}
// Selective injection
@Component
public final class UserService {
public UserService(@Tag(RedisTag.class) Cache cache) {
// Injects RedisCache specifically
}
}
@Component
public final class ProductService {
public ProductService(@Tag(InMemoryTag.class) Cache cache) {
// Injects InMemoryCache specifically
}
}
// Tag classes (usually empty marker classes)
class RedisTag private constructor()
class InMemoryTag private constructor()
// Tagged implementations
@Tag(RedisTag::class)
@Component
class RedisCache : Cache {
// Redis implementation
}
@Tag(InMemoryTag::class)
@Component
class InMemoryCache : Cache {
// In-memory implementation
}
// Selective injection
@Component
class UserService(@Tag(RedisTag::class) private val cache: Cache) {
// Injects RedisCache specifically
}
@Component
class ProductService(@Tag(InMemoryTag::class) private val cache: Cache) {
// Injects InMemoryCache specifically
}
Class Tags¶
Tags can be applied directly to component classes:
Method Tags¶
Tags can be applied to factory methods:
Annotation Tags¶
Create reusable tag annotations by meta-annotating your own annotation with @Tag:
@Tag(RedisTag.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
public @interface RedisCache {}
@Tag(InMemoryTag.class)
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.PARAMETER})
public @interface InMemoryCache {}
// Usage
@RedisCache
@Component
public final class RedisCacheImpl implements Cache {}
@Component
public final class UserService {
public UserService(@RedisCache Cache cache) {
// Injects RedisCacheImpl
}
}
@Tag(RedisTag::class)
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class RedisCache
@Tag(InMemoryTag::class)
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.VALUE_PARAMETER)
annotation class InMemoryCache
// Usage
@RedisCache
@Component
class RedisCacheImpl : Cache
@Component
class UserService(@RedisCache private val cache: Cache) {
// Injects RedisCacheImpl
}
Special Tags¶
Special tag forms are useful when the default tag matching rules are too narrow. They let a component intentionally widen a request without losing type safety. This is most common with All<T>, where
you may want every implementation of an extension point, or every implementation that belongs to a specific tag group.
Use special tags sparingly. They are powerful because they change the meaning of a dependency request. A normal tag says "only this group"; Tag.Any says "ignore grouping"; a tagged All<T> says
"collect the whole group".
@Tag.Any¶
Matches all components regardless of their tags.
@Tag(Tag.Any.class) is the broadest request. It is useful when the consumer is intentionally generic, for example a registry, diagnostics component, or dispatcher that should see both tagged and
untagged implementations. Without Tag.Any, an untagged dependency matches only untagged components.
Because it widens the graph edge, Tag.Any should be visible in the constructor signature and used only where this broad behavior is part of the design. If a service only needs Redis caches or only
email notifiers, request that specific tag instead.
Tagged All¶
Get all components with a specific tag. This pattern collects all implementations that share a tag. It is useful when a subsystem has several implementations but they all belong to one named group, such as Redis-backed caches, public API interceptors, internal health checks, or a specific tenant/provider group.
The tag keeps the collection focused. Components of the same Java or Kotlin type can exist elsewhere in the graph without being included. That makes All<T> practical in larger applications where the
same interface may be reused for several independent purposes.
Tag Matching Rules¶
Tag matching is exact by design. Kora treats the tag as part of the dependency identity, alongside the type. This prevents accidental injection of the wrong implementation when several components share an interface but belong to different contexts.
When a dependency does not resolve, check both the type and the tag. A component with the right type but the wrong tag is not a match. The compiler helps here: the error message lists candidates of the same type that carry a different tag.
- Untagged to Untagged: a claim without a tag matches only components without a tag
- Exact Match: a tagged claim matches only components carrying exactly that tag class
Tag.AnyWins: a claim tagged withTag.Anymatches every component of that type- One Tag per Declaration:
@Tagcarries a single class, so a component belongs to at most one tag group - No Tag Inheritance: matching compares the tag class itself, so a subclass tag does not match its parent
Complete Example¶
The pieces above come together in a small runnable application: two notifier implementations distinguished by tags, a formatter provided as an overridable default, an optional audit sink that is not present in the graph, and a root service that collects every notifier.
package io.koraframework.guide.dependencyinjection;
@FunctionalInterface
public interface MessageFormatter {
String format(String message);
}
public interface Notifier {
String channel();
String notifyUser(String message);
}
public interface AuditSink {
void record(String channel, String message);
}
public final class EmailTag {
private EmailTag() {}
}
public final class SmsTag {
private SmsTag() {}
}
package io.koraframework.guide.dependencyinjection
fun interface MessageFormatter {
fun format(message: String): String
}
interface Notifier {
fun channel(): String
fun notifyUser(message: String): String
}
interface AuditSink {
fun record(channel: String, message: String)
}
class EmailTag private constructor()
class SmsTag private constructor()
Both notifiers are @Component classes, tagged so that they can be told apart:
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Tag;
@Tag(EmailTag.class)
@Component
public final class EmailNotifier implements Notifier {
private final MessageFormatter formatter;
public EmailNotifier(MessageFormatter formatter) {
this.formatter = formatter;
}
@Override
public String channel() {
return "email";
}
@Override
public String notifyUser(String message) {
return "EMAIL: " + formatter.format(message);
}
}
@Tag(SmsTag.class)
@Component
public final class SmsNotifier implements Notifier {
private final MessageFormatter formatter;
public SmsNotifier(MessageFormatter formatter) {
this.formatter = formatter;
}
@Override
public String channel() {
return "sms";
}
@Override
public String notifyUser(String message) {
return "SMS: " + formatter.format(message);
}
}
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Tag
@Tag(EmailTag::class)
@Component
class EmailNotifier(
private val formatter: MessageFormatter
) : Notifier {
override fun channel(): String = "email"
override fun notifyUser(message: String): String = "EMAIL: ${formatter.format(message)}"
}
@Tag(SmsTag::class)
@Component
class SmsNotifier(
private val formatter: MessageFormatter
) : Notifier {
override fun channel(): String = "sms"
override fun notifyUser(message: String): String = "SMS: ${formatter.format(message)}"
}
The service is the graph root. It requests every notifier with @Tag(Tag.Any.class), one specific notifier by tag, and an optional audit sink:
import io.koraframework.application.graph.All;
import io.koraframework.common.annotation.Component;
import io.koraframework.common.annotation.Root;
import io.koraframework.common.annotation.Tag;
import org.jspecify.annotations.Nullable;
import java.util.ArrayList;
import java.util.List;
@Root
@Component
public final class NotificationService {
private final All<Notifier> notifiers;
private final Notifier emailNotifier;
@Nullable
private final AuditSink auditSink;
public NotificationService(
@Tag(Tag.Any.class) All<Notifier> notifiers, //(1)!
@Tag(EmailTag.class) Notifier emailNotifier, //(2)!
@Nullable AuditSink auditSink) { //(3)!
this.notifiers = notifiers;
this.emailNotifier = emailNotifier;
this.auditSink = auditSink;
}
public List<String> broadcast(String message) {
var result = new ArrayList<String>();
for (var notifier : this.notifiers) {
var output = notifier.notifyUser(message);
result.add(output);
if (this.auditSink != null) {
this.auditSink.record(notifier.channel(), output);
}
}
return result;
}
public String notifyEmailOnly(String message) {
var output = this.emailNotifier.notifyUser(message);
if (this.auditSink != null) {
this.auditSink.record(this.emailNotifier.channel(), output);
}
return output;
}
public boolean isAuditEnabled() {
return this.auditSink != null;
}
}
- Both notifiers are tagged, so
Tag.Anyis required to collect them into oneAll<Notifier>. - A tagged claim selects exactly one implementation.
- Nothing provides
AuditSink, so the graph passesnullinstead of failing the build.
import io.koraframework.application.graph.All
import io.koraframework.common.annotation.Component
import io.koraframework.common.annotation.Root
import io.koraframework.common.annotation.Tag
@Root
@Component
class NotificationService(
@Tag(Tag.Any::class) private val notifiers: All<Notifier>, //(1)!
@Tag(EmailTag::class) private val emailNotifier: Notifier, //(2)!
private val auditSink: AuditSink? //(3)!
) {
fun broadcast(message: String): List<String> {
return notifiers.map { notifier ->
val output = notifier.notifyUser(message)
auditSink?.record(notifier.channel(), output)
output
}
}
fun notifyEmailOnly(message: String): String {
val output = emailNotifier.notifyUser(message)
auditSink?.record(emailNotifier.channel(), output)
return output
}
fun isAuditEnabled(): Boolean = auditSink != null
}
- Both notifiers are tagged, so
Tag.Anyis required to collect them into oneAll<Notifier>. - A tagged claim selects exactly one implementation.
- Nothing provides
AuditSink, so the graph passesnullinstead of failing the build.
Finally, the application interface connects the framework modules, declares a nested module with an overridable default formatter, and overrides it:
import io.koraframework.application.graph.KoraApplication;
import io.koraframework.common.annotation.DefaultComponent;
import io.koraframework.common.annotation.KoraApp;
import io.koraframework.common.annotation.Module;
import io.koraframework.config.hocon.HoconConfigModule;
import io.koraframework.logging.logback.LogbackModule;
@KoraApp
public interface Application extends HoconConfigModule, LogbackModule, NotificationModule {
static void main(String[] args) {
KoraApplication.run(ApplicationGraph::graph); //(1)!
}
default MessageFormatter messageFormatter() { //(2)!
return message -> "[app] " + message;
}
}
@Module
interface NotificationModule {
@DefaultComponent
default MessageFormatter defaultMessageFormatter() {
return message -> "[default] " + message;
}
}
ApplicationGraphis generated from theApplicationinterface;graph()returns the graph description.- This factory carries no
@DefaultComponent, so it wins overdefaultMessageFormatter().
import io.koraframework.application.graph.KoraApplication
import io.koraframework.common.annotation.DefaultComponent
import io.koraframework.common.annotation.KoraApp
import io.koraframework.common.annotation.Module
import io.koraframework.config.hocon.HoconConfigModule
import io.koraframework.logging.logback.LogbackModule
@KoraApp
interface Application : HoconConfigModule, LogbackModule, NotificationModule {
fun messageFormatter(): MessageFormatter { //(2)!
return MessageFormatter { message -> "[app] $message" }
}
}
@Module
interface NotificationModule {
@DefaultComponent
fun defaultMessageFormatter(): MessageFormatter {
return MessageFormatter { message -> "[default] $message" }
}
}
fun main() {
KoraApplication.run(ApplicationGraph::graph) //(1)!
}
ApplicationGraphis generated from theApplicationinterface;graph()returns the graph description.- This factory carries no
@DefaultComponent, so it wins overdefaultMessageFormatter().
The build wires the Kora BOM, the processor, and the two framework modules used above:
dependencies {
implementation platform("io.koraframework:kora-bom:$koraVersion")
annotationProcessor "io.koraframework:annotation-processors"
implementation "io.koraframework:config-hocon"
implementation "io.koraframework:logging-logback"
testImplementation "io.koraframework:test-junit5"
testAnnotationProcessor "io.koraframework:annotation-processors"
}
Testing the Graph¶
Because the graph is a compiled artifact, a test can start the real application graph and pull a component out of it. @KoraAppTest points at the @KoraApp interface, and @TestComponent injects a
component from the initialized graph:
import io.koraframework.test.extension.junit5.KoraAppTest;
import io.koraframework.test.extension.junit5.TestComponent;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@KoraAppTest(Application.class)
class NotificationServiceTest {
@TestComponent
private NotificationService notificationService;
@Test
void broadcastShouldUseAllNotifiers() {
var result = notificationService.broadcast("Hello");
assertEquals(2, result.size());
assertTrue(result.contains("EMAIL: [app] Hello"));
assertTrue(result.contains("SMS: [app] Hello"));
}
@Test
void notifyEmailOnlyShouldResolveTaggedNotifier() {
assertEquals("EMAIL: [app] Ping", notificationService.notifyEmailOnly("Ping"));
}
@Test
void optionalAuditDependencyShouldBeAbsent() {
assertFalse(notificationService.isAuditEnabled());
}
}
import io.koraframework.test.extension.junit5.KoraAppTest
import io.koraframework.test.extension.junit5.TestComponent
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Test
@KoraAppTest(Application::class)
class NotificationServiceTest {
@TestComponent
lateinit var notificationService: NotificationService
@Test
fun broadcastUsesAllTaggedAndUntaggedNotifiers() {
val result = notificationService.broadcast("hello")
assertEquals(2, result.size)
assertTrue(result.contains("EMAIL: [app] hello"))
assertTrue(result.contains("SMS: [app] hello"))
}
@Test
fun emailOnlyUsesTaggedComponent() {
assertEquals("EMAIL: [app] hello", notificationService.notifyEmailOnly("hello"))
}
@Test
fun optionalAuditSinkIsAbsent() {
assertFalse(notificationService.isAuditEnabled())
}
}
The assertion [app] rather than [default] is the override rule from @DefaultComponent verified end to end.
What's Next¶
Now that you understand the core concepts of Kora's dependency injection system, you're ready to put it all together. Continue with Building Kora DI Applications for a step-by-step tutorial that builds a complete notification system and demonstrates these concepts in a practical context.
The tutorial covers:
- Project setup and multi-module structure
- External library modules with defaults
- Component override and customization
- Tagged dependencies and collection injection
- Optional dependencies and graceful degradation
- Submodules and component organization
- Generic factories and type-safe creation
- Indirect dependencies with
ValueOf<T>
Best Practices¶
Keep Components Small and Focused¶
Why this matters: small components are easier to test, understand, and reuse. Each component should have a single responsibility.
Beginner Tip: if your component is doing too many things, break it apart. Ask yourself: "What is this component's one job?"
Good Example:
// Single responsibility components
@Component
public final class OrderValidator {
public ValidationResult validate(Order order) { /* validation logic */ }
}
@Component
public final class OrderProcessor {
private final PaymentService payment;
private final OrderRepository repository;
public OrderProcessor(PaymentService payment, OrderRepository repository) {
this.payment = payment;
this.repository = repository;
}
public void process(Order order) {
// Just coordinates payment and storage
payment.processPayment(order);
repository.save(order);
}
}
// Single responsibility components
@Component
class OrderValidator {
fun validate(order: Order): ValidationResult { /* validation logic */ }
}
@Component
class OrderProcessor(
private val payment: PaymentService,
private val repository: OrderRepository
) {
fun process(order: Order) {
// Just coordinates payment and storage
payment.processPayment(order)
repository.save(order)
}
}
Prefer Constructor Injection¶
Why this matters: constructor injection makes dependencies explicit and prevents partially constructed objects. It is the only injection style Kora supports for components, and it is the most testable one.
Beginner Tip: always put dependencies in the constructor. Never look dependencies up inside methods - that is the service locator anti-pattern.
Good Example:
@Component
public final class UserService {
private final UserRepository repository;
private final PasswordEncoder encoder;
// All dependencies declared in constructor
public UserService(UserRepository repository, PasswordEncoder encoder) {
this.repository = repository;
this.encoder = encoder;
}
public User createUser(String email, String password) {
String hashedPassword = encoder.encode(password);
User user = new User(email, hashedPassword);
return repository.save(user);
}
}
@Component
class UserService(
private val repository: UserRepository,
private val encoder: PasswordEncoder
) {
// All dependencies declared in the primary constructor
fun createUser(email: String, password: String): User {
val hashedPassword = encoder.encode(password)
val user = User(email, hashedPassword)
return repository.save(user)
}
}
Handle Optional Dependencies Gracefully¶
Why this matters: not all features are always available. Optional dependencies allow your application to work with different configurations.
Beginner Tip: in Java use JSpecify's @Nullable; in Kotlin use a nullable type. Always handle absence explicitly.
Good Example:
import org.jspecify.annotations.Nullable;
@Component
public final class NotificationService {
private final EmailService emailService;
@Nullable
private final SmsService smsService; // Might not be configured
public NotificationService(EmailService emailService, @Nullable SmsService smsService) {
this.emailService = emailService;
this.smsService = smsService;
}
public void sendNotification(String message) {
emailService.sendEmail(message); // Always available
// Graceful handling of optional dependency
if (smsService != null) {
smsService.sendSms(message);
}
}
}
@Component
class NotificationService(
private val emailService: EmailService,
private val smsService: SmsService? // Might not be configured
) {
fun sendNotification(message: String) {
emailService.sendEmail(message) // Always available
// Graceful handling of optional dependency
smsService?.sendSms(message)
}
}
Use Tags for Multiple Implementations¶
Why this matters: sometimes you need multiple implementations of the same interface (like different notification channels). Tags let you distinguish between them.
Beginner Tip: create dedicated marker classes for tags with descriptive names such as EmailTag, and give them a private constructor so nobody instantiates them by accident.
Good Example:
// Tag classes
public final class EmailTag {
private EmailTag() {}
}
public final class SmsTag {
private SmsTag() {}
}
// Tagged implementations
@Tag(EmailTag.class)
@Component
public final class EmailNotifier implements Notifier {
@Override
public void notifyUser(String message) { /* email logic */ }
}
@Tag(SmsTag.class)
@Component
public final class SmsNotifier implements Notifier {
@Override
public void notifyUser(String message) { /* SMS logic */ }
}
// Usage
@Component
public final class AlertService {
private final Notifier emailNotifier;
private final Notifier smsNotifier;
public AlertService(
@Tag(EmailTag.class) Notifier emailNotifier,
@Tag(SmsTag.class) Notifier smsNotifier) {
this.emailNotifier = emailNotifier;
this.smsNotifier = smsNotifier;
}
}
// Tag classes
class EmailTag private constructor()
class SmsTag private constructor()
// Tagged implementations
@Tag(EmailTag::class)
@Component
class EmailNotifier : Notifier {
override fun notifyUser(message: String) { /* email logic */ }
}
@Tag(SmsTag::class)
@Component
class SmsNotifier : Notifier {
override fun notifyUser(message: String) { /* SMS logic */ }
}
// Usage
@Component
class AlertService(
@Tag(EmailTag::class) private val emailNotifier: Notifier,
@Tag(SmsTag::class) private val smsNotifier: Notifier
)
Organize Components with Modules¶
Why this matters: modules group related components together, making your application easier to understand and maintain.
Beginner Tip: create modules for different layers (database, services, HTTP) or business domains (messaging, notifications, user management).
Good Example:
// Individual messenger modules for different channels
@Module
public interface SlackModule {
@Tag(SlackTag.class)
@DefaultComponent
default Supplier<String> slackMessengerHeaderSupplier() {
return () -> "ASCII_PROTOCOL_MESSENGER_SLACK";
}
}
@Module
public interface SignalModule {
@Tag(SignalTag.class)
@DefaultComponent
default Supplier<String> signalMessengerHeaderSupplier() {
return () -> "ASCII_PROTOCOL_MESSENGER_SIGNAL";
}
}
@Component
public final class SlackMessenger implements Messenger {
private final Supplier<String> headerSupplier;
public SlackMessenger(@Tag(SlackTag.class) Supplier<String> headerSupplier) {
this.headerSupplier = headerSupplier;
}
@Override
public void sendMessage(String message) {
String header = headerSupplier.get();
System.out.println(header + " ---> " + message);
}
}
@Component
public final class SignalMessenger implements Messenger {
private final Supplier<String> headerSupplier;
public SignalMessenger(@Tag(SignalTag.class) Supplier<String> headerSupplier) {
this.headerSupplier = headerSupplier;
}
@Override
public void sendMessage(String message) {
String header = headerSupplier.get();
System.out.println(header + " ---> " + message);
}
}
// Application combines messenger modules
@KoraApp
public interface Application extends
SlackModule, // Slack messaging
SignalModule { // Signal messaging
@Root
default Dispatcher dispatcher(@Tag(Tag.Any.class) All<Messenger> messengers) {
return new Dispatcher(messengers);
}
}
// Individual messenger modules for different channels
@Module
interface SlackModule {
@Tag(SlackTag::class)
@DefaultComponent
fun slackMessengerHeaderSupplier(): Supplier<String> = Supplier { "ASCII_PROTOCOL_MESSENGER_SLACK" }
}
@Module
interface SignalModule {
@Tag(SignalTag::class)
@DefaultComponent
fun signalMessengerHeaderSupplier(): Supplier<String> = Supplier { "ASCII_PROTOCOL_MESSENGER_SIGNAL" }
}
@Component
class SlackMessenger(
@Tag(SlackTag::class) private val headerSupplier: Supplier<String>
) : Messenger {
override fun sendMessage(message: String) {
val header = headerSupplier.get()
println("$header ---> $message")
}
}
@Component
class SignalMessenger(
@Tag(SignalTag::class) private val headerSupplier: Supplier<String>
) : Messenger {
override fun sendMessage(message: String) {
val header = headerSupplier.get()
println("$header ---> $message")
}
}
// Application combines messenger modules
@KoraApp
interface Application :
SlackModule, // Slack messaging
SignalModule { // Signal messaging
@Root
fun dispatcher(@Tag(Tag.Any::class) messengers: All<Messenger>): Dispatcher =
Dispatcher(messengers)
}
Avoid Common Anti-Patterns¶
Service Locator Pattern:
Circular Dependencies:
Kora reports this at compile time. When a cycle is unavoidable, express one of the edges through an interface and let Kora generate a delegating proxy, or request the dependency as PromiseOf<T>.
Restructuring the responsibilities is almost always the better fix.
Large Components:
// Don't create "God objects"
@Component
public final class HugeService {
// Does everything: validation, database, email, logging, caching...
private final Validator validator;
private final Repository repo;
private final EmailService email;
private final Logger logger;
private final Cache cache;
// Hundreds of methods...
}
// Don't create "God objects"
@Component
class HugeService(
// Does everything: validation, database, email, logging, caching...
private val validator: Validator,
private val repo: Repository,
private val email: EmailService,
private val logger: Logger,
private val cache: Cache
) {
// Hundreds of methods...
}
Summary of the rules worth keeping:
- Prefer constructor injection and let Kora build the dependency graph at compile time
- Keep components focused on one responsibility so graph errors stay easy to understand
- Use modules for reusable factories and default components, not as a place to hide application logic
- Use tags only when the same contract has multiple meaningful implementations
- Mark entry points with
@Root, and only entry points - Avoid service locators, circular dependencies, and large components that mix unrelated responsibilities
Summary¶
You learned the core ideas behind Kora dependency injection:
- components declare what they need through constructors or factory methods
- Kora validates and generates the dependency graph at compile time, with no reflection at runtime
- resolution starts from
@Root, so only what an entry point needs is built - modules group reusable factories, and
@DefaultComponentmakes them replaceable - tags disambiguate multiple implementations of the same type, and
All<T>collects extension points ValueOf<T>andPromiseOf<T>express indirect access without changing the business type- dependency injection keeps application structure explicit and testable
Troubleshooting¶
No component found for dependency
No component found for dependency:
com.example.UserRepository (no tags)
Required at:
com.example.Application#userService(com.example.UserRepository)
parameter: com.example.UserRepository repository
Dependency resolution path:
@--- factory com.example.Application#orderService(...)
^--- factory com.example.Application#userService(...)
^--- com.example.UserRepository [MISSING]
Fix:
- Add @Component to an implementation of com.example.UserRepository.
- Add a module method that returns com.example.UserRepository.
- Include a module that provides com.example.UserRepository in @KoraApp.
- Check that the class is annotated with
@Componentor returned from a module method - Verify that the module providing it is connected to the
@KoraAppinterface - Read the
Dependency resolution pathfrom the bottom: the[MISSING]line is the claim that failed, and the lines above it show who asked for it - If the error notes components "with the same type but different tag", the tag on the claim or on the component is wrong
Multiple components match dependency
Multiple components match dependency:
com.example.Cache (no tags)
Candidates:
- component com.example.RedisCache
- component com.example.InMemoryCache
Fix:
- Add different @Tag(...) annotations to candidates and request the needed tag.
- Mark fallback candidate with @DefaultComponent.
- Remove one duplicate provider.
- Add a tag to the claim and to the component that should satisfy it
- Or mark the fallback candidate with
@DefaultComponentso the other one wins - Or remove the duplicate provider
@KoraApp has no root components
- Annotate at least one component or module method with
@Root - If a framework module was supposed to contribute the root, check that the
@KoraAppinterface actually extends it
Circular dependency found
Circular dependency found:
com.example.ServiceA (no tags)
Dependency cycle:
@--- component com.example.ServiceA
^--- component com.example.ServiceB [CYCLE]
Fix:
- Break the cycle with ValueOf<T> or PromiseOf<T> where lazy access is valid.
- Move shared state into a separate component.
- Do not create dependency cycles in io.koraframework.application.graph.Lifecycle.
- Move the shared state into a third component that both sides depend on
- Express one edge through an interface so Kora can generate a delegating proxy for it
- As a last resort, request the dependency as
PromiseOf<T>orValueOf<T>
@Component class must have exactly one public constructor
- Keep one public constructor and make the extra ones non-public
- Or drop
@Componentand provide the class from a module method instead
Generated graph does not compile
- Fix the first reported error and compile again; later errors often depend on the first one
- Open the generated
<AppName>Graphinbuild/generatedwhen a wiring decision is unclear - it is ordinary source code
What's Next?¶
- Build a Complete DI Application to practice modules, components, factories, tags, lifecycle, and graph design without HTTP noise.
- Create Your First Kora Application if you read this introduction first and now want to run a minimal app.
- Configuration with HOCON or Configuration with YAML after getting started, because configuration depends on having a runnable Kora app.
Help¶
If you encounter issues:
- check the Container documentation
- compare with the basic examples in Kora Examples
- review Creating Your First Kora Application for a runnable graph