violations) {
+ this.constraintViolations = ImmutableList.copyOf(violations);
+ this.message = message;
+ }
+
+ /**
+ * Obtains a {@code MessageClass} for an invalid {@code Message}.
+ */
+ protected abstract C getMessageClass();
+
+ /**
+ * Obtains an error code to use for error reporting.
+ */
+ protected abstract R getErrorCode();
+
+ /**
+ * Obtains an error text to use for error reporting.
+ *
+ * This text will also be used as a base for an exception message to generate.
+ */
+ protected abstract String getErrorText();
+
+ /**
+ * Obtains the {@code Message}-specific type attributes for error reporting.
+ */
+ protected abstract Map getMessageTypeAttribute(M message);
+
+ /**
+ * Defines the way to create an instance of exception basing on the source {@code Message},
+ * exception text, and a generated {@code Error}.
+ */
+ protected abstract E createException(String exceptionMsg, M message, Error error);
+
+ private String formatExceptionMessage() {
+ return format("%s. Message class: `%s`. %s",
+ getErrorText(), getMessageClass(), violationsText());
+ }
+
+ private Error createError() {
+ var validationError = error();
+ var errorCode = getErrorCode();
+ var errorType = errorCode.getDescriptorForType()
+ .getFullName();
+ var errorText = errorText();
+
+ var error = Error.newBuilder()
+ .setType(errorType)
+ .setCode(errorCode.getNumber())
+ .setDetails(AnyPacker.pack(validationError))
+ .setMessage(errorText)
+ .putAllAttributes(getMessageTypeAttribute(message));
+ return error.build();
+ }
+
+ private ValidationError error() {
+ return ValidationError.newBuilder()
+ .addAllConstraintViolation(constraintViolations)
+ .build();
+ }
+
+ private String errorText() {
+ var errorTextTemplate = getErrorText();
+ var violationsText = violationsText();
+ return format("%s %s", errorTextTemplate, violationsText);
+ }
+
+ private String violationsText() {
+ return ViolationText.ofAll(constraintViolations);
+ }
+
+ /**
+ * Creates an exception instance for an invalid message which has fields that
+ * violate validation constraint(s).
+ */
+ public E newException() {
+ return createException(formatExceptionMessage(), message, createError());
+ }
+}
diff --git a/jvm-runtime/src/main/java/io/spine/validation/ValidationException.java b/jvm-runtime/src/main/java/io/spine/validation/ValidationException.java
index 74952004e3..be9b268434 100644
--- a/jvm-runtime/src/main/java/io/spine/validation/ValidationException.java
+++ b/jvm-runtime/src/main/java/io/spine/validation/ValidationException.java
@@ -29,8 +29,8 @@
import com.google.common.collect.ImmutableList;
import com.google.errorprone.annotations.InlineMe;
import io.spine.base.ErrorWithMessage;
-import io.spine.validation.diags.ViolationText;
+import java.io.Serial;
import java.util.List;
/**
@@ -39,6 +39,7 @@
public class ValidationException
extends RuntimeException implements ErrorWithMessage {
+ @Serial
private static final long serialVersionUID = 0L;
/**
diff --git a/jvm-runtime/src/main/java/io/spine/validation/diags/ViolationText.java b/jvm-runtime/src/main/java/io/spine/validation/ViolationText.java
similarity index 96%
rename from jvm-runtime/src/main/java/io/spine/validation/diags/ViolationText.java
rename to jvm-runtime/src/main/java/io/spine/validation/ViolationText.java
index e3a3db4dcb..9c2e30e091 100644
--- a/jvm-runtime/src/main/java/io/spine/validation/diags/ViolationText.java
+++ b/jvm-runtime/src/main/java/io/spine/validation/ViolationText.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025, TeamDev. All rights reserved.
+ * Copyright 2026, TeamDev. All rights reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -24,13 +24,12 @@
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
-package io.spine.validation.diags;
+package io.spine.validation;
import com.google.protobuf.Message;
import io.spine.annotation.Internal;
import io.spine.base.Field;
import io.spine.option.OptionsProto;
-import io.spine.validation.ConstraintViolation;
import java.util.Collection;
diff --git a/jvm-runtime/src/main/java/io/spine/validation/diags/package-info.java b/jvm-runtime/src/main/java/io/spine/validation/diags/package-info.java
deleted file mode 100644
index 82de71ac25..0000000000
--- a/jvm-runtime/src/main/java/io/spine/validation/diags/package-info.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright 2025, TeamDev. All rights reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * https://www.apache.org/licenses/LICENSE-2.0
- *
- * Redistribution and use in source and/or binary forms, with or without
- * modification, must retain the above copyright notice and the following
- * disclaimer.
- *
- * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
- * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
- * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
- * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
- * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
- * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
- * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
- * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
- * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
- * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
- * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
- */
-
-/**
- * Provides classes for explaining found violations of validation constraints.
- */
-@CheckReturnValue
-@NullMarked
-package io.spine.validation.diags;
-
-import com.google.errorprone.annotations.CheckReturnValue;
-
-import org.jspecify.annotations.NullMarked;
diff --git a/jvm-runtime/src/test/kotlin/io/spine/validation/ExceptionFactorySpec.kt b/jvm-runtime/src/test/kotlin/io/spine/validation/ExceptionFactorySpec.kt
new file mode 100644
index 0000000000..f9a34c1d24
--- /dev/null
+++ b/jvm-runtime/src/test/kotlin/io/spine/validation/ExceptionFactorySpec.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.validation
+
+import com.google.protobuf.NullValue
+import com.google.protobuf.Timestamp
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.string.shouldContain
+import io.spine.protobuf.unpackKnownType
+import io.spine.validation.given.StubExceptionFactory
+import io.spine.validation.given.plainString
+import org.junit.jupiter.api.DisplayName
+import org.junit.jupiter.api.Test
+
+@DisplayName("`ExceptionFactory` should")
+internal class ExceptionFactorySpec {
+
+ private val violation = constraintViolation {
+ typeName = "example.org/example.InvalidMessage"
+ message = plainString("Field value is missing")
+ }
+ private val factory = StubExceptionFactory(Timestamp.getDefaultInstance(), listOf(violation))
+
+ @Test
+ fun `include the error text in the exception message`() {
+ val exception = factory.newException()
+ exception.message shouldContain StubExceptionFactory.ERROR_TEXT
+ }
+
+ @Test
+ fun `include the message class in the exception message`() {
+ val exception = factory.newException()
+ exception.message shouldContain Timestamp::class.java.name
+ }
+
+ @Test
+ fun `include the violation text in the exception message`() {
+ val exception = factory.newException()
+ exception.message shouldContain ViolationText.of(violation).toString()
+ }
+
+ @Test
+ fun `set the error code number in the produced error`() {
+ val exception = factory.newException()
+ exception.error.code shouldBe NullValue.NULL_VALUE.number
+ }
+
+ @Test
+ fun `set the error type using the error code descriptor`() {
+ val exception = factory.newException()
+ exception.error.type shouldBe NullValue.NULL_VALUE.descriptorForType.fullName
+ }
+
+ @Test
+ fun `pack a 'ValidationError' into the error details`() {
+ val exception = factory.newException()
+ val expected = validationError { constraintViolation.add(violation) }
+ exception.error.details.unpackKnownType() shouldBe expected
+ }
+
+ @Test
+ fun `include the violation text in the error message`() {
+ val exception = factory.newException()
+ exception.error.message shouldContain ViolationText.of(violation).toString()
+ }
+}
diff --git a/jvm-runtime/src/test/kotlin/io/spine/validation/diags/ViolationTextSpec.kt b/jvm-runtime/src/test/kotlin/io/spine/validation/diags/ViolationTextSpec.kt
index 5712bf0f0e..852c12078a 100644
--- a/jvm-runtime/src/test/kotlin/io/spine/validation/diags/ViolationTextSpec.kt
+++ b/jvm-runtime/src/test/kotlin/io/spine/validation/diags/ViolationTextSpec.kt
@@ -31,6 +31,7 @@ import com.google.protobuf.Timestamp
import io.kotest.matchers.string.shouldContain
import io.spine.base.Field
import io.spine.type.TypeName
+import io.spine.validation.ViolationText
import io.spine.validation.constraintViolation
import io.spine.validation.given.plainString
import org.junit.jupiter.api.DisplayName
diff --git a/jvm-runtime/src/test/kotlin/io/spine/validation/given/ExceptionFactoryStubs.kt b/jvm-runtime/src/test/kotlin/io/spine/validation/given/ExceptionFactoryStubs.kt
new file mode 100644
index 0000000000..a4696afdda
--- /dev/null
+++ b/jvm-runtime/src/test/kotlin/io/spine/validation/given/ExceptionFactoryStubs.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright 2026, TeamDev. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Redistribution and use in source and/or binary forms, with or without
+ * modification, must retain the above copyright notice and the following
+ * disclaimer.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package io.spine.validation.given
+
+import com.google.protobuf.Message
+import com.google.protobuf.NullValue
+import com.google.protobuf.Timestamp
+import com.google.protobuf.Value
+import io.spine.base.Error
+import io.spine.type.MessageClass
+import io.spine.validation.ConstraintViolation
+import io.spine.validation.ExceptionFactory
+import java.io.Serial
+
+/**
+ * A stub exception that exposes the [error] passed by [ExceptionFactory] to
+ * [ExceptionFactory.createException] for assertion purposes.
+ */
+internal class StubException(message: String, val error: Error) : Exception(message) {
+ companion object {
+ @Serial
+ private const val serialVersionUID: Long = 0L
+ }
+}
+
+/**
+ * A stub [ExceptionFactory] for testing, using [Timestamp] as the invalid message type
+ * and [NullValue] as the error code type.
+ */
+internal class StubExceptionFactory(
+ message: Timestamp,
+ violations: Iterable
+) : ExceptionFactory, NullValue>(
+ message, violations
+) {
+ @Suppress("serial") // OK for this stub.
+ override fun getMessageClass(): MessageClass =
+ object : MessageClass(Timestamp::class.java) {}
+
+ override fun getErrorCode(): NullValue = NullValue.NULL_VALUE
+
+ override fun getErrorText(): String = ERROR_TEXT
+
+ override fun getMessageTypeAttribute(message: Timestamp): Map = emptyMap()
+
+ override fun createException(
+ exceptionMsg: String,
+ message: Timestamp,
+ error: Error
+ ): StubException = StubException(exceptionMsg, error)
+
+ internal companion object {
+ const val ERROR_TEXT = "Test validation error"
+ }
+}
diff --git a/pom.xml b/pom.xml
index 0b4610014b..7788f02608 100644
--- a/pom.xml
+++ b/pom.xml
@@ -10,7 +10,7 @@ all modules and does not describe the project structure per-subproject.
-->
io.spine.tools
validation
-2.0.0-SNAPSHOT.406
+2.0.0-SNAPSHOT.407
2015
@@ -50,7 +50,7 @@ all modules and does not describe the project structure per-subproject.
io.spine
spine-base
- 2.0.0-SNAPSHOT.385
+ 2.0.0-SNAPSHOT.386
compile
@@ -62,37 +62,37 @@ all modules and does not describe the project structure per-subproject.
io.spine
spine-validation-jvm-runtime
- 2.0.0-SNAPSHOT.405
+ 2.0.0-SNAPSHOT.406
compile
io.spine.tools
compiler-backend
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
compile
io.spine.tools
compiler-gradle-api
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
compile
io.spine.tools
compiler-gradle-plugin
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
compile
io.spine.tools
compiler-jvm
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
compile
io.spine.tools
jvm-tools
- 2.0.0-SNAPSHOT.374
+ 2.0.0-SNAPSHOT.375
compile
@@ -158,13 +158,13 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
compiler-api
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
test
io.spine.tools
compiler-testlib
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
test
@@ -224,12 +224,12 @@ all modules and does not describe the project structure per-subproject.
com.google.devtools.ksp
symbol-processing
- 2.3.0
+ 2.3.6
com.google.devtools.ksp
symbol-processing-api
- 2.3.0
+ 2.3.6
com.google.errorprone
@@ -244,7 +244,7 @@ all modules and does not describe the project structure per-subproject.
com.google.protobuf
protoc
- 4.34.0
+ 4.34.1
dev.zacsweers.autoservice
@@ -259,12 +259,12 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
compiler-cli-all
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
io.spine.tools
compiler-protoc-plugin
- 2.0.0-SNAPSHOT.040
+ 2.0.0-SNAPSHOT.041
io.spine.tools
@@ -289,7 +289,7 @@ all modules and does not describe the project structure per-subproject.
io.spine.tools
validation-java-bundle
- 2.0.0-SNAPSHOT.405
+ 2.0.0-SNAPSHOT.406
net.sourceforge.pmd
diff --git a/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt b/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt
index af3451069e..27c7efae1a 100644
--- a/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt
+++ b/tests/runtime/src/test/kotlin/io/spine/validation/ValidateUtilitySpec.kt
@@ -32,7 +32,6 @@ import io.kotest.matchers.shouldBe
import io.spine.base.Time
import io.spine.code.proto.FieldContext
import io.spine.testing.UtilityClassTest
-import io.spine.validation.diags.ViolationText
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
diff --git a/version.gradle.kts b/version.gradle.kts
index 711f4c9125..2109cde54e 100644
--- a/version.gradle.kts
+++ b/version.gradle.kts
@@ -29,4 +29,4 @@
*
* For Spine-based dependencies please see [io.spine.dependency.local.Spine].
*/
-val validationVersion by extra("2.0.0-SNAPSHOT.406")
+val validationVersion by extra("2.0.0-SNAPSHOT.407")