- Overview
- Quick Start
- Introduction
- Guides
- Client Libraries
- API Reference
- Examples
- DDD Resources
- Validation user guide
- Validation developer guide
Architecture
Spine Validation has two main responsibilities, and the codebase is organized around the boundary between them:
- At build time — translate validation options declared in
.protofiles into Java code that enforces those constraints inside the generated message and builder classes. - At runtime — provide the small library of types that the generated code calls into to evaluate constraints and report violations.
This page describes how the modules in this repository implement that split. For an inventory of every module, see “Key modules”.
The compile-time / runtime split
The build-time work is performed by a Spine Compiler plugin. The Spine Compiler runs
during the consumer project’s build, after protoc produces the initial Java sources.
The plugin inspects the Protobuf model, detects validation options on fields and messages,
and injects validation logic into the generated classes.
The runtime library is small on purpose. It exposes the SPI that user code or generated
code calls (MessageValidator, ValidatorRegistry), the exception type
(ValidationException), and the Protobuf types used to describe violations
(ConstraintViolation, ValidationError, TemplateString). Everything else — the
constraint logic itself — lives in the generated code, not in a runtime evaluator.
This split is the main architectural decision in the project. Constraints are compiled, not interpreted. There is no rule engine running at message construction time; there is only the inlined Java code that the compiler plugin produced.
The build-time pipeline
The compiler plugin is structured as two layers:
:context— a language-agnostic model of the validation rules discovered in a set of.protofiles. This module is a Spine Bounded Context: validation options become events, events feed projections (views), and reactions wire them together.:java— aValidationPluginsubclass that consumes the model from:contextand emits Java code. Code emission is performed by two renderers:JavaValidationRendererfor assertion-style options, andSetOnceRendererfor(set_once), whose semantics modify builder behavior rather than add a check.
The base plugin class lives in
ValidationPlugin.kt
and registers the built-in views and reactions:
public abstract class ValidationPlugin(
renderers: List<Renderer<*>> = emptyList(),
views: Set<Class<out View<*, *, *>>> = setOf(),
viewRepositories: Set<ViewRepository<*, *, *>> = setOf(),
reactions: Set<Reaction<*>> = setOf(),
) : Plugin(
renderers = renderers,
views = views + setOf(
RequiredFieldView::class.java,
PatternFieldView::class.java,
GoesFieldView::class.java,
DistinctFieldView::class.java,
ValidatedFieldView::class.java,
RangeFieldView::class.java,
MaxFieldView::class.java,
MinFieldView::class.java,
SetOnceFieldView::class.java,
ChoiceGroupView::class.java,
RequireMessageView::class.java,
),
viewRepositories = viewRepositories,
reactions = reactions + setOf<Reaction<*>>(
RequiredReaction(),
IfMissingReaction(),
RangeReaction(),
MinReaction(),
MaxReaction(),
DistinctReaction(),
IfHasDuplicatesReaction(),
ValidateReaction(),
IfInvalidReaction(),
PatternReaction(),
ChoiceReaction(),
IsRequiredReaction(),
GoesReaction(),
SetOnceReaction(),
IfSetAgainReaction(),
RequireReaction()
)
) // Plugin
The Java implementation in
JavaValidationPlugin.kt
adds the Java renderers and folds in any custom options discovered through the SPI:
public open class JavaValidationPlugin : ValidationPlugin(
renderers = listOf(
JavaValidationRenderer(customGenerators = customOptions.map { it.generator }),
SetOnceRenderer()
),
views = customOptions.flatMap { it.view }.toSet(),
reactions = customOptions.flatMap { it.reactions }.toSet(),
)
customOptions is loaded via ServiceLoader<ValidationOption>, which is what makes the
plugin extensible without recompiling the Validation library. See
ValidationOption.kt
for the SPI itself, and the “Custom validation” section of
the User’s Guide for the consumer-facing walkthrough.
The runtime library
Generated validation code depends only on :jvm-runtime. The most important entry points
are:
MessageValidator— SPI for attaching custom validators to message types, including types declared in third-party.protofiles. See the User’s Guide “Using validators” section.ValidatorRegistry— discovers and appliesMessageValidatorimplementations.validation_error.proto— definesValidationErrorandConstraintViolation, the structured shape of violation reports.error_message.proto— definesTemplateString, the placeholder format used by error messages.
The runtime library does not parse .proto files, does not maintain a rule registry, and
does not interpret constraints. It contains only the types that the generated code and
user code share.
Distribution and consumer wiring
Two small modules exist purely to make the plugin usable from a consumer project:
:java-bundle— repackages:javaand its non-shared transitive dependencies as a single fat JAR. The Spine Compiler loads validation as a single classpath entry, so bundling avoids dependency resolution surprises in the compiler classloader.:gradle-plugin— theio.spine.validationGradle plugin. When applied to a consumer project it registers:java-bundleon the Spine Compiler’s user classpath, insertsJavaValidationPlugininto the compiler’s plugin list, and adds:jvm-runtimeto the consumer’simplementationconfiguration so generated code compiles and runs. SeeValidationGradlePlugin.kt.
The end-to-end picture
The diagram below shows what happens from the moment a developer writes a .proto file
with validation options through to the runtime check that fires when a message is built.
%%{init: {"flowchart": {"subGraphTitleMargin": {"top": 10, "bottom": 15}}}}%%
flowchart TD
proto[".proto with validation options"]
subgraph build["<b>Consumer build (Gradle)</b>"]
gradle["<b><code>:gradle-plugin</code></b><br/>io.spine.validation"]
compiler["Spine Compiler"]
bundle["<b><code>:java-bundle</code></b><br/>(<code>JavaValidationPlugin</code>)"]
ctx["<b><code>:context</code></b><br/>views + reactions"]
renderers["<code>JavaValidationRenderer</code><br/><code>SetOnceRenderer</code>"]
gen["Generated Java<br/>message + builder classes"]
end
subgraph rt["<b>Runtime</b>"]
runtime["<b><code>:jvm-runtime</code></b><br/><code>MessageValidator</code>,<br/><code>ValidationException</code>,<br/><code>ConstraintViolation</code>"]
app["Application code<br/><code>builder.build()</code>"]
end
proto --> compiler
gradle --> compiler
gradle -. registers .-> bundle
compiler --> bundle
bundle --> ctx
ctx --> renderers
renderers --> gen
gen --> app
app --> runtime
runtime -- on violation --> app
At a glance:
- The Gradle plugin is the only thing the consumer applies. It pulls in the bundle and the runtime library transparently.
- The Spine Compiler invokes
JavaValidationPlugin, which uses:contextto build a language-agnostic model of the constraints, then runs Java renderers to inject code into the classes thatprotocgenerated. - At runtime, the application calls into generated code, typically through
Builder.build()or a generatedvalidate()method. The generated code uses types from:jvm-runtimeto report violations.
What’s next
- Key modules — the full module inventory, including the test modules.