When to Use Java Records vs Builders in a Quarkus API
Build a small order API, use records for clean DTOs, and add a builder only where staged construction starts getting messy.
A constructor change shipped on Friday. Two String parameters swapped places in a twelve-argument OrderDto call. Everything compiled. Invoices went out with shipping addresses in the customer name field until someone noticed the pattern in support tickets on Monday.
Records fixed the boilerplate part of that story. The staged assembly problem stayed exactly where it was: inventory, pricing, shipping, and fraud checks keep adding fields until the canonical constructor turns into a minefield.
We build OrderDesk, a small Quarkus API that shows where records are enough on their own, where a hand-written builder still earns its keep, and how Bean Validation fits on record request bodies. The sample uses Quarkus 3.35.2.
What we build
OrderDesk is a compact e-commerce slice that:
exposes
GET /productswith simple record DTOs;exposes
GET /orders/samplewith a multi-field OrderDto assembled through OrderDtoBuilder and a staged OrderAssemblyService;exposes
POST /orderswith a validated CreateOrderRequest record;ships unit and
@QuarkusTestcoverage for builder invariants and HTTP validation.
What you need
You have written Jakarta REST resources before and know what a DTO is for.
JDK 21
Quarkus CLI or Maven
curlfor manual checksAbout two ☕️☕️
Project setup
Create the project:
quarkus create app com.orderdesk:orderdesk-records-builders \
--extension='quarkus-rest-jackson,hibernate-validator' \
--java=21 \
--no-code
cd orderdesk-records-buildersExtensions:
quarkus-rest-jackson— REST endpoints and Jackson JSON serialization for recordshibernate-validator— Bean Validation on request bodies and method parameters
Use package com.orderdesk for application code.
Simple record DTOs
Before records, a three-field product DTO was mostly ceremony: fields, constructor, getters, equals, hashCode, and often Lombok because nobody wanted to maintain it by hand.
A record states the contract in one place — immutable data, value semantics, transparent state:
package com.orderdesk;
import java.math.BigDecimal;
public record ProductDto(
Long id,
String name,
BigDecimal price
) {
}Jackson deserializes records through the canonical constructor on current Quarkus, so this sample does not need extra annotations.
Product catalog and list endpoint
package com.orderdesk;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ProductCatalog {
private static final Map<Long, ProductDto> PRODUCTS = Map.of(
1L, new ProductDto(1L, "Mechanical Keyboard", new BigDecimal("149.99")),
2L, new ProductDto(2L, "Vertical Mouse", new BigDecimal("89.99")));
public List<ProductDto> listAll() {
return PRODUCTS.keySet().stream()
.sorted()
.map(PRODUCTS::get)
.toList();
}
public Optional<ProductDto> findById(long id) {
return Optional.ofNullable(PRODUCTS.get(id));
}
}package com.orderdesk;
import java.util.List;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
@Path("/products")
public class ProductResource {
private final ProductCatalog catalog;
public ProductResource(ProductCatalog catalog) {
this.catalog = catalog;
}
@GET
public List<ProductDto> listProducts() {
return catalog.listAll();
}
}Start dev mode:
./mvnw quarkus:devList products:
curl -s http://localhost:8080/products | jqYou should see two products with id, name, and price. Map.of is fine for lookup by id, but do not assume values() iteration order — sort keys (or use a LinkedHashMap) when the API must return a stable list. This is the sweet spot: small immutable carriers, no construction pipeline, no builder.
When the canonical constructor stops scaling
Real order DTOs grow. Cart submission, inventory confirmation, tax, shipping, fraud scoring, addresses — fields arrive in stages from different steps. The record still fits as the immutable result:
package com.orderdesk;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record OrderDto(
String orderId,
String customerId,
List<ProductDto> products,
BigDecimal subtotal,
BigDecimal tax,
BigDecimal shipping,
BigDecimal total,
String currency,
String shippingAddress,
String billingAddress,
String status,
Integer fraudScore,
Instant createdAt
) {
public OrderDto {
if (orderId == null || orderId.isBlank()) {
throw new IllegalArgumentException("orderId must not be blank");
}
}
}The compact constructor is the right place for single-field invariants that must hold on every instance. Cross-field rules like subtotal matching line items or shipping being required for physical goods deserve a different home, so we put those on the builder.
Calling new OrderDto(...) with a dozen positional arguments is where teams swap two String values and ship garbage. Named assembly fixes that without giving up record immutability.
OrderDtoBuilder
The builder is mutable scratch space. build() turns that scratch space into the record once, with validation and defensive copies:
package com.orderdesk;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
public class OrderDtoBuilder {
private String orderId;
private String customerId;
private List<ProductDto> products = new ArrayList<>();
private BigDecimal subtotal = BigDecimal.ZERO;
private BigDecimal tax = BigDecimal.ZERO;
private BigDecimal shipping = BigDecimal.ZERO;
private BigDecimal total = BigDecimal.ZERO;
private String currency = "EUR";
private String shippingAddress;
private String billingAddress;
private String status = "CREATED";
private Integer fraudScore = 0;
private Instant createdAt = Instant.now();
public OrderDtoBuilder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public OrderDtoBuilder customerId(String customerId) {
this.customerId = customerId;
return this;
}
public OrderDtoBuilder addProduct(ProductDto product) {
this.products.add(product);
return this;
}
public OrderDtoBuilder subtotal(BigDecimal subtotal) {
this.subtotal = subtotal;
return this;
}
public OrderDtoBuilder tax(BigDecimal tax) {
this.tax = tax;
return this;
}
public OrderDtoBuilder shipping(BigDecimal shipping) {
this.shipping = shipping;
return this;
}
public OrderDtoBuilder total(BigDecimal total) {
this.total = total;
return this;
}
public OrderDtoBuilder currency(String currency) {
this.currency = currency;
return this;
}
public OrderDtoBuilder shippingAddress(String shippingAddress) {
this.shippingAddress = shippingAddress;
return this;
}
public OrderDtoBuilder billingAddress(String billingAddress) {
this.billingAddress = billingAddress;
return this;
}
public OrderDtoBuilder status(String status) {
this.status = status;
return this;
}
public OrderDtoBuilder fraudScore(Integer fraudScore) {
this.fraudScore = fraudScore;
return this;
}
List<ProductDto> productsSnapshot() {
return List.copyOf(products);
}
BigDecimal totalSnapshot() {
return total;
}
public OrderDto build() {
if (customerId == null || customerId.isBlank()) {
throw new IllegalStateException("Customer ID is required");
}
if (products.isEmpty()) {
throw new IllegalStateException("At least one product is required");
}
if (orderId == null || orderId.isBlank()) {
throw new IllegalStateException("Order ID is required");
}
return new OrderDto(
orderId,
customerId,
List.copyOf(products),
subtotal,
tax,
shipping,
total,
currency,
shippingAddress,
billingAddress,
status,
fraudScore,
createdAt);
}
}.shipping(new BigDecimal("9.99")) is easier to read at 2am than the seventh anonymous argument to a constructor. Defaults like currency = "EUR" and status = "CREATED" live in one place instead of spreading across call sites.
Staged enrichment with OrderAssemblyService
This is the part records do not solve on their own. One builder instance walks through inventory, pricing, shipping, and fraud enrichment before build() freezes the record.
package com.orderdesk;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.UUID;
import org.jboss.logging.Logger;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class OrderAssemblyService {
private static final Logger LOG = Logger.getLogger(OrderAssemblyService.class);
private static final BigDecimal TAX_RATE = new BigDecimal("0.19");
private static final BigDecimal SHIPPING_FLAT = new BigDecimal("9.99");
private final ProductCatalog catalog;
public OrderAssemblyService(ProductCatalog catalog) {
this.catalog = catalog;
}
public OrderDto buildSampleOrder(String orderId) {
ProductDto keyboard = catalog.findById(1L).orElseThrow();
ProductDto mouse = catalog.findById(2L).orElseThrow();
OrderDtoBuilder builder = new OrderDtoBuilder()
.orderId(orderId)
.customerId("customer-42")
.addProduct(keyboard)
.addProduct(mouse)
.shippingAddress("Main Street 10")
.billingAddress("Main Street 10");
applyInventory(builder);
applyPricing(builder);
applyShipping(builder);
applyFraud(builder);
return builder.build();
}
public OrderDto assembleFromRequest(CreateOrderRequest request) {
OrderDtoBuilder builder = new OrderDtoBuilder()
.orderId(UUID.randomUUID().toString())
.customerId(request.customerId())
.shippingAddress(request.shippingAddress())
.billingAddress(request.shippingAddress());
for (Long productId : request.productIds()) {
ProductDto product = catalog.findById(productId)
.orElseThrow(() -> new IllegalArgumentException("Unknown product: " + productId));
builder.addProduct(product);
}
applyInventory(builder);
applyPricing(builder);
applyShipping(builder);
applyFraud(builder);
OrderDto order = builder.build();
LOG.infof("Assembled order %s for customer %s", order.orderId(), order.customerId());
return order;
}
private void applyInventory(OrderDtoBuilder builder) {
builder.status("INVENTORY_CONFIRMED");
LOG.debug("Inventory enrichment applied");
}
private void applyPricing(OrderDtoBuilder builder) {
BigDecimal subtotal = builder.productsSnapshot().stream()
.map(ProductDto::price)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal tax = subtotal.multiply(TAX_RATE).setScale(2, RoundingMode.HALF_UP);
BigDecimal totalBeforeShipping = subtotal.add(tax);
builder.subtotal(subtotal).tax(tax).total(totalBeforeShipping).status("PRICED");
LOG.debugf("Pricing enrichment applied: subtotal=%s tax=%s", subtotal, tax);
}
private void applyShipping(OrderDtoBuilder builder) {
BigDecimal totalWithShipping = builder.totalSnapshot().add(SHIPPING_FLAT);
builder.shipping(SHIPPING_FLAT).total(totalWithShipping).status("SHIPPING_QUOTED");
LOG.debug("Shipping enrichment applied");
}
private void applyFraud(OrderDtoBuilder builder) {
int score = builder.totalSnapshot().compareTo(new BigDecimal("200")) > 0 ? 12 : 5;
builder.fraudScore(score).status("READY");
LOG.debugf("Fraud enrichment applied: score=%d", score);
}
}Each apply* method stands in for another service in a larger system. The builder is the assembly boundary. The record is what you hand back to REST or messaging once the work is done.
Order endpoints
package com.orderdesk;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import jakarta.validation.Valid;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/orders")
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
private final OrderAssemblyService assemblyService;
private final String sampleOrderId;
public OrderResource(
OrderAssemblyService assemblyService,
@ConfigProperty(name = "orderdesk.sample-order-id", defaultValue = "ORD-2026-001") String sampleOrderId) {
this.assemblyService = assemblyService;
this.sampleOrderId = sampleOrderId;
}
@GET
@Path("/sample")
public OrderDto sampleOrder() {
return assemblyService.buildSampleOrder(sampleOrderId);
}
@POST
@Consumes(MediaType.APPLICATION_JSON)
public OrderDto createOrder(@Valid CreateOrderRequest request) {
return assemblyService.assembleFromRequest(request);
}
}Optional config in application.properties keeps the sample order id stable in logs:
orderdesk.sample-order-id=ORD-2026-001Fetch the sample order:
curl -s http://localhost:8080/orders/sample | jq '.orderId, .status, .total'Expect ORD-2026-001, READY, and a computed total that includes tax and flat shipping.
Validated request records
Ingress DTOs are usually the easy case: immutable data carriers with validation at the edge. Bean Validation annotations on record components work the same as on classes:
package com.orderdesk;
import java.util.List;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
public record CreateOrderRequest(
@NotBlank String customerId,
@NotEmpty List<Long> productIds,
@NotBlank String shippingAddress
) {
}Create an order:
curl -s -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{
"customerId": "customer-77",
"productIds": [1],
"shippingAddress": "Tech Street 42"
}' | jq '.customerId, .status, .products | length'Send an empty payload and Quarkus returns HTTP 400 with constraint violations before your assembly service gets a chance to build anything.
Where builders are overkill
A builder on a three-field ProductDto buys you very little. There are no staged steps, no cross-field rules, and no enrichment pipeline. Use the record constructor and move on.
My rule of thumb:
Small immutable DTO → record only
Staged or multi-step construction → record + builder (or factory) at the assembly boundary
Mutable domain object → regular class
ORM entity → regular class, separate from API DTOs
Wrapper records like record CustomerId(String value) help when several String parameters would otherwise look identical to the compiler. They are cheap insurance on large payloads; this sample keeps plain strings so the builder contrast stays obvious.
Under load and across versions
Thread confinement — create a new OrderDtoBuilder per request. Builders are mutable scratch pads. Putting one in an @ApplicationScoped bean and reusing it across threads will leak order state between customers. I have seen the same failure mode with reusable JAXB builders years ago; the shape changes, the bug does not.
Validation boundaries — record compact constructors for invariants that must hold on every instance. Builders for cross-field assembly rules before the record exists. Bean Validation on ingress records for wire-format constraints. If you force all three concerns into one constructor, the result usually reads like a bug report.
DTO evolution — records are strict. When upstream APIs add fields gradually, builders absorb defaults and optional steps more gracefully than widening every new OrderDto(...) call site.
Jackson — records serialize cleanly in current Quarkus. Builders still help when you normalize inconsistent external JSON into one internal DTO shape before build().
Prove it
Run the test suite:
./mvnw testSeven tests cover builder invariants, record compact-constructor rejection, product listing, sample order shape, successful POST assembly, and validation failures on bad POST bodies.
Manual smoke checks while quarkus:dev is running:
curl -s http://localhost:8080/products
curl -s http://localhost:8080/orders/sample
curl -s -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{"customerId":"","productIds":[],"shippingAddress":""}'The last call should return 400, not a half-built order.
Closing
Records removed most of the DTO boilerplate I used to carry. Careful construction still matters when objects grow, enrich in stages, or need defaults and cross-field checks before they exist.
OrderDesk is intentionally small: records for the simple shapes, a builder at the assembly boundary, validation split across ingress annotations, builder build(), and the record compact constructor. That split is the point.
The finished sample lives in the orderdesk-records-builders repository.


