Compile-Time Dependency Injection: How Kora Builds an Application¶
August 3, 2026
Dependency injection is often introduced as a convenience mechanism: instead of constructing every object manually, a class declares what it needs and the container provides it. That description is useful, but it hides the more interesting architectural question: when does the container decide what the application actually is?
In many JVM frameworks, a significant part of that work happens at runtime. The container starts, discovers components, inspects constructors and factory methods, resolves dependencies, applies qualifiers, checks for cycles, creates internal definitions, and only then assembles the object graph. The Kora Framework deliberately moves most of that reasoning into compilation.
Its dependency injection engine is best understood as a compile-time graph compiler backed by a small runtime graph executor. During compilation, Kora discovers providers, resolves dependency claims, checks ambiguity, verifies tags and cycles, generates missing framework components, and produces the application graph. At runtime, the container executes that already known graph: it creates objects, initializes lifecycle-aware components, manages references, refreshes affected nodes when necessary, and shuts everything down in dependency order.
That separation is the key to understanding Kora DI.
DI as Graph Compilation¶
Consider a small application:
From the DI engine's perspective, these constructors describe a graph:
Each constructible component becomes a node, and each constructor or factory parameter becomes a dependency requirement that has to be resolved to another node.
For example:
can be read as a request for exactly one component compatible with OrderRepository.
A tagged dependency:
asks for exactly one matching OrderRepository with the required tag.
A collection-like dependency:
changes the request again: instead of exactly one provider, the constructor asks for all matching Handler components.
The compile-time container repeatedly resolves these requirements until it has constructed the reachable application graph. If a required provider does not exist, if several equally valid providers compete for a single injection point, or if a hard dependency cycle makes construction impossible, Kora stops compilation rather than postponing the error until startup.
This makes dependency injection less like runtime lookup and more like static graph resolution.
@KoraApp: The Root of the Application Graph¶
Every Kora application starts with an interface annotated with @KoraApp:
That interface defines the root assembly boundary of the application. It may contain factory methods directly, or it may inherit modules that contribute infrastructure and application-level providers.
A more realistic application might look like:
This is not merely organizational syntax. It tells the compiler which external modules are part of the application's dependency universe.
Kora deliberately avoids a model where every classpath dependency is scanned and arbitrary framework definitions are attached implicitly. Components and modules from the current compilation scope can
participate directly, while external modules are connected through explicit inheritance from the application interface. For multi-module builds, @KoraSubmodule provides an additional compile-time
boundary.
During annotation processing, Kora uses the @KoraApp interface as the starting point for generating ApplicationGraph. Startup then references that generated graph directly:
The graph is therefore not a runtime-discovered structure. It is a compiled representation of how the application should be assembled.
@Component: Declaring a Constructible Node¶
The simplest way to make an application class available to DI is @Component:
This declaration gives Kora two pieces of information. First, PaymentService is allowed to become a graph node. Second, its constructor tells the compiler which other nodes are required to create
it.
Kora keeps this model intentionally straightforward. A Java component has a public constructor, and its constructor parameters describe its dependencies. The framework does not generally try to instantiate arbitrary classes merely because they happen to be constructible.
That distinction matters. Suppose we write:
but no component, module factory, generic provider, or compile-time extension ever supplies PaymentRepository. Kora does not silently decide to instantiate some matching class. The dependency graph
is incomplete, so compilation fails.
This explicit registration model makes every node traceable to a concrete provider mechanism rather than allowing the container to invent application structure from incidental classpath contents.
Factory Methods: Providers Without @Component¶
Not every object can or should be annotated.
Third-party libraries are the obvious case. You may need an ExecutorService, a client from an external SDK, or some resource created through a builder API. Kora handles those through factory
methods:
To the graph compiler, these factory methods behave just like constructor-based providers. The first one contributes an ExecutorService node with no dependencies. The second contributes a
JobService node that depends on that executor.
Factory parameters are resolved using the same rules as constructor parameters, so the DI engine does not need a separate conceptual model for application classes and externally created objects.
This is one of the reasons Kora's container remains relatively easy to reason about: construction logic is expressed as ordinary Java or Kotlin methods, and the graph compiler uses those methods as provider definitions.
@Module: Grouping Related Providers¶
As applications grow, putting every factory method directly on @KoraApp becomes unwieldy. @Module allows related providers to be grouped:
These methods remain normal component providers. The module simply gives them a coherent boundary.
An application can then compose modules explicitly:
This makes module composition itself compile-time information. A library can publish provider modules, and the application chooses which ones become part of its graph without relying on runtime registration hooks or global discovery.
That matters for transparency. Looking at the @KoraApp interface tells you a meaningful part of the application's architectural composition before the service ever starts.
Reachability: Not Every Provider Becomes a Runtime Component¶
Kora's graph is not necessarily a registry of every component declaration it can discover. What ultimately matters is which nodes are reachable from the application's roots.
Consider:
If no root or reachable component depends on DebugReporter, there is no strong reason to instantiate it. Merely existing in source code does not automatically imply that it must participate in
runtime initialization.
Infrastructure that needs to start independently can instead be marked as a root:
HTTP servers, background workers, consumers, schedulers, and similar components often behave like graph roots because they represent active entry points into the running system.
This gives Kora DI a reachability-oriented model rather than a simple global bean registry. The resulting graph contains what the application actually needs to run.
Dependency Resolution as Exact Matching¶
Once Kora has discovered possible providers, the real resolution work begins.
Suppose we have:
The compiler effectively sees a dependency requirement described by type, tags, and cardinality. In this case the requirement is simple: resolve exactly one PaymentGateway.
If there is one valid provider, Kora creates the edge. If there are none, compilation fails. If there are several equally valid candidates, the dependency is ambiguous and compilation also fails.
For example:
combined with:
does not contain enough information to identify the intended provider.
Kora does not guess based on declaration order, classpath order, naming conventions, or some implicit ranking. The application has expressed an ambiguous architecture, so the compiler reports it.
This is an important design choice. Dependency injection remains deterministic because ambiguity has to be modeled explicitly instead of being hidden behind container heuristics.
Tags Are Part of Dependency Identity¶
Tags solve cases where several components share the same Java type but serve different roles.
Suppose there are two gateways:
A consumer can state which one it requires:
Architecturally, Kora is no longer resolving only a Java type. It is resolving a type together with a tag set.
That makes tags more than cosmetic qualifiers. They are part of the identity of the dependency request.
Using classes as tags instead of string names also keeps these distinctions within the normal Java type system. Tags can be navigated, renamed, and refactored through ordinary tooling instead of becoming fragile string identifiers.
One detail is especially important: an untagged injection point does not mean “any component of this type.” It asks specifically for the untagged variant. If the application deliberately wants to
ignore tag distinctions, Kora exposes Tag.Any for that purpose.
This keeps semantic boundaries explicit. A client configured for one database, tenant, region, or transport should not accidentally satisfy an unrelated injection point simply because the raw Java type matches.
All<T>: When More Than One Match Is Correct¶
Ambiguity is only an error when a dependency expects a single component.
Sometimes the application genuinely wants every implementation.
Imagine:
with several implementations:
A dispatcher can request all of them:
This changes the dependency from singular to plural.
A plain NotificationChannel requires exactly one compatible provider. All<NotificationChannel> resolves every matching provider and exposes them through an iterable collection.
Unlike a normal singular dependency, All<T> can also validly be empty. That makes it useful for optional extension points, plugin-like patterns, event handlers, validators, interceptors, or any
other scenario where the application wants to collect zero or more implementations.
Tags combine naturally with All<T>:
while @Tag(Tag.Any.class) allows the collection to deliberately span tagged and untagged implementations.
The DI model therefore has a small but expressive cardinality vocabulary: normal dependencies mean exactly one, nullable or optional dependencies mean zero or one, and All<T> means zero to many.
Defaults and Application Overrides¶
Libraries often need to provide a default implementation without preventing applications from replacing it.
Kora models this with @DefaultComponent:
An application can then provide another component with the same type and tags. The graph builder treats the default as a fallback rather than an equal candidate, so an explicit application-level implementation wins.
This avoids turning customization into a runtime bean-replacement mechanism. A module can ship reasonable defaults, while overriding them remains ordinary source-level composition.
Missing Dependencies Become Compile Errors¶
One of the clearest consequences of compile-time DI is the location of failure.
Suppose:
but the application does not provide PetRepository.
In a runtime DI model, that mistake may survive compilation, packaging, image creation, deployment, and process startup before the container finally reports that the graph cannot be built.
Kora already knows during annotation processing that no valid provider exists. Compilation therefore stops.
More importantly, the compiler has enough graph information to explain why the dependency was needed. It can report the missing type, the injection point, and the dependency path leading from a graph root to the unresolved node.
Conceptually, the failure can be understood as:
That graph path is useful because the place where resolution fails may be several dependencies away from the root that made the node reachable in the first place.
Compile-time DI therefore improves not only when the error happens, but also the context available to explain it.
Cycles Are Detected as Graph Structure¶
Cycles are a natural graph problem.
A valid dependency chain might look like:
while an accidental cycle looks like:
For example:
Because Kora resolves the graph during compilation, it can detect this structure before the application starts.
For hard dependencies that cannot be proxied or deferred, the cycle is a compile-time error. Kora can report the actual cycle path rather than allowing the service to fail only after object creation has already begun.
The framework can resolve some interface-based cycles through generated lazy proxies, but that does not change the underlying architectural point: cycles are visible to the graph compiler.
In many cases, the cleaner solution is not to rely on proxying at all, but to change the nature of one of the edges. That is where ValueOf<T> becomes important.
ValueOf<T> Changes the Meaning of a Dependency Edge¶
ValueOf<T> may initially look like a provider abstraction:
which can later be dereferenced:
But in Kora's graph model it represents something more important: an indirect dependency that does not participate in lifecycle propagation in the same way as a direct dependency.
Consider:
A direct dependency means that ServiceC is built against a specific ServiceB instance. If the graph later refreshes ServiceB, downstream components that directly depend on it may also need to be
rebuilt.
Now change the constructor to:
ServiceC now keeps a reference to the graph node rather than a fixed ServiceB instance. Calling serviceB.get() resolves the current value. If ServiceB is refreshed, ServiceC itself can
remain alive.
That makes ValueOf<T> useful at architectural boundaries where one component should observe changes in another without sharing its lifecycle.
A long-running HTTP server is a good example. The server owns a listening socket and should not need to restart merely because some downstream handler structure has changed. Holding a ValueOf to
that changing dependency allows the server to remain stable while still observing the latest graph value.
The same mechanism can break a hard cycle:
The relationship still exists semantically, but it is no longer a hard construction edge in the same sense.
This shows that Kora's graph models more than object references. It also models lifecycle propagation.
The Graph Can Be Refreshed at Runtime¶
Compile-time DI does not mean that every graph value is permanently frozen after compilation.
The shape of the graph is known ahead of time, but some nodes can be refreshed at runtime.
Kora's refreshable graph can rebuild a node and the directly affected downstream portion of the graph. The replacement subgraph is created and initialized first, and only then swapped into the running application. If initialization fails, the temporary graph is released and the previous graph remains active.
This transactional refresh model makes ValueOf<T> particularly important because it defines where refresh propagation stops.
Suppose we have:
If every edge is direct, refreshing the configuration may require rebuilding several downstream nodes.
If a long-lived component instead depends on:
that component can survive while observing the refreshed repository instance.
The graph is therefore static in structure but not necessarily static in value. Kora validates the topology at compile time and then lets runtime operations act within that validated topology.
Lifecycle Follows the Dependency Graph¶
The same graph used for injection also tells Kora how the application should start and stop.
Consider:
The database must be ready before the repository, the repository before the service, and the service before the server begins accepting traffic. During shutdown, the order should be reversed.
Kora can derive this naturally from graph dependencies.
Components that own resources can implement:
For example:
The container initializes dependencies before their dependents and releases them in reverse order. AutoCloseable integrates with shutdown as well, and factory-created objects can be wrapped when the
original type cannot implement Kora's lifecycle interface directly.
This makes lifecycle a property of the application graph rather than a completely separate startup subsystem.
Parallel Initialization Falls Out Naturally¶
Once the graph is known, Kora also knows which branches are independent.
Suppose the startup graph looks like:
The database branch and Kafka branch do not depend on each other, so they can initialize concurrently until they reach a node that requires both.
Because dependency ordering is explicit, runtime startup can schedule ready nodes in parallel instead of initializing the whole application serially.
This is an understated benefit of compile-time graph construction. The graph is not only a DI lookup structure; it is also an execution plan for initialization and shutdown.
Kora can therefore initialize independent lifecycle nodes on virtual threads while still preserving dependency correctness.
@KoraSubmodule: Scaling DI Across Build Modules¶
Large applications often span several Gradle modules:
Kora's @KoraSubmodule provides a compile-time boundary for these cases.
A module can declare:
Kora processes the components and modules inside that compilation unit and generates the submodule interface needed by the application.
The main application then composes them:
At runtime there is still one application graph, but discovery work is organized around compilation boundaries.
That is important for incremental builds because changes inside one project module do not necessarily require rediscovering every component in the entire codebase. The module boundary therefore helps not only code organization but also DI compilation scalability.
Framework Features Use the Same DI Engine¶
The container is not limited to handwritten application classes.
Many Kora integrations participate in the same resolution process.
Suppose a generated HTTP handler requires:
but the application has not declared such a component explicitly.
A Kora compile-time extension can recognize that the requested dependency is generatable, create the necessary JSON writer, and feed the resulting provider back into the DI graph.
Conceptually:
need JsonWriter<OrderResponse>
↓
no explicit provider
↓
ask compile-time extensions
↓
JSON processor can generate it
↓
generate $OrderResponse_JsonWriter
↓
register generated provider
↓
continue graph resolution
The same pattern is used by repositories, declarative HTTP clients, configuration mappings, validation, gRPC, and other generated features.
This is one of the most important architectural details in Kora. The DI compiler and the code-generation subsystems cooperate. Generated framework infrastructure becomes ordinary graph nodes, and those nodes are resolved with the same type-and-tag rules as handwritten components.
Compile-Time DI as Architecture Validation¶
Once all of these mechanisms are combined, compilation becomes more than syntax and type checking.
Kora can verify questions such as whether every dependency has a provider, whether a dependency is ambiguous, whether tags match, whether graph roots are constructible, whether hard cycles exist, whether required generated infrastructure can be produced, and whether the application can form a valid lifecycle graph.
These are architectural properties.
A traditional runtime DI framework may not answer them until the application context is created. Kora answers much of them while the compiler is already processing source.
The failure boundary therefore moves earlier:
runtime-oriented model:
compile
→ package
→ build image
→ deploy
→ start JVM
→ build DI container
→ fail
becomes:
That is why compile-time DI should not be reduced to a startup optimization.
Faster startup is valuable, but the deeper benefit is that an invalid application graph is treated much more like invalid source code.
What the Runtime Container Still Does¶
Kora does still have a runtime dependency container.
Its job is simply different.
At compile time, Kora determines which providers exist, how dependencies resolve, how tags and cardinalities match, which components are reachable, where cycles exist, and how the final graph is wired.
At runtime, the graph executor creates singleton values, initializes lifecycle-aware components, stores node state, serves indirect references such as ValueOf<T>, performs graph refreshes, swaps
refreshed subgraphs into the live application, and releases components in the correct order.
A useful way to summarize the split is:
This keeps runtime dynamic where runtime state genuinely matters without requiring the application to rediscover its own architecture every time it starts.
The Application Is the Graph¶
A small Kora application can look deceptively simple:
But that source already encodes a substantial amount of architecture.
@KoraApp defines the graph boundary. @Component declarations introduce providers. Constructor and factory parameters define dependency requirements. Tags refine identity. All<T> changes
cardinality. ValueOf<T> changes lifecycle coupling. @Root defines active graph entry points. Lifecycle gives nodes startup and shutdown behavior. Modules contribute additional providers.
Compile-time extensions generate infrastructure that satisfies otherwise unresolved dependencies.
Kora then validates those relationships and emits the resulting application graph.
The complete process looks roughly like this:
source declarations
↓
discover providers
↓
identify graph roots
↓
resolve dependency requirements
↓
apply types, tags, defaults, and cardinality
↓
generate framework-provided components
↓
detect missing, ambiguous, or cyclic dependencies
↓
generate ApplicationGraph
↓
compile
↓
runtime initializes and manages the graph
Traditional dependency injection is often described as a container that finds objects for you.
Kora's model is more precise than that.
The compiler proves how the application can be assembled, generates that assembly graph, and the runtime executes it.
That is the architectural difference behind Kora's compile-time dependency injection.