This is a comprehensive reference for using PBJ in your projects. It covers all major usage patterns with real-world examples from the Hiero Block Node and Hiero Consensus Node projects.
For a minimal quick-start, see getting-started.md. For protobuf specification details, see protobuf-and-schemas.md.
plugins {
id("com.hedera.pbj.pbj-compiler") version "<version>"
}
dependencies {
implementation("com.hedera.pbj:pbj-runtime:<version>")
}pbj {
javaPackageSuffix = ".pbj" // appended to derived package names
generateTestClasses = true // generate JUnit 5 tests (default: true)
diffMessages = setOf<String>() // diff roots by protobuf message name (default: empty)
diffMessageContainers = setOf<String>() // catalogs whose message-valued fields are diff roots (default: empty)
generateCopyBuilderTracking = false // copy-builder tracking on larger diff roots (default: false)
}Setting generateTestClasses = false avoids requiring a dependency on Google Protobuf libraries (which the generated tests use for binary compatibility validation).
The diff options are described in Partial Updates (Diffs).
PBJ supports multiple proto source directories with override precedence:
sourceSets {
main {
pbj {
srcDir(layout.projectDirectory.dir("src/main/proto"))
srcDir(layout.projectDirectory.dir("proto-overrides"))
exclude("*.proto") // exclude specific patterns
}
}
}PBJ and protoc can generate code from the same proto files into different packages:
package com.example;
option java_package = "com.example.protoc"; // protoc uses this
// <<<pbj.java_package = "com.example.pbj">>> // PBJ uses thisThe // <<<pbj.java_package = "...">>> comment overrides the Java package for all PBJ-generated classes in the file. This takes highest priority in package resolution:
syntax = "proto3";
package org.hiero.block.api;
option java_package = "org.hiero.block.api.protoc";
// <<<pbj.java_package = "org.hiero.block.api">>>
option java_multiple_files = true;For finer control, override packages per definition type:
option (pbj.message_java_package) = "com.example.messages";
option (pbj.enum_java_package) = "com.example.enums";
option (pbj.service_java_package) = "com.example.services";Add the pbj.comparable option to generate a compareTo() method (the class will implement Comparable<T>). You can make all fields comparable or specify a subset:
// All fields are comparable
// <<<pbj.comparable = "seconds, nanos">>>
message Timestamp {
int64 seconds = 1;
int32 nanos = 2;
}
// Only a subset of fields — non-listed fields are ignored in comparison
// <<<pbj.comparable = "accountId, balance">>>
message Account {
int64 accountId = 1;
int64 balance = 2;
string memo = 3; // not included in compareTo()
repeated int32 tags = 4; // repeated fields cannot be comparable
}The generated compareTo() compares fields in the order listed, using the appropriate comparison for each type (Integer.compare(), Long.compareUnsigned(), null-safe String.compareTo(), etc.). Nested message fields must themselves be comparable.
Supported in the comparable list: all scalar types, strings, bytes, enums, messages, oneOf fields, and wrapper types. Repeated fields and map fields cannot be included.
Message comments can select a message as a diff root (see Partial Updates (Diffs)):
// Diff APIs, without copy-builder tracking unless the project enables it
// <<<pbj.diff = true>>>
message Topic { ... }
// Diff APIs plus copy-builder tracking, regardless of the project setting or the message size
// <<<pbj.copy_builder_tracking = true>>>
message Account { ... }
// Diff APIs, but never copy-builder tracking, even when the project enables it
// <<<pbj.copy_builder_tracking = false>>>
message Token { ... }pbj.copy_builder_tracking = true also makes the message a diff root. pbj.copy_builder_tracking = false only turns
tracking off. On its own it doesn't select a root, so combine it with pbj.diff = true or a Gradle selection.
Use the builder pattern — all model objects are immutable:
// Simple object
HelloRequest request = HelloRequest.newBuilder()
.name("World")
.count(5)
.build();
// Object with nested message
BlockProof proof = BlockProof.newBuilder()
.block(blockNumber)
.signedBlockProof(TssSignedBlockProof.newBuilder()
.blockSignature(Bytes.wrap(signatureBytes)))
.build();
// Object with repeated fields
Block block = Block.newBuilder()
.blockItems(itemList)
.build();PBJ provides several accessor patterns for each field:
// Direct access — returns null for absent reference types, default for primitives
String name = request.name(); // null if absent
int count = request.count(); // 0 if absent (Java primitive)
// Safe access with fallback
String name = request.nameOrElse(""); // "" if absent
int count = request.countOrElse(42); // 42 if absent
// Throwing access — throws NullPointerException if absent
String name = request.nameOrThrow();
// Presence check
if (request.hasName()) { ... }
// Consumer-style access
request.ifName(name -> System.out.println(name));Important: PBJ returns null for absent reference-type fields (unlike protoc, which returns defaults). For primitives (int, long, boolean, etc.), the getter returns the Java default when absent — use fooOrElse() to distinguish "not set" from "set to default."
Use copyBuilder() to create a modified copy without affecting the original:
EventCore modified = eventCore.copyBuilder()
.birthRound(Long.max(eventCore.birthRound() - offset, 1))
.build();
// eventCore is unchangedEvery generated class has a DEFAULT singleton with all fields at their default values:
HelloRequest empty = HelloRequest.DEFAULT;OneOf fields use a type-safe discriminated union:
message BlockRequest {
oneof block_specifier {
uint64 block_number = 1;
bool retrieve_latest = 2;
}
}// Check which variant is set
if (request.hasBlockNumber()) {
long num = request.blockNumber();
} else if (request.hasRetrieveLatest() && request.retrieveLatest()) {
// retrieve latest block
}
// Access the raw OneOf wrapper
OneOf<BlockRequest.BlockSpecifierOneOfType> specifier = request.blockSpecifier();
BlockRequest.BlockSpecifierOneOfType kind = specifier.kind();
// Cast with .as() when you know the type
long blockNum = specifier.as(); // cast to the active variant's typeRepeated fields return immutable List<T>:
// Reading
List<RosterEntry> entries = roster.rosterEntries();
for (RosterEntry entry : entries) {
long nodeId = entry.nodeId();
}
// Building — pass a List to the builder
List<BlockItem> items = new ArrayList<>();
items.add(headerItem);
items.add(bodyItem);
Block block = Block.newBuilder()
.blockItems(items)
.build();Empty repeated fields return Collections.emptyList(), never null.
Map fields are exposed as standard Map<K, V> with deterministic key ordering:
Map<String, Integer> settings = config.settings();
// Iterate — entries are in sorted key order (deterministic)
for (Map.Entry<String, Integer> entry : settings.entrySet()) {
String key = entry.getKey();
int value = entry.getValue();
}Every generated class has a PROTOBUF codec for binary serialization:
// Serialize to bytes
Bytes bytes = HelloRequest.PROTOBUF.toBytes(request);
// Parse from Bytes
HelloRequest parsed = HelloRequest.PROTOBUF.parse(bytes);
// Parse from ReadableSequentialData
HelloRequest parsed = HelloRequest.PROTOBUF.parse(readableData);Every generated class has a JSON codec:
// Serialize to JSON string
String json = HelloRequest.JSON.toJSON(request);
// Pretty-print with indentation
String prettyJson = HelloRequest.JSON.toJSON(request, " ", false);
// Parse from ReadableSequentialData
HelloRequest parsed = HelloRequest.JSON.parse(jsonData);For untrusted input, customize safety limits:
HelloRequest msg = HelloRequest.PROTOBUF.parse(
input, // ReadableSequentialData
false, // strictMode — throw on unknown fields?
true, // parseUnknownFields — preserve for round-trip?
Codec.DEFAULT_MAX_DEPTH, // max nesting depth (default: 512)
maxMessageSize // max field size in bytes (default: 2 MB)
);Strict mode throws UnknownFieldException on unrecognized fields — useful for validation.
Use ReadableStreamingData and WritableStreamingData for file or network I/O:
import com.hedera.pbj.runtime.io.stream.ReadableStreamingData;
import com.hedera.pbj.runtime.io.stream.WritableStreamingData;
// Read from a file
try (var input = new ReadableStreamingData(new FileInputStream(file))) {
MyMessage msg = MyMessage.PROTOBUF.parse(input);
}
// Write to a file
try (var output = new WritableStreamingData(new FileOutputStream(file))) {
MyMessage.PROTOBUF.write(msg, output);
}PBJ uses Bytes instead of byte[] for immutable byte sequences. It implements RandomAccessData and Comparable<Bytes>.
import com.hedera.pbj.runtime.io.buffer.Bytes;
// --- Creation ---
Bytes data = Bytes.wrap(byteArray); // wrap byte[] (no copy)
Bytes data = Bytes.wrap(byteArray, offset, len); // wrap slice (no copy)
Bytes data = Bytes.wrap("hello"); // from UTF-8 string
Bytes data = Bytes.fromHex("3de47629..."); // from hex string
Bytes data = Bytes.fromBase64("SGVsbG8="); // from Base64 string
Bytes merged = Bytes.merge(bytes1, bytes2); // concatenate two Bytes
// --- Conversion ---
byte[] array = data.toByteArray(); // to byte[] (copies)
String hex = data.toHex(); // to hex string
String b64 = data.toBase64(); // to Base64 string
String utf8 = data.asUtf8String(0, data.length()); // decode as UTF-8
// --- Slicing and searching ---
Bytes slice = data.slice(offset, length); // zero-copy view of sub-range
Bytes appended = data.append(moreBytes); // concatenate (creates new Bytes)
Bytes copy = data.replicate(); // defensive copy
boolean found = data.contains(needle); // substring search
int pos = Bytes.indexOf(haystack, needle); // find offset of needle
// --- I/O integration ---
ReadableSequentialData rsd = data.toReadableSequentialData();
InputStream is = data.toInputStream(); // zero-copy InputStream
data.writeTo(outputStream); // write to OutputStream
data.writeTo(byteBuffer); // write to ByteBuffer
data.writeTo(writableSequentialData); // write to any WritableSequentialData
// --- Cryptographic operations (zero-copy) ---
data.writeTo(messageDigest); // feed into MessageDigest
data.writeTo(messageDigest, offset, length); // feed slice
data.updateSignature(signature); // update java.security.Signature
data.updateSignature(signature, offset, length);
boolean valid = data.verifySignature(signature); // verify Signature
// --- Low-level access ---
byte b = data.getByte(offset); // single byte
int i = data.getInt(offset); // 4 bytes, big-endian
long l = data.getLong(offset); // 8 bytes, big-endian
int vi = data.getVarInt(offset, zigZag); // protobuf varint
long vl = data.getVarLong(offset, zigZag); // protobuf varlong
// --- Sorting ---
Bytes.SORT_BY_LENGTH // Comparator: shorter first
Bytes.SORT_BY_SIGNED_VALUE // Comparator: signed byte comparison
Bytes.SORT_BY_UNSIGNED_VALUE // Comparator: unsigned byte comparisonBytes.EMPTY is a singleton empty instance. The compareTo() method performs unsigned lexicographic comparison.
BufferedData is a sealed class wrapping a ByteBuffer that implements both ReadableSequentialData and WritableSequentialData. It has two subclasses selected automatically:
ByteArrayBufferedData— backed by a heap byte array. Use for short-lived, general-purpose buffers.DirectBufferedData— backed by off-heap (direct) memory. Use for long-lived buffers or when interacting with native I/O.
import com.hedera.pbj.runtime.io.buffer.BufferedData;
// Heap allocation (most common)
BufferedData buf = BufferedData.allocate(1024);
// Off-heap allocation (for long-lived, performance-critical buffers)
BufferedData buf = BufferedData.allocateOffHeap(1024);
// Wrap existing data
BufferedData buf = BufferedData.wrap(byteArray);
BufferedData buf = BufferedData.wrap(byteBuffer); // auto-selects subclass
// Use as both reader and writer
buf.writeInt(42);
buf.writeBytes(someBytes);
buf.flip(); // switch from writing to reading
int value = buf.readInt();
// Convert to other types
InputStream is = buf.toInputStream(); // zero-copyBufferedData.EMPTY_BUFFER is a singleton empty read-only buffer.
WritableMessageDigest wraps a MessageDigest as a WritableSequentialData, allowing you to compute a hash of serialized data as it's being written — without buffering the entire message first:
import com.hedera.pbj.runtime.hashing.WritableMessageDigest;
MessageDigest md = MessageDigest.getInstance("SHA-384");
WritableMessageDigest wmd = new WritableMessageDigest(md);
// Write serialized data directly into the digest
MyMessage.PROTOBUF.write(message, wmd);
// Get the hash — no intermediate byte[] needed
byte[] hash = wmd.digest(); // also resets for reuse
// Or write the digest directly into a buffer
wmd.reset();
MyMessage.PROTOBUF.write(anotherMessage, wmd);
wmd.digestInto(outputBuffer, offset);This is particularly useful in the consensus node where message hashes are computed during serialization for state proofs and signatures.
Always catch ParseException when parsing untrusted input:
try {
MyMessage msg = MyMessage.PROTOBUF.parse(untrustedBytes);
} catch (ParseException e) {
logger.warn("Failed to parse message: {}", e.getMessage());
// Handle gracefully
}PBJ can generate shallow diff and replay support for selected messages. A producer sends only the top-level fields that changed, plus a list of fields to clear, and a consumer rebuilds the complete value from the prior value. Nested messages, repeated fields and maps are replaced as whole fields. A partial is an ordinary encoding of the message, so any protobuf library can parse it.
Diff support is opt-in. Only diff roots get the diff APIs. Messages reachable from a root (through message fields,
lists, maps and oneofs) get only the encoding-aware $equalsForDiff() used for comparison. Unrelated messages are
unchanged. Select roots in Gradle, with message comments, or both. The selections are additive.
pbj {
// Individual roots, by fully qualified protobuf message name (not the generated Java name)
diffMessages = setOf("proto.Account", "proto.Token")
// Every message-valued field of each container (including oneof members and map values) becomes a root.
// The container itself is not selected.
diffMessageContainers = setOf("com.hedera.hapi.platform.state.StateValue")
// Optional: track touched fields in copy builders on roots with more than 8 entries, to speed up diff()
generateCopyBuilderTracking = true
}With a container catalog such as Hiero's StateValue, every state type becomes a root, including types added later,
without maintaining a separate list. An unknown name in either set fails the build.
Each root Model gets these static methods:
long diff(Model prior, Model next); // mask of changed fields
long diff(Model prior, Model next, long candidates); // check only candidate fields
Model partial(Model next, long mask); // keep masked fields, reset the rest
int[] clearedFields(Model prior, Model next, long mask); // precise clears, including old oneof alternatives
int[] clearedFields(Model next, long mask); // clears every inactive oneof alternative
boolean worthPartial(Model next, long mask, int minSaving, int overhead);
Model applyPartial(Model prior, Model partial, int[] cleared);Each mask bit is one ordinary field or one oneof group, in declaration order. Models with more than 64 entries use
long[] masks. Cleared lists are sorted protobuf field numbers. Every changed field is checked by value, so a field
set back to its existing value is not reported as changed.
Each root's PROTOBUF codec also extends DiffCodec<T>. Callers that only hold a registered Codec<T> can
discover diff support with instanceof DiffCodec and work with opaque Object masks.
A tracked root implements CopyBuilderTracked<T>. Values built through copyBuilder() remember the instance they were copied from and which
setters were called. When next was copied directly from prior, diff(prior, next) checks only the touched
fields. Otherwise it compares every field. Either way, the result is identical. Tracking only affects CPU cost and
never appears in equals(), hashCode(), toString() or the codecs. CopyBuilderTracked.untracked(value) returns
an equal copy without tracking state.
Which roots are tracked:
pbj.copy_builder_tracking = trueor= falseon a message always wins.- Otherwise,
generateCopyBuilderTracking = truetracks only roots with more than 8 mask entries (ordinary and map fields, plus one per oneof group). Smaller roots keep the diff APIs and compare every field, which costs about the same as checking a mask.
Tracking is a property of the generated class, so it adds an object reference and a mask to every instance of a
tracked root, including instances nested inside other messages. The size limit keeps small, widely used roots, such
as AccountID and Timestamp when selected through StateValue, from paying that cost everywhere. If you annotate a
small type with pbj.copy_builder_tracking = true, it pays the same cost.
Producer:
if (codec instanceof DiffCodec<T> diffCodec && diffCodec.canDiff(prior) && diffCodec.canDiff(next)) {
final Object mask = diffCodec.diff(prior, next);
if (diffCodec.worthPartial(next, mask, minSaving, envelopeOverhead)) {
emitPartial(diffCodec.partial(next, mask), diffCodec.clearedFields(prior, next, mask));
return;
}
}
emitFull(next);- Check
canDiffon both values. It returnsfalsefornulland for values with top-level unknown fields. The direct model methods throwUnsupportedOperationExceptionin that case, so emit the full value instead. - Use
clearedFields(prior, next, mask). It clears exactly the fields thatpriorhad andnextlacks. The overload withoutpriorclears every inactive oneof alternative, which is valid but larger. - Oneof switches must clear the old alternative. If
priorset oneof member 5 andnextsets member 7, the cleared list must contain 5.PartialApplieris schema-free and can't infer this, andapplyPartialrejects patches that omit it. - Producers must agree on policy. If several producers must emit identical bytes, they need the same root selection and emission thresholds. Consumers can replay partials without generated diff APIs.
Consumer, with models:
Model next = Model.applyPartial(prior, partial, cleared);Consumer, with bytes and no schema:
Bytes nextBytes = PartialApplier.splice(priorBytes, partialBytes, cleared);splice replaces complete top-level field runs, removes cleared fields and copies the remaining bytes unchanged.
Both inputs must use ascending field order and canonical varints, as PBJ produces. Normalize values from other
encoders through their PBJ codec first. splice validates framing, ordering and clears, but schema-specific checks
remain the caller's responsibility.
PBJ's gRPC implementation runs on Helidon's HTTP/2 stack with no io.grpc dependency. For architecture details, see architecture.md.
// Server
implementation("com.hedera.pbj:pbj-grpc-helidon:<version>")
// Client
implementation("com.hedera.pbj:pbj-grpc-client-helidon:<version>")For each service in a proto file, PBJ generates a *ServiceInterface. Implement it with your business logic:
public class GreeterServiceImpl implements GreeterServiceInterface {
@Override
public HelloReply sayHello(HelloRequest request) throws GrpcException {
if (!request.hasName()) {
throw new GrpcException(GrpcStatus.INVALID_ARGUMENT, "Name required");
}
return HelloReply.newBuilder()
.message("Hello " + request.name())
.build();
}
}For simple request/response RPCs, just implement the method — PBJ handles serialization:
@Override
public HelloReply sayHello(HelloRequest request) {
return HelloReply.newBuilder()
.message("Hello " + request.nameOrElse("stranger"))
.build();
}The server sends multiple responses to a single request:
@Override
public void sayHelloStream(
HelloRequest request,
Flow.Subscriber<? super HelloReply> replies) {
for (int i = 0; i < 10; i++) {
replies.onNext(HelloReply.newBuilder()
.message("Hello " + request.name() + " " + i)
.build());
}
replies.onComplete();
}The client sends multiple requests; the server sends a single response:
@Override
public Flow.Subscriber<HelloRequest> sayHelloCollect(
Flow.Subscriber<HelloReply> response) {
return new Flow.Subscriber<>() {
private final List<String> names = new ArrayList<>();
@Override
public void onSubscribe(Flow.Subscription subscription) {
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(HelloRequest item) {
names.add(item.nameOrElse(""));
}
@Override
public void onError(Throwable throwable) {
response.onError(throwable);
}
@Override
public void onComplete() {
response.onNext(HelloReply.newBuilder()
.message("Hello " + String.join(", ", names))
.build());
response.onComplete();
}
};
}Both client and server stream messages concurrently:
@Override
public Flow.Subscriber<HelloRequest> sayHelloBidi(
Flow.Subscriber<HelloReply> replies) {
return new Flow.Subscriber<>() {
@Override
public void onSubscribe(Flow.Subscription subscription) {
subscription.request(Long.MAX_VALUE);
}
@Override
public void onNext(HelloRequest item) {
replies.onNext(HelloReply.newBuilder()
.message("Hello " + item.name())
.build());
}
@Override
public void onError(Throwable throwable) {
replies.onError(throwable);
}
@Override
public void onComplete() {
replies.onComplete();
}
};
}For advanced control over serialization (e.g., custom size limits), override the open() method and use Pipelines:
@Override
public Pipeline<? super Bytes> open(
Method method,
RequestOptions options,
Pipeline<? super Bytes> replies) {
return switch ((MyServiceMethod) method) {
case myUnaryMethod ->
Pipelines.<MyRequest, MyResponse>unary()
.mapRequest(bytes -> MyRequest.PROTOBUF.parse(
bytes.toReadableSequentialData(),
false, false,
Codec.DEFAULT_MAX_DEPTH,
customMaxSize))
.method(this::myUnaryMethod)
.mapResponse(MyResponse.PROTOBUF::toBytes)
.respondTo(replies)
.build();
};
}WebServer.builder()
.port(8080)
.addRouting(PbjRouting.builder()
.service(new GreeterServiceImpl())
.service(new AnotherServiceImpl()))
.build()
.start();Browsers cannot call native gRPC, because they cannot read HTTP trailers. PbjGrpcWebService serves the same ServiceInterface over gRPC-Web on the same port. It is a Helidon HttpService, so it is registered on the HttpRouting and works over both HTTP/1.1 and HTTP/2:
final var service = new GreeterServiceImpl();
WebServer.builder()
.port(8080)
.addRouting(PbjRouting.builder().service(service)) // native gRPC
.routing(routing -> routing.register(PbjGrpcWebService.of(service))) // gRPC-Web
.build()
.start();Native gRPC requests (application/grpc*) still go to PbjRouting; requests with an application/grpc-web* content type fall through to the HttpRouting.
Use the builder for cross-origin access and limits:
PbjGrpcWebService.builder(service)
.allowedOrigins("https://explorer.example.com") // or PbjGrpcWebService.ANY_ORIGIN
.allowedHeaders("x-my-metadata") // in addition to the gRPC-Web defaults
.config(pbjConfig) // same max message size as native gRPC
.build();Without allowedOrigins, no CORS headers are sent, so only same-origin pages and non-browser clients can call the service.
What is supported:
- Unary and server-streaming methods. gRPC-Web has no client streaming, so client-streaming and bidirectional methods fail with
UNIMPLEMENTED. Services generated by older PBJ versions reportMethodType.UNKNOWNfor every method; those methods are served on a best-effort basis, and should be regenerated. - Binary (
application/grpc-web,+proto,+json) and base64 text (application/grpc-web-text,+proto) modes.Content-Typecontrols request decoding;Acceptcan independently select binary or text responses without changing the protobuf/JSON codec. WithoutAccept, responses use the request's mode.RequestOptions.isProtobuf()andisJson()recognize these content types, so service code needs no changes. - Compression (
grpc-encoding,grpc-accept-encoding) and deadlines (grpc-timeout), including service initialization, stalled request bodies, and synchronous service execution. Expiry interrupts the worker; application code must cooperate with interruption.
Each call holds its (virtual) request thread until completion and dispatches the service on a cancellable virtual worker carrying the same Helidon request context. Detected read/write failures and deadlines notify the request pipeline once. Helidon 4.5.4's HttpService API has no idle-disconnect or HTTP/2 stream-reset callback, so an otherwise idle stream can outlive a disconnected browser. Use a finite grpc-timeout to bound that retention. There is no flow control yet: a slow client slows down the thread that calls onNext.
PBJ's gRPC layer negotiates compression via standard grpc-encoding / grpc-accept-encoding HTTP/2 headers. Two compressors are built-in and registered automatically:
| Algorithm | Header value | Notes |
|---|---|---|
| Identity (none) | identity |
Default, always available |
| Gzip | gzip |
Always available |
Zstd support is in the pbj-grpc-common module:
dependencies {
implementation("com.hedera.pbj:pbj-grpc-common:<version>")
}Register it at application startup (note that Zstd is registered by default by both the PBJ gRPC client and server code):
import com.hedera.pbj.grpc.common.compression.ZstdGrpcTransformer;
// Register with default compression level (3) — already done by default
new ZstdGrpcTransformer().register("zstd");
// Or with a custom compression level (-5 to 22)
new ZstdGrpcTransformer(6).register("zstd");Implement GrpcCompression.Compressor and GrpcCompression.Decompressor (or GrpcCompression.GrpcTransformer for both):
import com.hedera.pbj.runtime.grpc.GrpcCompression;
// Register a custom compressor/decompressor
GrpcCompression.registerCompressor("snappy", mySnappyCompressor);
GrpcCompression.registerDecompressor("snappy", mySnappyDecompressor);
// Query available algorithms
Set<String> compressors = GrpcCompression.getCompressorNames();
Set<String> decompressors = GrpcCompression.getDecompressorNames();Compression is negotiated automatically — the server selects the best algorithm from the client's grpc-accept-encoding header that it also supports.
PbjGrpcClient grpcClient = PbjGrpcClient.builder()
.host("localhost")
.port(8080)
.build();PBJ generates client stubs in the service interfaces. Use them for simple, type-safe calls:
// Create a typed client stub from the generated service interface
var client = new MyServiceInterface.MyServiceClient(grpcClient, options);
// Make a unary call
MyResponse response = client.methodName(request);Implement a Pipeline (extends Flow.Subscriber) to handle responses:
Pipeline<SubscribeStreamResponse> handler = new Pipeline<>() {
@Override
public void onNext(SubscribeStreamResponse response) {
if (response.hasBlockItems()) {
List<BlockItem> items = response.blockItems().blockItems();
// process items
} else if (response.hasStatus()) {
// handle status update
}
}
@Override
public void onError(Throwable throwable) {
logger.error("Stream error", throwable);
}
@Override
public void onComplete() {
logger.info("Stream completed");
}
};A useful proto schema design pattern for performance-sensitive systems is defining "unparsed" message variants that use bytes fields instead of typed message fields. This allows passing data through without deserializing it.
Define a parallel message where nested message fields are replaced with bytes:
// Regular (fully typed) message
message BlockItem {
oneof item {
BlockHeader block_header = 1;
EventHeader event_header = 2;
TransactionResult transaction_result = 5;
BlockProof block_proof = 9;
}
}
// Unparsed variant — same field numbers, but bytes instead of typed messages
message BlockItemUnparsed {
oneof item {
bytes block_header = 1;
bytes event_header = 2;
bytes transaction_result = 5;
bytes block_proof = 9;
}
}Because both messages use the same field numbers and wire type 2 (length-delimited), they are wire-compatible — bytes serialized as BlockItem can be parsed as BlockItemUnparsed and vice versa.
- Pass-through services — A proxy or relay that forwards data without inspecting every field
- Deferred parsing — Parse only the fields you need (e.g., read just the header), leave the rest as bytes
- Forward compatibility — An unparsed variant won't fail to parse when the inner message adds new fields in a newer schema version
- Performance — Avoid the cost of deserializing and re-serializing nested messages that you don't need to inspect
// Parse as unparsed — individual items remain as raw bytes
BlockUnparsed block = BlockUnparsed.PROTOBUF.parse(rawBytes);
// Inspect only what you need
for (BlockItemUnparsed item : block.blockItems()) {
if (item.hasBlockHeader()) {
// Parse just this one field when needed
BlockHeader header = BlockHeader.PROTOBUF.parse(item.blockHeader());
long blockNumber = header.number();
}
// Other items stay as bytes — no parsing cost
}
// Convert between parsed and unparsed when needed
Block fullyParsed = Block.PROTOBUF.parse(BlockUnparsed.PROTOBUF.toBytes(unparsedBlock));This pattern is used extensively in the Hiero Block Node for streaming block data through the system efficiently.
- Protobuf & Schemas — full protobuf spec compliance, type mappings, nullability rules
- Architecture — module structure, dependency graph, design decisions
- Codec Architecture — shared codec interfaces and IO abstractions