Skip to content

Dependency injection & auto-wiring

Katalyst discovers components by scanning the packages passed to scanPackages and matching classes against a small set of interfaces and function shapes. There are no DI annotations and no module files: implementing the interface (or returning the right type) is the signal. Constructor parameters are resolved by type at instantiation.

Discovery signals

You write… Discovered as Module
A class implementing Component A managed component (observers, helpers) katalyst-core
A class implementing Service A service with transactionManager and transactional helpers katalyst-core
A class implementing CrudRepository<Id, Entity> A repository katalyst-persistence
An object/class implementing Table<Id, Entity> A table katalyst-persistence
A class implementing EventHandler<T> An event handler katalyst-events
A class implementing StartupHook or ReadyHook A lifecycle hook (runs at startup, or once the server is ready) katalyst-di
A class implementing KatalystMigration A migration katalyst-migrations
A class annotated @ConfigPrefix, or a class implementing ConfigBinding A typed config binding katalyst-config-provider
fun Route.xxx() = katalystRouting { … } A route module katalyst-ktor
fun Application.xxx() = katalystMiddleware { … } Middleware katalyst-ktor
fun Route.xxx() = katalystWebSockets { … } A WebSocket route katalyst-ktor
fun Application.xxx() = katalystExceptionHandler { … } Exception handlers katalyst-ktor
A function returning SchedulerJobHandle A scheduler registration katalyst-scheduler

Component

import io.github.darkryh.katalyst.core.component.Component

class RegistrationMonitor(private val eventBus: EventBus) : Component {
    // implementing Component is the only signal needed
}

Component is for lightweight collaborators that need DI but are not transactional services.

Service

import io.github.darkryh.katalyst.core.component.Service

class AuthService(private val repository: AuthAccountRepository) : Service {
    suspend fun load(id: Long) = transactionManager.transaction { repository.findById(id) }
}

Service extends Component and adds transaction helpers: the transactionManager property, plus transaction { … }, workflowTransaction { … }, and currentTransaction { … }. See Transactions.

Parameter injection rules

Katalyst resolves constructor parameters — and the parameters of route, middleware, and scheduler functions — by these rules:

  • Object and config parameters are resolved from the container by type.
  • Kotlin default values are honored when no binding exists for an optional parameter.
  • Nullable parameters with no available binding resolve to null.
  • Required scalar parameters (Int, Long, Boolean, String) must have a default value or be supplied as part of a registered config object — Katalyst will not invent a scalar.
  • Missing required dependencies fail fast at startup with diagnostics naming the type.
class NotificationOrchestrator(
    private val gateway: RealtimeGateway,      // resolved by type
    private val auditTrail: AuditTrail?,       // null if no binding
    private val retryLimit: Int = 3            // default used; scalars need a default
) : Service

Deferral wrappers (Provider<T>, Lazy<T>, () -> T) are not part of Katalyst auto-wiring.

Qualifiers

When two implementations of the same type are intentionally registered, disambiguate a parameter with @InjectNamed:

import io.github.darkryh.katalyst.di.injection.InjectNamed

class PaymentService(
    @InjectNamed("stripe") private val gateway: PaymentGateway
) : Service

Keep injection type-only and annotation-free everywhere else.

Resolving inside Ktor handlers

Inside a route, middleware, or WebSocket block, resolve dependencies with ktInject:

val service = call.ktInject<AuthService>()    // direct
val settings by ktInject<JwtSettingsService>() // delegate

See Ktor integration.

Validation and failure modes

During bootstrap Katalyst builds a dependency graph and validates it before instantiating anything. Failures are reported as typed ValidationErrors aggregated into a ValidationReport, and fatal problems throw FatalDependencyValidationException. Common errors:

Error Cause
MissingDependencyError A required parameter has no binding
CircularDependencyError Two or more components depend on each other
UninstantiableTypeError A discovered type cannot be constructed
InstantiationFailureError A constructor threw during instantiation
SecondaryTypeBindingError A class binds a disallowed secondary interface

Bean lifecycle at shutdown

Stopping the application closes every bean in the container that implements AutoCloseable, in reverse registration order, each instance exactly once. This is what stops a SchedulerService from firing jobs against a torn-down container and what releases the connection pool.

The container owns the lifecycle of everything registered in it, including instances a module handed over rather than built:

// Wrong: the container closes myClient at shutdown, and it is dead for whoever else holds it.
bootstrapKatalystContainer(
    additionalModules = listOf(katalystBeanModule { single<HttpClient> { myClient } }),
    /* ... */
)

Katalyst cannot tell the two apart — by the time the bean is registered, an instance the provider captured and one it constructed are the same thing, and caller modules arrive through the same channel (KatalystFeature.provideBeanModules(), additionalModules) as the framework's own. The same rule holds in tests: overrideBeanModules(...) beans are closed when the environment closes.

So do not register an instance that has to outlive the container. Build it per container, or register a wrapper that does not implement AutoCloseable.

See also