Validate Once, Enforce Everywhere: Protobuf + Protovalidate + Quarkus REST
Move common validation into Protobuf schemas for cross-language consistency
Today on The Main Thread, I’m happy to welcome Joe Rinehart as a guest author.
Joe works on developer experience at Buf, with a strong focus on Protobuf tooling and validation. In this article, he tackles a problem many Java developers run into the moment Protobuf enters the picture: how to keep strong, declarative validation when your DTOs are generated, immutable, and shared across languages.
What makes this post a great fit for The Main Thread is its architectural angle. Instead of treating validation as a framework feature or a Java-only concern, Joe shows how to move validation to where it belongs: the schema. By combining Protobuf, Protovalidate, and Quarkus REST, validation becomes a contract that is enforced consistently across services, runtimes, and teams.
If you care about cross-language APIs, long-lived schemas, and avoiding subtle production bugs caused by validation drift, this is exactly the kind of approach worth understanding.
Also make sure to check my other article on Protobuf integration with Quarkus:
When I started working with Protobuf after years of using Java Bean Validation, I immediately hit a wall. Protobuf’s generated Java classes are immutable builders—there’s nowhere to put @Email or @NotBlank annotations. I didn’t want to write manual validation code for every message, but I also didn’t want to lose the compile-time safety and declarative style I’d relied on for years. Thankfully, I quickly found out there’s a Proto solution.
I’d just joined Buf and started working on developer experience for Protovalidate, and I quickly learned validation had actually gotten better. Rules move from Java annotations into the schema itself. Because Protovalidate is built on CEL (Common Expression Language), the same validation rules work in Go, JavaScript/TypeScript, Java, Python, and C++. Even better, CEL’s simple syntax makes it easy to write cross-language custom validation rules without dropping into language-specific code.
In the previous article, we added Protobuf serialization to Quarkus REST endpoints. This tutorial shows you how to add Protovalidate to those endpoints without duplicating logic across languages or frameworks.
The Problem
We’ve all debugged production incidents caused by bad input. An invalid email here, a missing required field there, and suddenly your quiet day…isn’t so quiet. Normally, in Java, we’d use annotations like @Email, @NotBlank, and @Size on our DTOs. Bean Validation works well within Java, but when you need the same rules in other languages, you end up duplicating them: maybe you’re writing a React frontend, or a downstream Python job, and you hope their validation libraries are consistent. Inevitably, an implementation drifts…
Why Protovalidate?
Protovalidate solves this problem by bringing consistent validation rules to Protobuf, with runtimes available for Java, JavaScript/TypeScript, Go, Python, and C++. With it, you define validation directly in your schema, with annotations that feel a lot like Java annotations:
message CreateUserRequest {
string name = 1 [(buf.validate.field).string.min_len = 1];
string email = 2 [(buf.validate.field).string.email = true];
}
These aren’t documentation. They’re enforced constraints that produce type-safe violation messages when data doesn’t comply. Your React frontend can validate before sending a request. Your Java API validates the request when it receives it. Both use identical logic generated from the same .proto file.
Let’s get started.
Prerequisites
Java 17 or newer
Maven 3.9+
protoc binary installed (brew install protobuf on Mac)
A basic understanding of REST endpoints
Add the Protovalidate Dependency
Edit your pom.xml, adding Protovalidate as a dependency:
<dependency>
<groupId>build.buf</groupId>
<artifactId>protovalidate</artifactId>
<version>1.0.0</version>
</dependency>You’ll also need to make buf/validate/validate.proto available during code generation. Add Protovalidate to your protobuf-maven-plugin:
<plugin>
<groupId>org.xolstice.maven.plugins</groupId>
<artifactId>protobuf-maven-plugin</artifactId>
<version>0.6.1</version>
<executions>
<execution>
<id>compile-protobuf</id>
<phase>generate-sources</phase>
<goals>
<goal>compile</goal>
</goals>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>build.buf</groupId>
<artifactId>protovalidate</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</plugin>
Update Your Schema
Open src/main/proto/user.proto, import buf/validate/validate.proto, and add validation rules to CreateUserRequest :
import "buf/validate/validate.proto";
message CreateUserRequest {
string name = 1 [
(buf.validate.field).string = {
min_len: 1,
max_len: 250,
}
];
string email = 2 [
(buf.validate.field).string = {
email: true,
}
];
}
Built-in rules like email, min_len, and max_len cover most common validation patterns. The buf/validate/validate.proto file comes from Buf’s Schema Registry.
Run mvn clean compile to regenerate your Java classes with validation metadata.
Add Validation to ProtobufMessageBodyReader
Now we’ll modify src/main/java/com/example/ProtobufMessageBodyReader.java to validate after parsing:
package com.example;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.lang.reflect.Method;
import java.lang.reflect.Type;
import build.buf.protovalidate.ValidationResult;
import build.buf.protovalidate.Validator;
import build.buf.protovalidate.ValidatorFactory;
import build.buf.protovalidate.exceptions.ValidationException;
import com.google.protobuf.Message;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.MessageBodyReader;
import jakarta.ws.rs.ext.Provider;
import org.jboss.logging.Logger;
@Provider
@Consumes("application/x-protobuf")
public class ProtobufMessageBodyReader implements MessageBodyReader<Message> {
private static final Logger LOG = Logger.getLogger(ProtobufMessageBodyReader.class);
private final Validator validator = ValidatorFactory.newBuilder().build();
@Override
public boolean isReadable(Class<?> type, Type genericType,
Annotation[] annotations, MediaType mediaType) {
boolean readable = Message.class.isAssignableFrom(type);
LOG.infof("ProtobufMessageBodyReader.isReadable: type=%s, readable=%s",
type.getSimpleName(), readable);
return readable;
}
@Override
public Message readFrom(Class<Message> type, Type genericType,
Annotation[] annotations, MediaType mediaType,
MultivaluedMap<String, String> headers,
InputStream inputStream)
throws WebApplicationException {
Message message;
// Parse the message
LOG.infof("ProtobufMessageBodyReader.readFrom: parsing %s from InputStream",
type.getSimpleName());
try {
Method parseFrom = type.getMethod("parseFrom", InputStream.class);
message = (Message) parseFrom.invoke(null, inputStream);
LOG.infof("ProtobufMessageBodyReader.readFrom: successfully parsed %s",
type.getSimpleName());
} catch (Exception e) {
LOG.errorf(e, "ProtobufMessageBodyReader.readFrom: failed to parse %s",
type.getSimpleName());
throw new WebApplicationException("Failed to parse protobuf message", e);
}
// Validate it
try {
ValidationResult validationResult = validator.validate(message);
if (!validationResult.isSuccess()) {
LOG.infof("ProtobufMessageBodyReader.readFrom: validation failed for %s: %s",
type.getSimpleName(), validationResult.getViolations());
Response response = Response.status(Response.Status.BAD_REQUEST)
.entity(validationResult.toProto().toByteArray())
.type("application/x-protobuf")
.build();
throw new WebApplicationException("Validation failed", response);
}
} catch (ValidationException e) {
LOG.errorf(e, "ProtobufMessageBodyReader.readFrom: failed to validate %s",
type.getSimpleName());
throw new WebApplicationException("Failed to validate protobuf message", e);
}
return message;
}
}
When validation fails, we return HTTP 400 with violations serialized as Protobuf. Your error responses are as type-safe as your success responses.
Test the Validation
Add a test in src/test/java/com/example/ProtobufClientTest.java:
// You'll need a few new imports.
import build.buf.validate.Violation;
import build.buf.validate.Violations;
import java.util.Map;
@Test
void testInvalidUser() throws Exception {
// Send an invalid user with no name and a bad email address.
CreateUserRequest request = CreateUserRequest.newBuilder()
.setEmail("charlie_example.com")
.build();
byte[] requestBody = request.toByteArray();
byte[] responseBody = given()
.contentType("application/x-protobuf")
.accept("application/x-protobuf")
.body(requestBody)
.when()
.post("/api/users")
.then()
.statusCode(400)
.extract()
.body()
.asByteArray();
// Parse the violations Protobuf message
Map<String, String> expectedViolations = Map.of(
"name", "value length must be at least 1 characters",
"email", "value must be a valid email address"
);
Violations violations = Violations.parseFrom(responseBody);
// Verify we got exactly the expected errors
assertEquals(expectedViolations.size(), violations.getViolationsCount(),
"Expected " + expectedViolations.size() + " violations");
expectedViolations.forEach((fieldName, expectedMessage) -> {
Violation violation = violations.getViolationsList().stream()
.filter(v -> v.getField().getElements(0).getFieldName().equals(fieldName))
.findFirst()
.orElseThrow(() -> new AssertionError("Expected violation for '" + fieldName + "' field"));
assertEquals(expectedMessage, violation.getMessage(),
"Unexpected message for field '" + fieldName + "'");
});
}
Run mvn test. The test sends invalid data and gets back a structured Violations message (documented at buf.build/bufbuild/protovalidate/docs). Each violation includes the field path and a human-readable error message.
Why This Matters
It works just like an interceptor enforcing Bean Validation: if a request doesn’t pass validation, we immediately return a 400. The difference? The rules live in your schema, not in Java classes. When your React frontend validates with the same rules as your Java API, you catch errors before they hit the network. When your Python batch job validates with the same rules as your real-time service, you prevent inconsistencies in your data pipeline.
Need to change max_len to 300? Update the schema, regenerate, and it’s enforced everywhere.
When to Use Protovalidate
Protovalidate’s built-in rules handle common patterns: field formats, lengths, numeric ranges, required fields. When you need custom logic, drop to CEL expressions:
import "google/protobuf/timestamp.proto";
import "buf/validate/validate.proto";
message Booking {
google.protobuf.Timestamp start_time = 1;
google.protobuf.Timestamp end_time = 2;
option (buf.validate.message).cel = {
id: "start_before_end"
message: "start_time must be before end_time"
expression: "this.start_time < this.end_time"
};
}
Skip Protovalidate when validation requires external state like database lookups or API calls. Personally, I’d argue those are business rules rather than message constraints, and I’d skip them in Bean Validation as well.
Where to Go From Here
You’ve added schema-level validation to your Protobuf REST API. Validation rules live in your .proto files, ready to be consistently enforced across languages and services.
Full documentation and more validation examples at protovalidate.com.





