- Overview
- Quick Start
- Introduction
- Guides
- Client Libraries
- API Reference
- Examples
- DDD Resources
- Validation user guide
- Validation developer guide
Extension points
Spine Validation exposes two extension points. They sit on opposite sides of the compile-time / runtime split:
- The
ValidationOptionSPI (build time) — adds a new validation option with its own model, codegen, and runtime helpers. - The
MessageValidatorSPI (runtime) — adds a custom check on a specific message type, executed alongside the compiled constraints.
Each surface has a corresponding User’s Guide section that explains how to use it:
“Custom validation” for ValidationOption, and
“Using validators” for MessageValidator. This page is the
contributor-side view: what each surface guarantees, how discovery works, what an
implementation may and may not do, and why.
The earlier sections of the Developer’s Guide cover each surface in detail — “The validation model” and “Java code generation” for the build-time half, and “Runtime library” for the runtime half. This page consolidates the two into a single picture.
The two surfaces at a glance
| Aspect | ValidationOption | MessageValidator |
|---|---|---|
| Granularity | A new .proto option, applicable to many messages. | A custom check on one specific message type. |
| When it runs | Build time (codegen) plus optional runtime helpers it ships itself. | Runtime: after compiled checks for local messages; via ValidatorRegistry for external/direct validation. |
| Inputs | Reads the model in :context, emits Java via :java. | Receives a built Message, returns List<DetectedViolation>. |
| Discovery | ServiceLoader<ValidationOption> on the Compiler user classpath. | ServiceLoader<MessageValidator> in the consumer’s classpath. |
| Required by | Adding a new constraint vocabulary ((when), (currency), …). | Constraints that cannot be expressed declaratively, or external types. |
| Lives in | Defined in :java; implementations live in their own modules. | Defined in :jvm-runtime; implementations live in any consumer module. |
The two are deliberately not interchangeable. A ValidationOption is the right choice
when the same constraint vocabulary applies across many messages and benefits from
declarative configuration in .proto files. A MessageValidator is the right choice
when the constraint is specific to one message type, or when the message type is external
and cannot carry options at all.
The ValidationOption SPI end-to-end
The ValidationOption SPI is intentionally narrow. A custom
option contributes exactly three things, matching the build-time pipeline:
reactions— reaction instances that subscribe to the upstreamFieldOptionDiscovered/OneofOptionDiscovered/MessageOptionDiscoveredevents, filter byOPTION_NAME, validate applicability, and emit a*Discovereddomain event. See “The validation model”.view— Protobuf-declared projections that fold those domain events into queryable state. See “The validation model”.generator— anOptionGeneratorsubclass that queries the projection and emits oneSingleOptionCodeper option application. See “Java code generation”.
JavaValidationPlugin discovers SPI implementations through ServiceLoader and folds
them into the same plugin registration that brings in the built-ins:
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(),
)
private val customOptions: List<ValidationOption> by lazy {
ServiceLoader.load(ValidationOption::class.java)
.filterNotNull()
}
From the model’s point of view, custom reactions and views are indistinguishable from
the built-ins. From the renderer’s point of view, the custom generator receives the
same Querying and TypeSystem as the built-ins and contributes to the same
validate() method. Built-ins and custom options share one pipeline, not two.
Discovery
A ValidationOption implementation is discovered through the standard Java
ServiceLoader SPI:
- The implementing class must be on the Spine Compiler user classpath, not merely on the application runtime classpath. In a Gradle build that consumes Validation, this means the module declaring the option is added to the Spine Compiler user classpath (see “Pass the option to the Compiler”).
- A
META-INF/services/io.spine.tools.validation.java.ValidationOptionentry must list the implementing class. The conventional way to generate it is the@AutoService(ValidationOption::class)annotation processor; any other mechanism that produces the same descriptor is equivalent. - The class must have a public no-arg constructor — the
ServiceLoadercontract.
In addition to the SPI implementation itself, two more pieces of build-time wiring are
required for the option to work: the option’s Protobuf descriptor must be discoverable
through OptionsProvider (so the Compiler recognises the option when parsing
.proto files), and the consumer’s build must place the option’s module on the
Compiler user classpath. Both are covered in the User’s Guide; the SPI itself does not
attempt to encode them.
Lifecycle
Discovered implementations are constructed when a JavaValidationPlugin instance is
created — its constructor dereferences the top-level customOptions lazy while collecting
generators, views, and reactions. From that point on, the same instance is used for the
entire build:
- Each
Reactioninstance returned byreactionsis registered with the Bounded Context exactly once. Reactions are stateless by contract. - Each
Viewclass returned byviewis registered once and instantiated by the framework as needed. Views accumulate state per projection key. - The single
generatorinstance is the one passed toJavaValidationRenderer. The renderer callsinject(querying, typeSystem)on it before the firstcodeFor()invocation, andcodeFor(type)is called once per message type in theSourceFileSet. The instance must therefore be safe to reuse across messages within a single build — see “Java code generation”.
Ordering
The order in which custom generators contribute to a generated validate() is
unspecified. Generators must not rely on running before or after any built-in or any
other custom generator: each contribution is a self-contained if (…) { violations.add(…) }
block, and accumulating violations (rather than short-circuiting) is what lets the
ordering stay free.
The same is true on the model side. Reactions and views run in event-delivery order; a
custom view that needs to fold both its primary event and a companion event must accept
either ordering, exactly the way RequiredFieldView accepts RequiredFieldDiscovered
and IfMissingOptionDiscovered in either order (see
“Companion options”).
The MessageValidator SPI
The runtime extension surface is MessageValidator<M>. Use it for
checks that cannot be expressed in .proto options at all — because the rule depends on
multiple fields, on external state, or on a message type whose source the consumer cannot
modify. The registry API, the ${validator} placeholder, and the DetectedViolation
shape are covered in “Runtime library”
and “Using ValidatorRegistry”; this section
keeps to the extension contract.
Discovery
For automatic discovery, the implementation must be on the consumer application’s runtime
classpath and listed in
META-INF/services/io.spine.validation.MessageValidator. @AutoService is only a
convenient way to produce that descriptor; there is no Validation-specific discovery
annotation.
The class must have a public no-arg constructor, and its concrete M type parameter must
be recoverable from the validator class. Direct implementations such as
MessageValidator<MyType> are the clearest shape; base classes are fine as long as
Guava’s TypeToken can still resolve M to a concrete Message class.
Lifecycle
MessageValidator instances are constructed by ServiceLoader when ValidatorRegistry
is initialized, or explicitly by application code before registration. Once registered,
an instance is retained and invoked on every matching validation. The registry itself is
annotated @ThreadSafe and dispatches concurrently: implementations must therefore be
safe to invoke from multiple threads at once.
Ordering and composition
ValidatorRegistry stores validators keyed by qualified message class name. When a
message of type M is validated:
- For a local message (one whose generated class is produced by the Java renderer in
this build), the generated
validate()first runs every compiled constraint and then consultsValidatorRegistryfor any registered validators onM. Compiled checks and validators all contribute to the sameValidationError. - For an external message (one whose generated class is not produced in this build),
the registry is the only entry point. A local message reaches external validators only
through fields marked
(validate) = true; a standalone external instance is not validated unless the caller invokesValidate.checkorValidatorRegistry.validatedirectly.
The order in which validators of the same message type run is unspecified. Validators must report independently of their peers because the registry concatenates all reports.
Constraints on what extensions can do
Both SPIs are deliberately narrow. The constraints below are not arbitrary; they fall out of the compile-time / runtime split that the rest of the architecture is built on (see “Architecture”).
ValidationOption
- No file I/O at generation time. The generator must derive everything from the view
state populated by reactions. Reading
.protofiles, querying descriptors at runtime, or scanning the file system from insidecodeFor()defeats the model’s reason to exist — the renderer is supposed to be replaceable with a renderer for another target language without touching:context. - No mutation of the message PSI directly. Return constraints, supporting fields, and
supporting methods through
SingleOptionCode; placement is theValidationCodeInjector’s job. Directly adding methods, fields, or interface implementations from inside a generator bypasses the conventions for the shape of generated validators (see “Java code generation”). - No silent failure. A misapplied option must fail compilation through
Compilation.check/Compilation.error, not be quietly skipped. Reactions that decide an option does not apply must returnNoReaction, not throw. - No interpreting at runtime. If an option needs runtime helpers, they must ship in
a separate module that the generated code calls into — like
:jvm-runtimedoes for the built-ins. The runtime must not parse.protodescriptors to recover what the generator already knew.
MessageValidator
- No descriptor scanning. The runtime is intentionally free of descriptor-driven rule discovery. A validator that wants to apply different rules to different fields must do so in code, not by re-deriving a model at runtime.
- No reflection-driven dispatch in the hot path. The registry does a single
ConcurrentHashMaplookup keyed by class name. The reflection that recoversMfrom the validator’s class runs once, at registration. Validators must not extend that reflection cost into per-call dispatch. - No assumptions about ordering or peers. A validator must produce a correct report
regardless of which other validators (built-in, custom, registered explicitly,
registered through
ServiceLoader) run alongside it. - Thread-safety is on the implementer. The registry is
@ThreadSafe; validators must be too. Per-call mutable state must be local to the call. - Use
DetectedViolation, notConstraintViolation. The registry is responsible for translatingDetectedViolationtoConstraintViolation, packing values intoAny, prefixing field paths with the parent path, and stamping the type name. A validator that bypassesDetectedViolationcannot participate in nested validation correctly.
Why these constraints exist
The compile-time / runtime split is what lets the runtime stay small (see “Runtime library”) and the language-agnostic model stay portable (see “The validation model”). Both extension points are designed so that a well-behaved implementation reinforces that split:
- A
ValidationOptionadds a new constraint vocabulary without forcing a runtime rule engine into existence — the constraint becomes inlined Java like every built-in. - A
MessageValidatoradds a runtime-only check without leaking knowledge of the Compiler, the model, or codegen — it is opaque to everything below theMessageValidatorboundary.
The constraints above are how each SPI keeps that property; they are also why neither SPI exposes more knobs than it does. New extension points should be evaluated against the same split before they are added.
What’s next
- Adding a new built-in validation option — the
contributor walkthrough that exercises the
ValidationOptionSPI end-to-end. - The validation model — what a custom reaction and view look like in detail.
- Java code generation — what a custom
OptionGeneratorhands back to the renderer. - Runtime library — what a
MessageValidatoris plugged into. - User’s Guide — Custom validation and Using validators for the consumer-facing view of the same SPIs.