The library brings data validation directly into your Protobuf messages.
While Protobuf provides a structured way to define data schemas, it does not include built-in mechanisms for enforcing domain-specific rules at runtime. Without validation, invalid or inconsistent data can slip through, leading to subtle bugs and potential system failures.
Typically, developers address this by manually adding validation logic in application code or by invoking separate validation APIs. This ad-hoc approach can be error-prone and often results in duplicated, hard-to-maintain code scattered across the codebase.
Spine Validation solves this by modifying the code generated by the Protobuf compiler (protoc
).
At build time, Spine Validation injects assertions directly into the generated Java classes,
enabling automatic enforcement of constraints without explicit API calls in application code.
This library is built with Java 17.
Define your validation rules right in the .proto file:
import "spine/options.proto";
import "spine/time_options.proto";
import "google/protobuf/timestamp.proto";
message CardNumber {
string digits = 1 [(pattern).regex = "\\d{4}\\s?\\d{4}\\s?\\d{4}\\s?\\d{4}"];
string owner = 2 [(required) = true];
google.protobuf.Timestamp issued_at = 3 [(when).in = PAST];
}
At build time, Spine Validation injects assertions into the generated Java classes:
var card = CardNumber.newBuilder()
.setDigits("invalid")
.build(); <- Validates here.
If any constraint is violated, a ValidationException
is thrown from build()
.
You can also validate without throwing:
var card = CardNumber.newBuilder()
.setDigits("invalid")
.buildPartial(); <- No validation.
var optionalError = card.validate();
optionalError.ifPresent(err -> {
System.out.println(err.getMessage());
});
Validation options are defined by the following files:
Users must import these .proto files to use the options they define.
import "spine/options.proto"; // Brings all options, except for time-related ones.
import "spine/time_options.proto"; // Brings time-related options.
The library is a set of plugins for ProtoData.
Each target language is a separate ProtoData plugin.
Take a look at the following diagram to grasp a high-level library structure:
The workflow is the following:
- (1), (2) – user defines Protobuf messages with validation options.
- (3) – Protobuf compiler generates Java classes.
- (4), (5) – policies and views build the validation model.
- (6), (7) – Java plugin generates and injects validation code.
Module | Description |
---|---|
:model | The language-agnostic model for the built-in options. |
:java | Generates and injects Java validation code based on applied options. |
:java-api | Extension API for custom options in Java. |
Users can extend the library by providing custom Protobuf options and code generation logic.
Follow these steps to create a custom option:
- Declare a Protobuf extension
in your
.proto
file. - Register it via
io.spine.option.OptionsProvider
. - Implement the following entities:
- Policy (
MyOptionPolicy
) – discovers and validates the option. - View (
MyOptionView
) – accumulates valid option applications. - Generator (
MyOptionGenerator
) – generates Java code for the option.
- Policy (
- Register them via
io.spine.validation.api.ValidationOption
.
Below is a workflow diagram for a typical option:
Take a look at the :java-tests:extensions
module that contains a full example of
implementation of the custom (currency)
option.
Note that a custom option can provide several policies and views, but only one generator. This allows building more complex models, using more entities and events.
Let's take a closer look at each entity.
Usually, this is an entry point to the option handling.
The policy subscribes to one of *OptionDiscovered
events:
FileOptionDiscovered
.MessageOptionDiscovered
.FieldOptionDiscovered
.OneofOptionDiscovered
.
It filters incoming events, taking only those who contain the option of the interest. The policy
may validate the option application, query TypeSystem
, extract and transform data arrived with
the option, if any. Once ready, it emits an event signaling that the discovered option is valid
and ready for the code generation.
The policy may report a compilation warning or an error, failing the whole compilation if it finds an illegal application of the option.
For example:
- An unsupported field type.
- Illegal option content (invalid regex, parameter, signature).
The policy may just ignore the discovered option and emit NoReaction
. A typical example
of this is a boolean option, such as (required)
, which does nothing when it is set to false
.
The desired behavior depends on the option itself.
Views accumulate events from policies, serving as data providers for the validation model used by code generators. Views are typically simple and only accumulate data; for more complex logic, use policies.
Usually, one view represents a single application of an option.
The generator is an entity that provides an actual implementation of the option behavior. The generator produces Java code for every application of that option within the message type.
It has access to the Querying
interface and can query views to find those belonging
to the processed message type.