I had a customer conversation recently about a service pair like this. A catalog service returned a pricing snapshot as JSON. An internal Java service received that same snapshot for every quote. We talked about when a binary format pays for itself. I wanted to follow up with a small example here.
I would leave the catalog endpoint as JSON. A browser can use it, a support engineer can read it, and curl can print it. The internal Java call runs often and both services use the same model. A compact payload can save CPU, allocations, and network traffic.
I keep binary formats off broad client routes. Mobile clients and partner integrations need a body that people can inspect. I use Fory only where both Java services can support the contract.
My earlier Protobuf REST tutorial covers a different case. Protobuf is a good fit for a durable, language-neutral schema. It gives you a schema file, a compiler, generated types, and explicit field numbers. Apache Fory can serialize a Java object graph directly. The Quarkus Fory extension adds it to CDI and Quarkus REST.
I use Fory for a limited case: Java services share a versioned model and exchange this kind of data often. The Java model, stable class IDs, and release process define the contract.
I use a pricing service with two routes. The catalog route returns JSON. The internal pricing route reads and writes application/fory. The same PricingSnapshot crosses both routes. I set stable class IDs, choose a rolling-deployment mode, limit request size, and test the real HTTP boundary.
What You Need
I use Quarkus 3.39.1, Java 21, and Quarkus Fory 1.6.0. The extension guide uses a Java 17+ record. Java 21 is a good baseline for a new service.
Java 21 or newer
Quarkus CLI
curlAbout two ☕️☕️
Two Fory terms are used below. Class registration limits Fory to a known set of Java types. A class ID is the stable numeric ID for one of those types on the wire. When registration is enabled, both peers use the same IDs.
Create the Application
Create an empty Quarkus REST application or start from my Github repository:
quarkus create app -B \
-P io.quarkus.platform:quarkus-bom:3.39.1 \
--maven \
--java=21 \
--no-code \
--extensions=rest-jackson \
com.themainthread:quarkus-fory-internal-contracts
cd quarkus-fory-internal-contractsquarkus-rest-jackson serves the public JSON endpoint. Fory has its own release cadence, so I pin its published version in pom.xml:
<properties>
<fory.version>1.6.0</fory.version>
</properties>Add the extension and the test client:
<dependencies>
<dependency>
<groupId>io.quarkiverse.fory</groupId>
<artifactId>quarkus-fory</artifactId>
<version>${fory.version}</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<scope>test</scope>
</dependency>
</dependencies>At startup, the extension creates an injectable BaseFory instance and registers Quarkus REST providers for application/fory. The REST resource only needs @Consumes and @Produces. The Fory extension guide also shows direct CDI serialization for cache values, files, and message payloads outside REST.
Write Down the Contract
I send the pricing service a snapshot. It should price one known input, not fetch catalog data halfway through the calculation. That gives us a reproducible request and a realistic object graph: a snapshot, an address, line items, and a quote response.
Create PricingSnapshot.java:
package com.themainthread.pricing;
import java.util.List;
import io.quarkiverse.fory.ForySerialization;
@ForySerialization(classId = 256)
public record PricingSnapshot(
String snapshotId,
String customerTier,
ShippingAddress destination,
List<LineItem> lines) {
}Create ShippingAddress.java:
package com.themainthread.pricing;
import io.quarkiverse.fory.ForySerialization;
@ForySerialization(classId = 257)
public record ShippingAddress(
String countryCode,
String postalCode) {
}Create LineItem.java:
package com.themainthread.pricing;
import io.quarkiverse.fory.ForySerialization;
@ForySerialization(classId = 258)
public record LineItem(
String sku,
int quantity,
long unitPriceCents,
int weightGrams) {
}Finally, create QuoteDecision.java:
package com.themainthread.pricing;
import io.quarkiverse.fory.ForySerialization;
@ForySerialization(classId = 259)
public record QuoteDecision(
String snapshotId,
long subtotalCents,
long shippingCents,
long totalCents,
int deliveryDays) {
}@ForySerialization makes each model type available to the extension at build time. I start IDs at 256 because Fory reserves the lower range. In a real system, keep the allocation in a version-controlled contract module or registry. 256 always means PricingSnapshot. Reusing it for another type makes old bytes decode as the wrong thing.
With Fory, the Java types, class IDs, and compatibility rules make up the contract. Put these records in a small contract JAR. Copied classes slowly drift, especially once two teams release on different schedules.
Add a Small Pricing Rule
I use this sample data on both routes. Create SampleSnapshots.java:
package com.themainthread.pricing;
import java.util.List;
public final class SampleSnapshots {
private SampleSnapshots() {
}
public static PricingSnapshot sample() {
return new PricingSnapshot(
"quote-20260829-001",
"gold",
new ShippingAddress("DE", "10115"),
List.of(
new LineItem("STORM-JACKET", 1, 12_999, 1_100),
new LineItem("HIKING-SOCKS", 3, 799, 450)));
}
}I store prices as cents in a long. Floating-point values can add a rounding problem to checkout, which already has enough problems.
Create QuoteCalculator.java:
package com.themainthread.pricing;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class QuoteCalculator {
public QuoteDecision quote(PricingSnapshot snapshot) {
long subtotalCents = snapshot.lines().stream()
.mapToLong(line -> Math.multiplyExact(line.quantity(), line.unitPriceCents()))
.sum();
int totalWeightGrams = snapshot.lines().stream()
.mapToInt(line -> Math.multiplyExact(line.quantity(), line.weightGrams()))
.sum();
long shippingCents = shippingCents(snapshot.destination().countryCode(), totalWeightGrams);
int deliveryDays = totalWeightGrams > 10_000 ? 4 : 2;
return new QuoteDecision(
snapshot.snapshotId(),
subtotalCents,
shippingCents,
Math.addExact(subtotalCents, shippingCents),
deliveryDays);
}
private long shippingCents(String countryCode, int totalWeightGrams) {
long baseShippingCents = "DE".equals(countryCode) ? 799 : 1_499;
return totalWeightGrams > 10_000 ? baseShippingCents + 500 : baseShippingCents;
}
}Math.multiplyExact and Math.addExact stop an invalid or unexpectedly large snapshot from wrapping a money value. They only protect the calculation. The caller still needs input limits and service-level authorization.
Serve the Public Snapshot as JSON
Create CatalogSnapshotResource.java:
package com.themainthread.pricing;
import static jakarta.ws.rs.core.MediaType.APPLICATION_JSON;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
@Path("/catalog/snapshots")
@ApplicationScoped
@Produces(APPLICATION_JSON)
public class CatalogSnapshotResource {
@GET
@Path("/sample")
public PricingSnapshot sample() {
return SampleSnapshots.sample();
}
}Start Quarkus:
./mvnw quarkus:devIn a second terminal:
curl -s http://localhost:8080/catalog/snapshots/sampleThe response is easy to inspect:
{"snapshotId":"quote-20260829-001","customerTier":"gold","destination":{"countryCode":"DE","postalCode":"10115"},"lines":[{"sku":"STORM-JACKET","quantity":1,"unitPriceCents":12999,"weightGrams":1100},{"sku":"HIKING-SOCKS","quantity":3,"unitPriceCents":799,"weightGrams":450}]}I keep this endpoint as JSON because it faces callers outside the service. The binary route starts at the internal boundary.
Add the Fory Pricing Endpoint
Create PricingResource.java:
package com.themainthread.pricing;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
@Path("/internal/pricing")
@ApplicationScoped
@Consumes("application/fory")
@Produces("application/fory")
public class PricingResource {
private final QuoteCalculator quoteCalculator;
@Inject
public PricingResource(QuoteCalculator quoteCalculator) {
this.quoteCalculator = quoteCalculator;
}
@POST
@Path("/quote")
public QuoteDecision quote(PricingSnapshot snapshot) {
return quoteCalculator.quote(snapshot);
}
}The extension provides the application/fory message-body reader. Quarkus deserializes the request before it calls quote and serializes QuoteDecision after it returns. It also supports vendor media types ending in +fory. Use one when you need a versioned media type.
Set the protocol rules in src/main/resources/application.properties:
quarkus.http.limits.max-body-size=64K
quarkus.fory.required-class-registration=true
quarkus.fory.compatible-mode=compatible
quarkus.fory.track-ref=falsequarkus.http.limits.max-body-size=64K rejects large bodies before Fory reads the object graph. Quarkus allows 10 MB by default. A pricing snapshot needs far less, so I set a limit that matches this route.
quarkus.fory.required-class-registration=true tells Fory to decode a known type set. It is the extension default. I still declare it because it is part of the protocol. Authentication, authorization, and business validation need their own controls. A binary payload is still input from the network.
quarkus.fory.compatible-mode=compatible lets peers add and remove fields during a rolling deployment. I keep class IDs fixed, release an added field before I send it, and remove a field only after every old peer is gone. If a field changes type or meaning, I create a new contract version. Quarkus fixes the Fory settings in the built application, so build and deploy a new artifact when you change this policy.
quarkus.fory.track-ref=false fits this tree of immutable records. Turn on reference tracking when the model shares object instances or contains cycles. A flat request does not need it.
Test the Actual Binary Boundary
curl can inspect the JSON route. It cannot create a Fory payload from a Java record. The test uses the BaseFory bean from the extension. Create PricingResourceTest.java:
package com.themainthread.pricing;
import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import jakarta.inject.Inject;
import org.apache.fory.BaseFory;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class PricingResourceTest {
@Inject
BaseFory fory;
@Test
void acceptsAndReturnsTheRegisteredForyContract() {
byte[] request = fory.serialize(SampleSnapshots.sample());
byte[] response = given()
.contentType("application/fory")
.accept("application/fory")
.body(request)
.when()
.post("/internal/pricing/quote")
.then()
.statusCode(200)
.extract()
.asByteArray();
QuoteDecision quote = (QuoteDecision) fory.deserialize(response);
assertFalse(response.length == 0);
assertEquals("quote-20260829-001", quote.snapshotId());
assertEquals(15_396, quote.subtotalCents());
assertEquals(799, quote.shippingCents());
assertEquals(16_195, quote.totalCents());
assertEquals(2, quote.deliveryDays());
}
@Test
void keepsJsonOutOfTheInternalContract() {
given()
.contentType("application/json")
.body("{\"snapshotId\":\"quote-20260829-001\"}")
.when()
.post("/internal/pricing/quote")
.then()
.statusCode(415);
}
@Test
void keepsThePublicCatalogEndpointReadable() {
given()
.accept("application/json")
.when()
.get("/catalog/snapshots/sample")
.then()
.statusCode(200)
.contentType("application/json")
.body("snapshotId", org.hamcrest.Matchers.equalTo("quote-20260829-001"));
}
}Run the tests:
./mvnw testMaven finishes with BUILD SUCCESS. The first test sends bytes over HTTP and reads the response as QuoteDecision. The second test gets 415 Unsupported Media Type. That stops Jackson from becoming an undocumented second format for the internal endpoint. The third test checks the public JSON route.
Choose Fory for the Right Boundary
I use Fory for frequent Java-to-Java calls where the payload is an object graph already shared through a contract JAR. Pricing snapshots and internal batch hand-offs fit that shape.
I use Protobuf for cross-language contracts, partner APIs, and durable events that live for a long time. In those systems, the schema needs to stand on its own. The generated model and field numbers give the contract a clear home outside one shared Java codebase.
I still measure this with real traffic shape. Check end-to-end request time, response size, allocation rate, and CPU under concurrent load. Include database access and the calculation around serialization. A tiny serialize() benchmark can look great while the real request waits on something else.
Handle Rolling Deployments Before the First One
compatible mode gives us room to add and remove fields during a rollout. I keep class IDs stable. I add a field before I rely on it and remove it only after the last old peer is gone. A Java type can stay the same while its business meaning changes. I treat that as a new contract version.
I add two-version tests in CI. Run the current consumer against payload fixtures from the previous release, then run the previous consumer against the current producer. The test above proves the HTTP provider path. These two-version tests prove the release path.
Caches and queues need the same care. Their bytes can live longer than an HTTP request. Give them an expiration, key version, or envelope version. Old bytes can arrive after a rollout, so a reused class ID is a serious bug.
Keep the Binary Endpoint Bounded and Private
Class registration limits the Java types Fory will decode. I put application/fory endpoints behind mTLS, service identity, or the same authorization used by other internal service calls. Public API documentation should not advertise routes that external callers cannot use.
The 64 KB HTTP limit protects this route from oversized requests. Pick the production value from the largest supported snapshot plus a small margin. Add a metric or access log for rejected requests. A 5 MB import needs a separate streamed endpoint with its own authorization and limits. Raising the price endpoint limit for that import makes both routes worse.
Reference tracking stays off for this record-only model. If you add bidirectional relationships, decide if they belong on the wire first. Sending a whole ORM graph through a binary serializer moves accidental complexity from one service to another.
Conclusion
I use JSON for people and broad clients, and Fory for a shared Java pricing snapshot on an internal REST route. I keep the binary path controlled with stable class IDs, additive release rules, request limits, and compatibility tests.


