Services & data products
The anatomy of a data product, order-service as the template built end to end, and inventory/review as further products that demonstrate the pattern varying by role.
A data mesh’s central claim is that a domain team ships data the same way it ships a service: as an independently deployable, independently owned unit with its own storage, its own API, and its own contract. This chapter makes that claim concrete in Quarkus. It builds one service, order-service, all the way through — entity, REST resource, a synchronous call to another service, an event publish — as the template the rest of the mesh repeats, then shows two more services, inventory-service and review-service, that reuse the same shape while varying the protocol surface to fit their role.
The code is in order-service, inventory-service, and review-service. demo-order.sh builds and runs order-service and inventory-service together and drives an order through them; the script header describes what it proves and the sharp edges hit wiring it up.
What a data product looks like here
In the abstract, a data product is the architectural quantum of a data mesh: the smallest independently deployable unit that carries everything it needs to do its job — input ports where data comes in, output ports where it serves data out, the transformation between them, and enough self-description that another domain can find it, understand its shape, and depend on it safely.
Figure 3.1 draws that quantum as the data mesh literature does: input and output ports where data crosses the product’s boundary, three structural pieces doing the work inside — code (pipelines, APIs, policy-as-code), data & metadata (polyglot storage, schema, SLOs), and infrastructure (build, deploy, run, serve) — and a control port layered on top carrying governance, SLOs, and access policy, plus the checklist a product is expected to satisfy: discoverable, addressable, understandable, trustworthy, natively accessible, interoperable, valuable on its own, and secure.
In this project, that abstraction is concrete: each domain service is a data product. It owns a slice of Postgres (its internal state), serves data through its REST/gRPC endpoints (output ports), optionally accepts synchronous calls or Kafka events as input, and — from the next chapter on — publishes a versioned contract so other domains can depend on its shape without reading its source. The service boundary and the data-product boundary are the same boundary, which keeps domain ownership enforceable.
| Service | Owns | Surface |
|---|---|---|
order-service |
the orders table, the order lifecycle |
REST in (POST/GET /orders), gRPC out to inventory-service, publishes order.placed |
inventory-service |
the stock table |
gRPC server (CheckStock), plus a REST demo surface (/stock) for seeding and inspection |
review-service |
product reviews, keyed by SKU | REST only (/reviews), with one admin-only endpoint protected by OIDC |
notification-service |
notifications, derived from events | Kafka consumer only, pushes to WebSocket clients |
payment-service / shipping-service |
payments / shipments | Kafka consumer → processor → Kafka producer, no externally callable API at all |
graphql-gateway |
nothing of its own | GraphQL only, composing REST + gRPC from other services |
The variation follows from role: a service exposes the
protocols its role needs, not a uniform surface. notification-service
and the payment/shipping pair are event-only because their job is to react,
not to be called; review-service is REST-only because it has no
cross-service synchronous dependency; order-service needs both REST (for
clients) and a gRPC client (to validate stock before committing). The
reasoning behind which protocol fits which job is the subject of the next
two chapters — contracts, then the data planes themselves.
Each service owns its own Postgres schema/database in the shared compose
stack (orderdb, inventorydb, and so on — see init and each
service’s application.properties). One cluster, one database per service
gives per-service data ownership without running a fleet of
database instances.
order-service: the template, built end to end
The template is built end to end first: entity → persistence → a synchronous dependency on another service → an event publish. The remaining services repeat that shape with different payloads.
The entity: Order
@Entity
@Table(name = "orders")
public class Order extends PanacheEntityBase {
@Id
@Column(length = 36, nullable = false, updatable = false)
public String id;
@Column(name = "customer_id", nullable = false)
public String customerId;
@Column(name = "item_sku", nullable = false)
public String itemSku;
@Column(nullable = false)
public int quantity;
@Column(nullable = false, precision = 12, scale = 2)
public BigDecimal amount;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
public OrderStatus status;
@Column(name = "created_at", nullable = false)
public Instant createdAt;
public static Order create(String customerId, String itemSku, int quantity, BigDecimal amount) {
Order order = new Order();
order.id = UUID.randomUUID().toString();
order.customerId = customerId;
order.itemSku = itemSku;
order.quantity = quantity;
order.amount = amount;
order.status = OrderStatus.PLACED;
order.createdAt = Instant.now();
return order;
}
}
This is Hibernate ORM with Panache in its active-record style: the
entity extends PanacheEntityBase and carries its own persistence
operations (persist(), findById(), listAll()) rather than routing
through a separate repository class. PanacheEntityBase (not the shorter
PanacheEntity) is the right base because the primary key is a
String UUID the application assigns (UUID.randomUUID()), not the
auto-generated Long id that PanacheEntity defines. order-service controls
its id generation so the id can be returned to the client immediately and
travel unchanged into the order.placed event as order_id.
status is a Java enum (OrderStatus) persisted with
@Enumerated(EnumType.STRING) rather than ORDINAL. Storing the name
("PLACED") instead of the ordinal position (0) means adding a new status
later, or reordering the enum, can’t silently reinterpret existing rows as
the wrong status, a risk with ORDINAL that costs nothing to avoid.
The create(...) static factory is the one place an Order gets built: it
assigns the id, defaults status to PLACED, and stamps createdAt —
centralizing those decisions so OrderResource only ever calls create(...)
and persist(), never assembles a half-initialized Order by hand.
The REST resource: validate, persist, publish
@Path("/orders")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
@Inject
InventoryClient inventoryClient;
@Inject
OrderEventProducer eventProducer;
@POST
@Transactional
public Response placeOrder(OrderCreate payload) {
CheckStockResponse stock;
try {
stock = inventoryClient.checkStock(payload.itemSku(), payload.quantity());
} catch (StatusRuntimeException e) {
return Response.status(Response.Status.SERVICE_UNAVAILABLE)
.entity("inventory-service unreachable: " + e.getMessage())
.build();
}
if (!stock.getAvailable()) {
return Response.status(Response.Status.CONFLICT)
.entity("insufficient stock for %s (%d on hand)"
.formatted(payload.itemSku(), stock.getQuantityOnHand()))
.build();
}
Order order = Order.create(payload.customerId(), payload.itemSku(), payload.quantity(), payload.amount());
order.persist();
eventProducer.publish(order).exceptionally(ex -> {
LOG.warnf(ex, "failed to publish order.placed for %s", order.id);
return null;
});
return Response.status(Response.Status.CREATED).entity(toDto(order)).build();
}
@GET
public List<OrderDto> listOrders() {
return Order.<Order>listAll(Sort.by("createdAt").descending())
.stream()
.map(OrderResource::toDto)
.toList();
}
@GET
@Path("/{id}")
public Response getOrder(@PathParam("id") String id) {
Order order = Order.findById(id);
if (order == null) {
return Response.status(Response.Status.NOT_FOUND).build();
}
return Response.ok(toDto(order)).build();
}
}
placeOrder is the whole data-product transformation in one method, and
the order of its three steps matters:
- Check stock first, over gRPC, before touching Postgres.
checkStockis a blocking call intoInventoryClient(covered next), and a failure there —StatusRuntimeException, meaning inventory-service is unreachable or erroring — returns503immediately. No row is written. This is fail-closed: the method refuses an order it can’t validate rather than persisting one it isn’t sure about. If stock comes back but isn’tavailable, that’s a409 Conflict, not a503— the dependency answered, it just said no. - Persist only after the gRPC call succeeds.
Order.create(...)builds the aggregate andorder.persist()writes it, inside the method’s@Transactionalboundary (Quarkus’s CDI interceptor-backedjakarta.transaction.Transactional, which wraps the method in a JTA transaction and commits on normal return). By the timepersist()returns inside that boundary, the order is durably committed. - Publish the event only after persistence, and never let a publish
failure undo or fail the request.
eventProducer.publish(order)returns aCompletionStage<Void>;.exceptionally(...)attaches a logging-only handler and the method does notawaitthe publish at all. The order has already been committed by the time publish is attempted, so a broker outage must not turn into a failedPOSTfor work that already succeeded. The cost of that choice — a window where the order is committed but the event never arrives — is the dual-write gap, and the comment inOrderEventProducernames the production answer: the outbox pattern, which this template does not implement.
listOrders and getOrder are the read side: listAll(Sort.by(...)) is
Panache’s query builder returning rows newest-first, and findById returns
null on a miss rather than throwing, which is why getOrder checks for
null and maps it to a 404 explicitly instead of catching an exception.
The one synchronous dependency: InventoryClient
@ApplicationScoped
public class InventoryClient {
private static final Duration CALL_TIMEOUT = Duration.ofSeconds(3);
@GrpcClient("inventory")
InventoryService inventoryService;
public CheckStockResponse checkStock(String sku, int quantity) {
CheckStockRequest request = CheckStockRequest.newBuilder()
.setSku(sku)
.setQuantity(quantity)
.build();
return inventoryService.checkStock(request)
.await().atMost(CALL_TIMEOUT);
}
}
@GrpcClient("inventory") wires quarkus-grpc to inject a client stub for
the channel named inventory (configured in application.properties,
pointed at inventory-service’s gRPC port). InventoryService is not
hand-written; it is the Mutiny-flavored service interface quarkus-grpc
generates at build time from inventory.proto,
which order-service never defines itself; it’s scanned out of the
contracts module’s packaged jar (more on that mechanism in the next
chapter). Because the generated interface is Mutiny-based, the call returns
a Uni<CheckStockResponse>, and .await().atMost(CALL_TIMEOUT) is what
turns that reactive, non-blocking call into the plain blocking return value
OrderResource’s worker-thread method needs — with a hard 3-second ceiling
so a hung inventory-service can’t hang an order request indefinitely. An
await() with no timeout at all would trade that bounded failure for an
unbounded one; the timeout is what keeps “inventory-service is slow” from
becoming “order-service is slow.”
Publishing the event
@ApplicationScoped
public class OrderEventProducer {
@Inject
@Channel("order-placed")
Emitter<OrderPlaced> emitter;
public CompletionStage<Void> publish(Order order) {
OrderPlaced event = OrderPlaced.newBuilder()
.setEventType("order.placed")
.setOrderId(order.id)
.setCustomerId(order.customerId)
.setItemSku(order.itemSku)
.setQuantity(order.quantity)
.setAmount(order.amount.toPlainString())
.setStatus(order.status.name())
.setCreatedAt(DateTimeFormatter.ISO_INSTANT.format(order.createdAt))
.build();
return emitter.send(event);
}
}
@Channel("order-placed") binds this Emitter<OrderPlaced> to the outgoing
channel configured in application.properties, which maps order-placed to
the order.placed Kafka topic and pins
value.serializer explicitly to Apicurio’s Avro serializer rather than
leaving Quarkus to autodetect one. The code comment on this class explains
why: two Apicurio artifacts share the
io.apicurio.registry.serde.avro package, a split-package situation that
defeats Quarkus’s autodetection, which silently falls back to a
JSON serializer and breaks the Avro-on-the-wire guarantee the next chapter
depends on. OrderPlaced itself is a generated
Avro SpecificRecord, not hand-written either; it comes from the
contracts module, which is where the next chapter picks up. emitter.send
returns the CompletionStage<Void> that OrderResource treats as
best-effort, closing the loop back to the publish-after-commit ordering
above.
Further products: inventory-service and review-service
inventory-service and review-service repeat the same shape —
Panache entity, REST (and here, gRPC) resource — while exposing a different
surface because their role is different.
inventory-service’s Stock entity is a plain auto-increment Long id
(PanacheEntityBase again, but note the business key is sku, looked up
via an active-record finder):
@Entity
@Table(name = "stock", uniqueConstraints = @UniqueConstraint(columnNames = "sku"))
public class Stock extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
@Column(name = "sku", nullable = false, unique = true, length = 64)
public String sku;
@Column(name = "quantity_on_hand", nullable = false)
public int quantityOnHand;
public static Stock findBySku(String sku) {
return find("sku", sku).firstResult();
}
}
Its primary surface is a gRPC server, not REST, because its only
required consumer is order-service’s synchronous CheckStock call:
@GrpcService
public class InventoryGrpcService implements InventoryService {
@Override
@Blocking
public Uni<CheckStockResponse> checkStock(CheckStockRequest request) {
return Uni.createFrom().item(() -> {
Stock stock = Stock.findBySku(request.getSku());
int onHand = stock != null ? stock.quantityOnHand : 0;
boolean available = request.getQuantity() > 0 && onHand >= request.getQuantity();
return CheckStockResponse.newBuilder()
.setAvailable(available)
.setQuantityOnHand(onHand)
.build();
});
}
}
@GrpcService registers this class as the server-side implementation of the
InventoryService contract (the same generated interface order-service
calls as a client). @Blocking matters here: the Panache call
(Stock.findBySku) is a blocking Hibernate/JDBC call, and without
@Blocking quarkus-grpc would invoke this method on its reactive I/O
thread, where a blocking JDBC round trip would stall that thread and
threaten the whole event loop. @Blocking tells Quarkus to dispatch this
method onto a worker thread instead, where blocking is safe — the same
concern InventoryClient’s bounded await() addresses from the other
direction.
inventory-service also exposes a small REST surface, StockResource
(POST /stock to seed, GET /stock / GET /stock/{sku} to inspect) — but
its Javadoc states that this is not part of the cross-service
contract; it is a demo and test convenience, and the inter-service
dependency is the gRPC call above.
review-service goes the other direction: REST-only, because it has no
synchronous cross-service dependency to satisfy, plus one endpoint that
doubles as this project’s OIDC demo:
@DELETE
@Path("/{id}")
@Transactional
@RolesAllowed("admin")
public Response delete(@PathParam("id") Long id) {
Review review = Review.findById(id);
if (review == null) {
return Response.status(Status.NOT_FOUND).build();
}
review.delete();
return Response.noContent().build();
}
@RolesAllowed("admin") is Jakarta’s standard role-based access-control
annotation; Quarkus’s OIDC extension wires it to the bearer token’s roles
claim, so a request with no token gets 401, and one with a token lacking
the admin role gets 403 — all before this method body ever runs. It
shows that a data product’s surface can carry its own authorization policy, scoped to exactly the operation that needs it
(creating and reading reviews stays open; moderating them doesn’t). This is
Figure 3.1’s control port: the governance layer the diagram draws around the
product is, here, a single annotation on the one method that needs it.
Build, run, observe
cd demos && ./demo-order.sh
The script brings up the compose baseline (Postgres, Kafka, Apicurio) and
packages and starts inventory-service and order-service as packaged JVM
processes against that infrastructure, not
quarkus:dev, so the %prod-profiled config pointing at the compose stack
is what runs. It then drives an order through the stack:
- Seed a SKU via
POST /stock. - Place an order:
POST /ordersfor twoWIDGET-1units, and confirm201 Createdwithstatus: PLACED. GETthe same order back by id.- Confirm
GET /orderslists it. - Query the
orderstable directly withpsqlto confirm the row exists independent of the REST layer.
The template also handles two production-readiness edges that surface only
in a packaged (non-dev) deployment, both wired in by default: the
order-service→inventory-service gRPC call targets a single canonical port
(9000, env-overridable on both sides via INVENTORY_GRPC_HOST /
INVENTORY_GRPC_PORT), and the packaged JVM trusts the Avro event package
via org.apache.avro.SERIALIZABLE_PACKAGES set in the container image’s
JAVA_TOOL_OPTIONS — without which Avro’s ClassSecurityValidator would
silently drop every order.placed publish from a non-dev JVM.
What you learned
- A data product in this project is a service: its own Postgres schema, its own REST/gRPC surface, its own Kafka publish — the service boundary is the data-product boundary.
order-service’splaceOrdershows the ordering a data product’s write path needs: validate a synchronous dependency first (fail closed), persist only after that succeeds, and publish only after persistence — with a publish failure never undoing or failing the already-committed write.- Each service’s protocol surface matches its role rather than a
house-wide template:
inventory-serviceis gRPC-first because that’s what its one consumer needs;review-serviceis REST-only with endpoint-scoped OIDC authorization because it has no synchronous dependency to satisfy.
The next question is how other domains find these services, trust their shape, and know it won’t change without warning — contracts and the catalog.
Verification status: verified. mvn verify is green (OrderResourceTest, InventoryGrpcServiceTest, OrderPlacedAvroWireIT, InventoryCheckStockWireIT), and demo-order.sh and demo-grpc.sh passed end to end against a freshly started compose stack.