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)
}Setting generateTestClasses = false avoids requiring a dependency on Google Protobuf libraries (which the generated tests use for binary compatibility validation).
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.
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'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();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