Complete JavaFX MVVM examples, bindings and library documentation

This commit is contained in:
2026-11-07 18:36:53 +01:00
parent c76f5b2a28
commit 8062b61ed6
110 changed files with 3808 additions and 220 deletions
+173
View File
@@ -0,0 +1,173 @@
# Injection
## Introduction
`injection` is a small container. You mark concrete classes, it scans a package, keeps one shared instance of each scanned class, and fills dependencies by type. [`fx`](../fx/README.md) uses it for controllers, components, and converters. You can use it without JavaFX.
The worked example is the person desk in `fx-example`, package `de.neitzel.fx.example`. This guide uses that desk for every feature the desk exercises, then covers the container rules the desk does not hit.
## Quick start
Depend on the module. It brings Reflections and `jakarta.inject-api`. The parent artifact is `de.neitzel.lib:neitzellib:1.0-SNAPSHOT`. Java 21.
```xml
<dependency>
<groupId>de.neitzel.lib</groupId>
<artifactId>injection</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
```
Mark a class with `jakarta.inject.Singleton` or `jakarta.inject.Named`, and take dependencies with `jakarta.inject.Inject`. Open a context on the package that contains those classes.
The desk does it this way. `DeskApp` names the scan root and opens the context. `ViewLoader` then asks that context for the controller, the forms, and the converters. `AgeSuffix` is a `@Named` service. `AgeConverter` takes that service in its constructor.
```java
ApplicationContext context = new ApplicationContext(DeskApp.class);
LoadedView loaded = ViewLoader.load(
DeskController.class.getResource("Desk.fxml"), DeskApp.sample(), context);
```
`DeskApp.start` is that load, then the scene. The class carries `@Config(basePackage = "de.neitzel.fx.example")`.
```java
@Named
public class AgeSuffix {
public String prefix() {
return "years ";
}
}
```
`AgeConverter` in `fx-example` is the full class. It keeps the suffix and implements `Converter`. `getComponent` returns the same instance on every call. `AgeConverter` and `AgeSuffix` are each created once, and every use of the converter shares that one suffix.
## Mark a component
The sections from here on are the container's rules. The desk is the example where a rule shows up on the screen. Rules the desk does not hit are stated on their own.
The scanner looks for `jakarta.inject.Singleton` and `jakarta.inject.Named` on the class. Both annotations mean the same thing here: the class is a component, and it is stored as a singleton. `@Named`'s value is never read. There is no lookup by name.
`AgeSuffix` carries `@Named`. `AgeConverter`, `StreetConverter`, `AddressForm`, `VisitForm`, `NoteForm`, `VisitRow`, `DeskController`, `FormHeading`, `StreetPolicy`, and `VisitedOnConverter` carry `@Singleton`. The age field still shows `years 36`, which is how the desk shows that `@Named` was found.
Put the annotation on each concrete class you want created. The scan uses Reflections' "annotated types" query, which also returns subtypes of an annotated type that sit inside the scan root. An unannotated subclass of a `@Singleton` class can therefore be picked up. Mark the classes you intend to create.
The scanner does not read `@Qualifier`, `Provider`, `Instance`, producer methods, `@PostConstruct`, or `@PreDestroy`. `@Named` on a parameter or a field is not a qualifier. Injection matches the raw class after generics are erased, so a `List<Address>` is looked up as `List`.
There is no `@Component` stereotype. Comments on `Scope` and `@Config` still mention one. Discovery is `@Singleton` and `@Named`.
## Where the container injects
For each component the scanner chooses one constructor, then the `@Inject` fields, then the `@Inject` setters. Members are taken from that class only. Inherited fields and methods are left alone. The chosen constructor and the injected members are made accessible, so they may be non-public.
Constructor choice:
1. More than one constructor annotated with `@Inject` makes the component invalid. It is left out of the context.
2. Exactly one `@Inject` constructor is used, even when other constructors exist.
3. Otherwise the single declared constructor is used. A compiler-generated public no-arg constructor counts. Parameters are allowed, and `@Inject` is not required on that lone constructor.
4. Otherwise a zero-argument constructor is used when one exists.
5. Otherwise the component is invalid.
After `new`, each `@Inject` field is set, then each valid `@Inject` setter is called. A setter is an `@Inject` method that is `void`, has one parameter, and whose name starts with `set`. An `@Inject` method of another shape is skipped and recorded as an error. The component can still be created.
The desk uses all three places:
| Place | Desk class | What is injected |
| --- | --- | --- |
| Constructor | `AgeConverter` | `AgeSuffix` |
| Constructor | `VisitRow` | `DeskController` |
| Field | `StreetConverter` | `StreetPolicy`, on a private field, after the no-arg constructor |
| Setter | `AddressForm.setHeading` | `FormHeading` |
`AddressForm.initialize` runs later, when FXML loads the form, and copies `heading.text()` onto the label. The setter has already run by then. The home form shows the word `Address`.
`VisitRow` is created again for every table row. Each row receives the same `DeskController`, because the controller is a singleton and dependencies are loaded with `getComponent`.
## Ask for an instance
```java
public <T> T getComponent(Class<? extends T> type)
public <T> T createComponent(Class<? extends T> type)
public <T> void addSingleton(Class<? extends T> type, T instance)
```
`getComponent` returns the cached instance. The first call creates it and stores it. Later calls return that object.
`createComponent` always builds a new instance and does not store it. Dependencies of that instance still come from `getComponent`, so they are the shared singletons. `fx` uses this for a second component of the same class in one load, and for every repeated row and table row. The home `AddressForm` is the cached instance. Each `VisitForm` and each `VisitRow` is a new instance, and each of them still sees the one `DeskController` and the one `StreetConverter`.
A type the context does not know produces `IllegalArgumentException` with the message `No component found for type:` and the class name. A failure while calling the constructor or writing a member is wrapped as `RuntimeException` whose message starts with `Fehler beim Erstellen der Instanz für`, with the original failure as the cause.
The context is a plain map. It is not safe to share across threads without external locking. There is no shutdown and no list-all method.
## Choose the scan root
```java
public ApplicationContext(String basePackage)
public ApplicationContext(Class<?> configClass)
```
The string constructor scans that package and its subpackages. The desk uses the class constructor:
```java
ApplicationContext context = new ApplicationContext(DeskApp.class);
```
`@Config` on the class supplies the package when `basePackage` is not empty. `DeskApp` sets it to `de.neitzel.fx.example`, which is also the class's own package. Subpackages are included, so `de.neitzel.fx.example.view` is scanned. Without the annotation, or with `basePackage` left as `""`, the scan root is `configClass.getPackageName()`. The annotation is not inherited, so a superclass `@Config` is ignored.
`@Config` only chooses the scan root. It does not bind properties.
Scan failures are not thrown from either constructor. A package full of broken components still produces a context. Those types are simply absent, and `getComponent` fails later with `No component found for type:`. To see why, scan with `ComponentScanner` and read `getErrors()`.
## Types you can inject
`getComponent` accepts the concrete class. It also accepts a superclass or an interface when exactly one scanned component has that super type. The context stores one `ComponentData` under the concrete class and under each such unique super type, and both keys return the same instance.
A super type implemented by two or more components is ambiguous. It is not a key. `getComponent` on it throws `No component found for type:`. The concrete classes stay available. The desk does not use this: every injection point names a concrete class.
Super types considered for a component are its superclass chain, stopping before `Object`, and the interfaces declared on that class. Interfaces declared only on a superclass are not included. The class itself is not registered twice under this rule.
`ComponentScanner` is the way to see the result before you build a context:
```java
ComponentScanner scanner = new ComponentScanner("de.neitzel.fx.example");
scanner.getInstantiableComponents(); // concrete classes and unique super types
scanner.getNotUniqueTypes(); // super types with two or more components
scanner.getErrors(); // messages, empty when the scan is clean
```
The lists and the map are the scanner's own collections.
A cycle never enters the map. Each side is reported as a missing dependency.
## When creation fails
`new ApplicationContext(...)` stays quiet. Inspect `ComponentScanner.getErrors()` when a type you expected is missing. The messages are:
| Situation | Message |
| --- | --- |
| Two or more `@Inject` constructors | `Multiple @Inject constructors found for ` plus the class name |
| Several constructors, none of them `@Inject`, and none of them zero-argument | `No @Inject constructor and no no-arg constructor found for ` plus the class name |
| `@Inject` method that is not a one-argument `void` method named `set...` | `Invalid @Inject method detected in ` plus the class name, `: `, and the method name. The method is skipped |
| A dependency never becomes a known type | `Component cannot be instantiated: ` plus the class name, then ` \| Missing constructor dependencies: `, ` \| Missing field dependencies: `, or ` \| Missing setter dependencies: `, each followed by the list of classes |
| Every dependency is either known or an ambiguous super type | the same prefix plus ` \| Conflicting super types detected` |
A class with a single `String` constructor and no `@Inject` takes that constructor (rule 3 above). `String` is not a component, so the class is omitted and the error names `class java.lang.String` as the missing constructor dependency.
## Register an instance yourself
`addSingleton` stores an object you already built.
- The type is absent: the instance is stored and `getComponent` returns it. The context does not construct it, and it does not scan again. Components already dropped because a dependency was missing stay dropped.
- The concrete class was found by the scan: `IllegalStateException` with `Type cannot be replaced:` and the class name.
- The key is a unique super type, whose stored component class is the implementation: the super-type entry is replaced. The concrete-class entry is left as it was, so the interface and the class can then return different objects.
`createComponent` on a manually added type has no injection constructor and fails.
The desk does not call `addSingleton`. Its services are the scanned classes.
## Scope
`Scope` has two constants, `SINGLETON` and `PROTOTYPE`. Every class the scanner accepts is stored as `SINGLETON`. Nothing in the scanner assigns `PROTOTYPE`. `getComponent` on a singleton returns the cached instance. `createComponent` is how you get another object of a singleton class, which is what the desk's repeated forms and table rows do.
## What this container does not do
It does not match qualifiers, read `@Named` values, inject by name, bind configuration properties, or call lifecycle methods. It does not create a new instance per injection. `fx` view models are built by `fx`, not by this container.
@@ -19,6 +19,10 @@ import java.util.Objects;
* for resolving and creating components.
*/
public class ApplicationContext {
/**
* Components this context can create, keyed by the registered type.
* The component scan fills the map, and manual singleton registration updates it.
*/
private final Map<Class<?>, ComponentData> components;
/**
@@ -12,33 +12,54 @@ import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertSame;
/**
* Checks that {@link ApplicationContext} injects constructor, field, and setter dependencies.
*/
class ApplicationContextInjectionTest {
/**
* Context scanned from the successful injection fixtures.
*/
private ApplicationContext context;
/**
* Scans the successful injection fixtures before each test.
*/
@BeforeEach
void setUp() {
context = new ApplicationContext("de.neitzel.injection.testcomponents.injection.success");
}
/**
* Resolves a service whose dependency is passed to the constructor.
*/
@Test
void resolvesConstructorInjection() {
ConstructorInjectedService service = context.getComponent(ConstructorInjectedService.class);
assertNotNull(service.getRootDependency());
}
/**
* Resolves a service whose dependency is injected into a field.
*/
@Test
void resolvesFieldInjection() {
FieldInjectedService service = context.getComponent(FieldInjectedService.class);
assertNotNull(service.getRootDependency());
}
/**
* Resolves a service whose dependency is injected through a setter.
*/
@Test
void resolvesSetterInjection() {
SetterInjectedService service = context.getComponent(SetterInjectedService.class);
assertNotNull(service.getRootDependency());
}
/**
* Resolves a mixed graph and reuses the same singleton dependency throughout.
*/
@Test
void resolvesMixedInjectionGraphAndSingletonReuse() {
MixedInjectionService service = context.getComponent(MixedInjectionService.class);
@@ -48,6 +69,9 @@ class ApplicationContextInjectionTest {
assertSame(rootDependency, service.getSetterInjectedService().getRootDependency());
}
/**
* Creates a new instance while singleton dependencies stay the instances already in the context.
*/
@Test
void createComponentInjectsANewInstanceAndKeepsTheSingleton() {
MixedInjectionService singleton = context.getComponent(MixedInjectionService.class);
@@ -63,6 +87,9 @@ class ApplicationContextInjectionTest {
assertSame(singleton.getSetterInjectedService(), created.getSetterInjectedService());
}
/**
* Keeps a created instance out of the singleton cache.
*/
@Test
void createComponentDoesNotBecomeTheSingleton() {
MixedInjectionService created = context.createComponent(MixedInjectionService.class);
@@ -7,8 +7,14 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Checks that the component scanner reports unusable injection plans.
*/
class ComponentScannerInjectionPlanTest {
/**
* Reports an error when a component declares more than one {@code @Inject} constructor.
*/
@Test
void reportsMultipleInjectConstructorsAsError() {
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
@@ -16,6 +22,9 @@ class ComponentScannerInjectionPlanTest {
assertTrue(scanner.getErrors().stream().anyMatch(error -> error.contains(MultipleInjectConstructorsComponent.class.getName())));
}
/**
* Reports an error when every constructor needs arguments and none is marked {@code @Inject}.
*/
@Test
void reportsMissingUsableConstructorAsError() {
ComponentScanner scanner = new ComponentScanner("de.neitzel.injection.testcomponents.injection.error");
@@ -13,6 +13,9 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Checks which types the component scanner accepts in the test component packages.
*/
class InjectableComponentScannerTest {
/**
@@ -3,12 +3,25 @@ package de.neitzel.injection.testcomponents.injection.error;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Fixture with two {@code @Inject} constructors, which the scanner must reject.
*/
@Singleton
public class MultipleInjectConstructorsComponent {
/**
* Declares an injectable constructor that takes a string.
*
* @param firstDependency unused string dependency
*/
@Inject
public MultipleInjectConstructorsComponent(String firstDependency) {
}
/**
* Declares a second injectable constructor that takes an integer.
*
* @param secondDependency unused integer dependency
*/
@Inject
public MultipleInjectConstructorsComponent(Integer secondDependency) {
}
@@ -2,11 +2,24 @@ package de.neitzel.injection.testcomponents.injection.error;
import jakarta.inject.Singleton;
/**
* Fixture with no default constructor and no {@code @Inject} constructor.
*/
@Singleton
public class NoInjectConstructorWithoutDefault {
/**
* Accepts a string and is not marked for injection.
*
* @param firstDependency unused string dependency
*/
public NoInjectConstructorWithoutDefault(String firstDependency) {
}
/**
* Accepts an integer and is not marked for injection.
*
* @param secondDependency unused integer dependency
*/
public NoInjectConstructorWithoutDefault(Integer secondDependency) {
}
}
@@ -3,15 +3,31 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Fixture whose dependency is passed to the constructor.
*/
@Singleton
public class ConstructorInjectedService {
/**
* Dependency received through the constructor.
*/
private final RootDependency rootDependency;
/**
* Stores the constructor dependency.
*
* @param rootDependency the dependency
*/
@Inject
public ConstructorInjectedService(RootDependency rootDependency) {
this.rootDependency = rootDependency;
}
/**
* Returns the constructor dependency.
*
* @return the dependency
*/
public RootDependency getRootDependency() {
return rootDependency;
}
@@ -3,11 +3,22 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Fixture whose dependency is injected into a field.
*/
@Singleton
public class FieldInjectedService {
/**
* Dependency injected into the field.
*/
@Inject
private RootDependency rootDependency;
/**
* Returns the injected dependency.
*
* @return the dependency
*/
public RootDependency getRootDependency() {
return rootDependency;
}
@@ -3,32 +3,69 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Fixture that receives dependencies by constructor, field, and setter.
*/
@Singleton
public class MixedInjectionService {
/**
* Dependency received through the constructor.
*/
private final ConstructorInjectedService constructorInjectedService;
/**
* Dependency injected into the field.
*/
@Inject
private FieldInjectedService fieldInjectedService;
/**
* Dependency injected through the setter.
*/
private SetterInjectedService setterInjectedService;
/**
* Stores the constructor dependency.
*
* @param constructorInjectedService the constructor dependency
*/
@Inject
public MixedInjectionService(ConstructorInjectedService constructorInjectedService) {
this.constructorInjectedService = constructorInjectedService;
}
/**
* Returns the constructor dependency.
*
* @return the constructor dependency
*/
public ConstructorInjectedService getConstructorInjectedService() {
return constructorInjectedService;
}
/**
* Returns the field dependency.
*
* @return the field dependency
*/
public FieldInjectedService getFieldInjectedService() {
return fieldInjectedService;
}
/**
* Returns the setter dependency.
*
* @return the setter dependency
*/
public SetterInjectedService getSetterInjectedService() {
return setterInjectedService;
}
/**
* Stores the setter dependency.
*
* @param setterInjectedService the setter dependency
*/
@Inject
public void setSetterInjectedService(SetterInjectedService setterInjectedService) {
this.setterInjectedService = setterInjectedService;
@@ -2,6 +2,9 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Singleton;
/**
* Shared singleton dependency used by the injection fixtures.
*/
@Singleton
public class RootDependency {
}
@@ -3,14 +3,30 @@ package de.neitzel.injection.testcomponents.injection.success;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
/**
* Fixture whose dependency is injected through a setter.
*/
@Singleton
public class SetterInjectedService {
/**
* Dependency injected through the setter.
*/
private RootDependency rootDependency;
/**
* Returns the injected dependency.
*
* @return the dependency
*/
public RootDependency getRootDependency() {
return rootDependency;
}
/**
* Stores the injected dependency.
*
* @param rootDependency the dependency
*/
@Inject
public void setRootDependency(RootDependency rootDependency) {
this.rootDependency = rootDependency;
@@ -1,4 +1,7 @@
package de.neitzel.injection.testcomponents.test1ok;
/**
* Shared supertype of more than one scanned component.
*/
public class SuperClass {
}
@@ -2,8 +2,14 @@ package de.neitzel.injection.testcomponents.test1ok;
import jakarta.inject.Singleton;
/**
* Singleton component in the root of the successful scan package.
*/
@Singleton
public class TestComponent1_1 extends SuperClass implements TestInterface1_2 {
/**
* Creates the component with no dependencies.
*/
public TestComponent1_1() {
}
}
@@ -1,4 +1,7 @@
package de.neitzel.injection.testcomponents.test1ok;
/**
* Interface implemented by exactly one scanned component.
*/
public interface TestInterface1_1 {
}
@@ -1,4 +1,7 @@
package de.neitzel.injection.testcomponents.test1ok;
/**
* Interface implemented by more than one scanned component.
*/
public interface TestInterface1_2 {
}
@@ -5,8 +5,14 @@ import de.neitzel.injection.testcomponents.test1ok.TestInterface1_1;
import de.neitzel.injection.testcomponents.test1ok.TestInterface1_2;
import jakarta.inject.Singleton;
/**
* Singleton component in a subpackage of the successful scan package.
*/
@Singleton
public class TestComponent1_2 extends SuperClass implements TestInterface1_1, TestInterface1_2 {
/**
* Creates the component with no dependencies.
*/
public TestComponent1_2() {
}
}
@@ -7,6 +7,11 @@ import jakarta.inject.Singleton;
*/
@Singleton
public class TestComponent2_1 {
/**
* Requires a string the scanner cannot resolve.
*
* @param test unresolved constructor dependency
*/
public TestComponent2_1(String test) {
}
}