Dependency injection
Versatia wires objects through constructor injection driven entirely by the generated descriptor. There is no container to configure, no module to write and nothing to look up at runtime: you annotate classes, the build validates the graph, the runtime instantiates it.
Declaring a bean
A bean is a class annotated with a scope. Its constructor parameters are its dependencies, and they are satisfied by other beans.
import dev.versatia.di.LocalBean
interface Greeter {
fun greeting(): String
}
@LocalBean
class FriendlyGreeter : Greeter {
override fun greeting() = "Welcome!"
}
@LocalBean
class Banner(private val greeter: Greeter) {
fun show() = println(greeter.greeting())
}
Banner asks for a Greeter, an interface. A bean provides its own type and every supertype
except Any, so FriendlyGreeter satisfies the request.
The scopes
| Annotation | Who can inject it | Instances |
|---|---|---|
@LocalBean | only the plugin that declares it | one per plugin |
@ExposeSingleton | the declaring plugin and every plugin enabled later | one, shared |
@ExposePrototype | the declaring plugin and every plugin enabled later | one per consuming plugin |
All three live in dev.versatia.di. Their semantics and the lifetime of beans are
covered in Scopes and lifetime; sharing beans between plugins in
Beans across plugins.
What a bean can be
- A top-level or nested class (
object Settings { @LocalBean class Format }works). public,internalorprivate. Private classes and constructors are instantiated through reflection; everything else through a direct constructor call. Encapsulation is yours to decide.- A class whose primary constructor, or its only constructor when it has no primary one, is
injected. Secondary constructors next to a primary one are ignored; several constructors and no
primary one are a compile error. The parameters must be non-nullable, non-generic class types;
no
vararg, no type parameters. A type alias stands for the class it names. - A constructor parameter with a default value is an optional dependency,
and one marked
@Valueis a configuration value.
Not allowed: abstract classes, interfaces, objects, inner classes and generic classes. Each of
these is a compile error; see Compile-time checks.
The plugin itself
Your main class is a local bean: VersatiaPlugin carries @PluginBean, and an enhancement on a
base class applies to every plugin extending it. Bukkit creates the instance, and the runtime
registers it before creating anything else, so any bean or auto-invoked function can ask
for it, by its own type or by any supertype such as JavaPlugin:
@LocalBean
class Storage(plugin: JavaPlugin) {
val folder = plugin.dataFolder
}
@LocalBean
class Commands(private val plugin: MyPlugin)
Its bean name is always plugin, whatever the class is called, so @Qualifier("plugin")
selects it where several candidates provide the requested type, and no other bean may take that
name. @Primary on the main class works like on any bean, and the bean is only visible inside its
own plugin. Two things are compile errors: a @Qualifier on the main class
(MyPlugin is the plugin's main class and its bean is always named "plugin"; remove @Qualifier)
and a scope annotation on it
(MyPlugin is the plugin's main class and a local bean already; remove @LocalBean), because a
second plugin instance must never be constructed.
Beans every plugin has
Every plugin may inject these without declaring anything:
| Type | Package | What it is | Provided by |
|---|---|---|---|
your main class (bean plugin) | yours | the plugin instance, also as VersatiaPlugin, JavaPlugin, Plugin; see above. Its plugin.yml data is plugin.pluginMeta | @PluginBean on VersatiaPlugin |
Logger | java.util.logging | plugin.logger: logs exactly as the plugin does, with its name | VersatiaCore, exposed prototype |
ComponentLogger | net.kyori.adventure.text.logger.slf4j | plugin.componentLogger, for logging components; also injectable as org.slf4j.Logger | VersatiaCore, exposed prototype |
PluginRegistry | dev.versatia | the running Versatia plugins by friendly name; see From code | VersatiaCore, exposed singleton |
PluginScope | dev.versatia.task | the plugin's own coroutines, one scope per plugin; see Your own coroutines | VersatiaCore, exposed prototype |
Messages | dev.versatia.messages | the plugin's bank of messages, read from its messages/ folder; see Messages | VersatiaCore, exposed prototype |
Text | dev.versatia.text | the plugin's parser of MiniMessage with runtime arguments; see Text without a file | VersatiaCore, exposed prototype |
A class that only logs takes the logger, not the plugin:
@LocalBean
class Shop(private val plugin: JavaPlugin) {
fun open() = plugin.logger.info("Shop open")
}
import java.util.logging.Logger
@LocalBean
class Shop(private val logger: Logger) {
fun open() = logger.info("Shop open")
}
The log line reads [MyPlugin] Shop open, as with plugin.logger. Take the plugin itself only for
what the plugin alone has, such as its data folder or the scheduler helpers;
the server goes through VersatiaEngine. Server, the scheduler and the data folder are not beans.
The prototypes are made for the plugin injecting them, so every plugin, and every module prototype made for a plugin, gets that plugin's logger, metadata and scope:
@ExposePrototype
class Greeter(private val logger: Logger) {
fun greet() = logger.info("Hello") // logged as the plugin that injected the Greeter
}
Your own scope annotations
A scope is itself an annotation marked @Bean. Annotating your own annotation with an existing
scope inherits it, so you can give your beans a domain-specific name:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
@LocalBean
annotation class Service
@Service
class OrderService(repository: OrderRepository)
@Service inherits @LocalBean, and through it everything a local bean is. The relation is
transitive, and the descriptor still records @Service as the annotation that produced the
element. The mechanism behind this is described in
Enhancements and processors.
Your own attribute annotations
The attributes work the same way. An annotation of your own annotated with @Qualifier,
@Primary, @ConditionalOnPlugin or @FriendlyName counts as that attribute wherever you write
it, transitively, exactly as if the attribute were written directly:
@Target(AnnotationTarget.CLASS, AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.BINARY)
@Qualifier("fast")
annotation class Fast
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.BINARY)
@Primary
annotation class Preferred
@LocalBean @Fast
class FastClock : Clock // the bean is named "fast"
@LocalBean @Preferred
class SystemClock : Clock // the default Clock
@LocalBean
class Race(@Fast clock: Clock) // FastClock
An attribute that is not repeatable may reach an element once: @Fast next to @Qualifier("x")
is a compile error, Race has @Qualifier more than once: written directly and inherited from @Fast; keep one. @ConditionalOnPlugin is repeatable, so its occurrences combine; see
Optional plugins.
Resolution in one paragraph
For a parameter of type T, the runtime first looks at the beans of the same plugin that provide
T; if none does, at the beans other plugins have exposed. One candidate wins. Several candidates
are an error unless a @Qualifier on the parameter or a @Primary on one of the classes decides.
The build fails only for what it can see locally: several local candidates, or a qualifier naming a
local bean of the wrong type. A dependency that no local bean provides compiles, since another
plugin may expose it; if nothing does, the runtime reports it when the plugin enables. The details
are in Names, qualifiers and primary beans and
Compile-time checks.
Optional dependencies
A constructor parameter with a default value is optional: when a bean provides its type, the bean is injected; when none does, the default is used.
interface PriceFormat {
fun format(price: Int): String
}
object PlainFormat : PriceFormat {
override fun format(price: Int) = "$price coins"
}
@LocalBean
class PriceList(private val format: PriceFormat = PlainFormat) {
fun describe(item: String, price: Int) = "$item costs ${format.format(price)}"
}
Without a PriceFormat bean, here or exposed by another plugin, PriceList gets PlainFormat.
Declare a @LocalBean class FancyFormat : PriceFormat, or install a plugin exposing one, and it is
injected instead, with no change to PriceList. Several candidates are still an ambiguity, as for
any parameter. This works for every class the runtime creates: beans, listeners
and the classes of subclass enhancements.
It does not apply to the parameters of auto-invoked functions, which
are always injected.
Conditional beans
A bean carrying @ConditionalOnPlugin whose plugins are not enabled
does not exist: it is not created, it provides nothing, and a @Primary on it counts only
while its condition holds. Another provider of the same type stands in. The build checks that
every dependency is satisfied in each case; see
Conditional beans.
Where beans are used
- As constructor dependencies of other beans.
- As parameters of auto-invoked functions.
- As the receiver of an auto-invoked function declared inside a bean class.
The plugin instance is available at all three places as well.
Corner cases
-
Many optional plugins. The build checks the dependencies of conditional beans for every combination of the plugins their
@ConditionalOnPluginnames. With more than 10 such plugins for one dependency it checks only two cases, none of them enabled and all of them; a problem in another combination is reported by the runtime when the plugin enables. -
A prototype's values. A
@Valueproperty of an@ExposePrototype, in the constructor or in the body, is read from the owner's files and set on every instance: an instance made for a consumer receives the values the owner read last, a newPreviousValueTrackingcell of its own for a tracked one, and every reload of the owner updates it and then reloads the consumer; see@Valueon a prototype. -
A prototype's optional dependencies. An instance made for a consumer reuses what the owner resolved, its default included; it never looks among the consumer's beans for that parameter.
-
Your own
Loggerbean. The beans every plugin has are exposed beans, so a local bean of the same type wins: with@LocalBean class AuditLogger : Logger("audit", null)everyLoggerparameter of the plugin getsAuditLogger. Two localLoggerbeans are ambiguous, a compile error, until@Primaryor@Qualifierdecides, as for any type. The same holds forComponentLoggerand the others. -
The consumer's own rules for a prototype's logger. A prototype made for another plugin receives the
Logger,ComponentLoggerandPluginScopethat plugin would get itself: its own localLoggerbean when it declares one, itsplugin.loggerotherwise. -
The injected logger is a delegate. The
Loggeris not the same object asplugin.logger: it carries the same name and forwards every record, its level, handlers and filter toplugin.logger, so what it logs and every setting made through it are the plugin's. Compare loggers by name, not with===.ComponentLoggerdelegates the same way. -
A default on a function parameter. A parameter of an auto-invoked function with a default value is injected like any other; only constructor parameters are optional.
-
A plugin that disables itself while its beans are created stops the creation: beans not yet created are never created, nothing is exposed to other plugins, and a bean created after the disable is closed at once if it is
AutoCloseable. The same holds for start and reload functions: once the plugin is disabled, the remaining ones of that event do not run.