Tegral Featureful
Tegral Featureful is a framework for defining lightweight modules (called "features") that can be installed in full applications. It is the base building block for Tegral applications.
Note that Tegral Featureful in itself is not an "application engine". It only allows you to define you own features, but not actually run full applications. This task is delegated to Tegral Web AppDSL in the case of web applications.
Package name | Catalog dependency | Full Gradle name |
---|---|---|
tegral-featureful | tegralLibs.featureful | guru.zoroark.tegral:tegral-featureful:VERSION |
What are features?
Features are simply objects that act upon the dependency injection environment of an application. This generally means that:
- Simpler features will simply
put()
things in the environment that applications will then be able to request and consume. - More complex features will also add DI extensions that will automatically discover and interact with other elements within the dependency injection environment.
Creating a feature
Let's create a feature that just injects a dummy "Foo" object in the dependency injection environment. Features are defined by creating a feature object that implements the Feature
interface.
class Foo {
fun bar(): String = "Bar!"
}
object FooFeature : SimpleFeature {
override val id = "acme-foo"
override val name = "ACME Foo"
override val description = "Provides a Foo instance in the DI environment"
override fun ExtensibleContextBuilderDsl.install() {
put(::Foo)
}
}
Metadata
All features have an ID, a name and a description.
- The ID of a feature should be unique across all features. The
tegral-
prefix is reserved for first-party features (i.e. features created by the Tegral team that are part of the Tegral repository). - The name of a feature is the human-readable name of the feature.
- The description of a feature should be a short (approx. 1 sentence) description of what the feature does.
A feature may also define a set of dependencies, which are features that will be installed together with the current feature. Defining dependencies is done by overriding the dependencies
property:
override val dependencies = setOf(MyOtherFeature)
Installation
When using Tegral Web AppDSL, features can be installed using the install(...)
syntax, i.e.:
fun main() {
tegral {
install(FooFeature)
// ...
}
}
Feature types
The root interface for all features is Feature<T>
. However, simpler or more specialized interfaces are also available depending on what you need.
Here is a table with which interfaces you should use:
In-code configuration (of type T ) | No in-code configuration | |
---|---|---|
External configuration | ConfigurableFeature<T> | ConfigurableFeature<Unit> and SimpleFeature |
No external configuration | Feature<T> | SimpleFeature |
Configurable features
Features can consume configuration if they need additional information to act upon the environment.
There are two kinds of configurations features can use:
In-code configuration. This is configuration that is expected to rarely change for a given application, e.g. enabling a special experimental syntax, enabling a special subsystem, etc. This is defined within the code, when installing the feature into an environment.
External configuration. This is configuration that is expected to change often (or per-environment), and each element can be defined either through a file, environment variables, etc. The schema for the configuration file is defined in-code, while the values are provided externally.
In-code configuration
If you expect some configuration aspects to almost never change within a single application, you can define them in-code. When creating a feature, you can provide a type T
that will be used as the in-memory configuration type. When installing your feature, users will be able to use a lambda block, which uses said type as a receiver, to mutate some properties. Then, the Tegral application builder will call your install
function with the mutated T
object.
Defining in-code configuration is done by:
- Providing a
T
type parameter (see this section for choosing the appropriate feature interface) to theFeature
interface you implement. - Implement
createConfigObject()
: this function should return a new instance ofT
with the default values. - Use the object in your feature's
install
function
For example, here is a feature that prints the configuration values it receives on startup:
class PrintService(private val foo: String, private val bar: String) : TegralService {
override suspend fun start() {
println("Foo: $foo, Bar: $bar")
}
}
data class PrintConfig(
var foo: String = "Foo!",
var bar: String = "Bar!"
)
object PrintConfigFeature : Feature<PrintConfig> {
override val id = "example.print"
override val name = "Example Print"
override val description = "Prints the configuration values"
override fun createConfigObject() = PrintConfig()
override fun ExtensibleContextBuilderDsl.install(configuration: PrintConfig) {
put { PrintService(configuration.foo, configuration.bar) }
}
}
fun main() {
tegral {
install(PrintConfigFeature) {
foo = "Hello"
}
}
}
// Foo: Hello, Bar: Bar!
Note that, within the install
block, you have access to additional properties:
configuration
: In case you need to access the external configuration object to configure in-code configuration, you can access it through this property.
External configuration
Externally configurable features use configuration sections, which are passed to a standard location in Tegral applications. For example, configurable features installed in a Tegral Web application have their sections under the [tegral.*]
category.
Let's extend our previous example. Instead of "Bar!", our Foo feature will use a configurable string, or "Bar!" if no such configuration is present.
We'll first create a configuration section...
data class FooConfig(
val bar: String = "Bar!"
) {
companion object : ConfigurationSection<FooConfig>(
"foo",
// Section can be omitted entirely, in which case we'll use the default
SectionOptionality.Optional(FooConfig()),
FooConfig::class
)
}
Let's make our feature implement ConfigurableFeature
instead of just Feature
, and register the section there:
object FooFeature : ConfigurableFeature {
override val id = "acme-foo"
override val name = "ACME Foo"
override val description = "Provides a Foo instance in the DI environment"
override val configurationSections = listOf(FooConfig)
override fun ExtensibleContextBuilderDsl.install() {
put(::Foo)
}
}
Finally, let's modify our Foo
class so that it uses our configuration section instead of the hardcoded value. This also means actually using dependency injection here, as we will be retrieving the configuration class from the DI environment.
class Foo(scope: InjectionScope) {
private val config: TegralConfig by scope()
fun bar(): String = config[FooConfig].bar
}
Optionally, you can use the wrapIn
pattern to avoid calling config[...]
all the time:
class Foo(scope: InjectionScope) {
private val config: TegralConfig by scope<TegralConfig>() wrapIn { it[FooConfig] }
fun bar(): String = config.bar
}
When defining features, you only need to define configuration sections. The application framework (e.g. Tegral Web AppDSL) will take care of defining the necessary root classes and instantiating the decoder.
Lifecycle features
Features can put
services into the environment, which will be started like any other service. However, you sometimes need more control over the application's lifecycle, and need to be called back at specific points.
The LifecycleHookedFeature
interface provides a few functions that can help with that, including:
- A callback for when the configuration is successfully loaded.
- A callback just before starting all other services.
You should almost always use services instead of lifecycle hooks, but it may be impossible to do otherwise sometimes.