diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java
index f0924907a6..31a9b728ff 100644
--- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java
+++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/ModelConvertor.java
@@ -7,6 +7,8 @@
import ai.timefold.solver.service.definition.api.domain.ModelRequest;
import ai.timefold.solver.service.definition.api.domain.ModelResponse;
+import org.jspecify.annotations.NullMarked;
+
/**
* The convertor of:
*
@@ -15,6 +17,9 @@
* - The
SolverModel representing a planning solution into the ModelOutput (usually forming a part
* of the {@link ModelResponse}).
*
+ *
+ * All method parameters and return values are non-null unless explicitly marked otherwise.
+ * Absence of a previous model output is expressed with an empty {@link Optional}, not a null reference.
*
* @param The solver model concrete {@link ai.timefold.solver.core.api.domain.solution.PlanningScore} type.
* @param The type of the model input part of the {@link ModelRequest}.
@@ -24,6 +29,7 @@
* by Timefold solver.
* @param The type of the model output part of the {@link ModelResponse}.
*/
+@NullMarked
public non-sealed interface ModelConvertor, ModelInput_ extends ModelInput, ModelConfigurationOverrides_ extends ModelConfigOverrides, SolverModel_ extends SolverModel, ModelOutput_ extends ModelOutput>
extends ModelConvertorBase {
@@ -35,15 +41,21 @@ public non-sealed interface ModelConvertor, ModelIn
* the solving process. In such cases, {@code lastModelOutput} represents the last stored output before the failure,
* allowing the method to restore or reuse relevant state as needed.
*
- * @param modelInput the model input part of the request, containing the data to be converted
- * @param modelConfig the model configuration, including any overrides to be applied
+ * @param modelInput the model input part of the request, containing the data to be converted; never null
+ * @param modelConfig the model configuration, including any overrides to be applied; never null
* @param lastModelOutput an {@link Optional} containing the last stored model output before a failure, or empty if not
- * recovering
- * @return the solver model instance to be used by the Timefold solver
+ * recovering; never null
+ * @return the solver model instance to be used by the Timefold solver; never null
*/
SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig modelConfig,
Optional lastModelOutput);
+ /**
+ * Converts the given {@link SolverModel_} into a {@link ModelOutput_}.
+ *
+ * @param solverModel the planning solution produced by the solver; never null
+ * @return the model output; never null
+ */
ModelOutput_ toModelOutput(SolverModel_ solverModel);
/**
@@ -54,9 +66,9 @@ SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig
* The updated {@link ModelInput_} can be used as a basis for new datasets by applying external changes.
*
- * @param modelInput The model input to be updated.
- * @param modelOutput The model output containing changes to be applied to the model input.
- * @return Reference to the updated {@link ModelInput_}.
+ * @param modelInput The model input to be updated; never null
+ * @param modelOutput The model output containing changes to be applied to the model input; never null
+ * @return Reference to the updated {@link ModelInput_}; never null
*/
ModelInput_ applyOutputToInput(ModelInput_ modelInput, ModelOutput_ modelOutput);
}
diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java
new file mode 100644
index 0000000000..52982dd1e4
--- /dev/null
+++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/ModelConvertorNullabilityTest.java
@@ -0,0 +1,14 @@
+package ai.timefold.solver.service.definition.api;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import org.jspecify.annotations.NullMarked;
+import org.junit.jupiter.api.Test;
+
+class ModelConvertorNullabilityTest {
+
+ @Test
+ void modelConvertorIsNullMarked() {
+ assertThat(ModelConvertor.class.getAnnotation(NullMarked.class)).isNotNull();
+ }
+}
diff --git a/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java b/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java
new file mode 100644
index 0000000000..594b3151f3
--- /dev/null
+++ b/service/quarkus/deployment/src/test/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertorTest.java
@@ -0,0 +1,86 @@
+package ai.timefold.solver.service.quarkus.deployment.defaults;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatNullPointerException;
+
+import java.util.Optional;
+
+import ai.timefold.solver.core.api.domain.solution.ConstraintWeightOverrides;
+import ai.timefold.solver.core.api.score.SimpleScore;
+import ai.timefold.solver.service.definition.api.ModelInput;
+import ai.timefold.solver.service.definition.api.ModelOutput;
+import ai.timefold.solver.service.definition.api.SolverModel;
+import ai.timefold.solver.service.definition.api.domain.ModelConfig;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class AbstractTrivialModelConvertorTest {
+
+ private AbstractTrivialModelConvertor convertor;
+ private TestdataModel model;
+ private ModelConfig modelConfig;
+
+ @BeforeEach
+ void setUp() {
+ convertor = new AbstractTrivialModelConvertor<>() {
+ };
+ model = new TestdataModel();
+ modelConfig = ModelConfig.empty();
+ }
+
+ @Test
+ void toSolverModelRejectsNullArguments() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.toSolverModel(null, modelConfig, Optional.empty()))
+ .withMessage("modelInput");
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.toSolverModel(model, null, Optional.empty()))
+ .withMessage("modelConfig");
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.toSolverModel(model, modelConfig, null))
+ .withMessage("lastModelOutput");
+ }
+
+ @Test
+ void toModelOutputRejectsNullSolverModel() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.toModelOutput(null))
+ .withMessage("solverModel");
+ }
+
+ @Test
+ void applyOutputToInputRejectsNullArguments() {
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.applyOutputToInput(null, model))
+ .withMessage("modelInput");
+ assertThatNullPointerException()
+ .isThrownBy(() -> convertor.applyOutputToInput(model, null))
+ .withMessage("modelOutput");
+ }
+
+ @Test
+ void applyOutputToInputReturnsModelOutputWhenTypesMatch() {
+ TestdataModel modelOutput = new TestdataModel();
+
+ assertThat(convertor.applyOutputToInput(model, modelOutput)).isSameAs(modelOutput);
+ }
+
+ @Test
+ void toSolverModelUsesEmptyOptionalWhenNoPreviousOutput() {
+ assertThat(convertor.toSolverModel(model, modelConfig, Optional.empty())).isSameAs(model);
+ }
+
+ private static final class TestdataModel implements ModelInput, ModelOutput, SolverModel {
+
+ @Override
+ public SimpleScore getScore() {
+ return null;
+ }
+
+ @Override
+ public ConstraintWeightOverrides getConstraintWeightOverrides() {
+ return null;
+ }
+ }
+}
diff --git a/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java b/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java
index 5d5b906363..e2c9e44e62 100644
--- a/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java
+++ b/service/quarkus/runtime/src/main/java/ai/timefold/solver/service/quarkus/deployment/defaults/AbstractTrivialModelConvertor.java
@@ -1,5 +1,6 @@
package ai.timefold.solver.service.quarkus.deployment.defaults;
+import java.util.Objects;
import java.util.Optional;
import ai.timefold.solver.core.api.score.Score;
@@ -12,6 +13,8 @@
import ai.timefold.solver.service.definition.api.domain.ModelRequest;
import ai.timefold.solver.service.definition.api.domain.ModelResponse;
+import org.jspecify.annotations.NullMarked;
+
/**
* Default model convertor for the trivial case when:
*
@@ -27,6 +30,7 @@
* @param The type of the {@link ai.timefold.solver.core.api.domain.solution.PlanningSolution} class to be used
* by Timefold solver.
*/
+@NullMarked
public abstract class AbstractTrivialModelConvertor, ModelInput_ extends ModelInput, ModelConfigurationOverrides_ extends ModelConfigOverrides, SolverModel_ extends SolverModel, ModelOutput_ extends ModelOutput>
implements
ModelConvertor {
@@ -34,6 +38,9 @@ public abstract class AbstractTrivialModelConvertor
@Override
public SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig modelConfig,
Optional lastModelOutput) {
+ Objects.requireNonNull(modelInput, "modelInput");
+ Objects.requireNonNull(modelConfig, "modelConfig");
+ Objects.requireNonNull(lastModelOutput, "lastModelOutput");
if (lastModelOutput.isPresent() && !(lastModelOutput.get().getClass()).equals(modelInput.getClass())) {
throw new IllegalArgumentException(
"Trivial conversion is possible only when modelInput (%s) and modelOutput (%s) are of the same class."
@@ -44,17 +51,20 @@ public SolverModel_ toSolverModel(ModelInput_ modelInput, ModelConfig