Data-Oriented Java with Quarkus: Why I Deleted the Service Layer
A hands-on guide to modeling business logic with records, sealed types, and pure functions in modern Java
Most Java developers have lived with the same architecture for years.
You define repositories, write a service layer, inject everything, throw exceptions for control flow, and test with a small mountain of mocks. It works, but it is noisy. It also hides your domain logic behind layers that add little value.
Data-Oriented Programming is a different line of thinking.
Instead of classes with behavior, you model data as data and operations as pure functions. Java has finally grown up enough to make this style ergonomic: records, sealed interfaces, and exhaustive pattern matching bring clarity that used to require functional languages.
Quarkus is the perfect environment for trying this out.
It gives you simple persistence with Panache, fast feedback with Dev Services, and a lightweight runtime that doesn’t force service layers on you. In this tutorial, you will build a complete Order API without a service class. All business logic lives in static functions. All outcomes are explicit through algebraic data types.
Let’s walk through it step by step.
Prerequisites
Java 21 or 25
Maven 3.9+
Podman (or Docker. Only to support Dev Services for PostgreSQL)
Create the Quarkus Project
We begin by generating a new Quarkus application.
This includes Quarkus REST with Jackson, Panache ORM, and the PostgreSQL JDBC driver.
mvn io.quarkus:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=data-oriented-quarkus \
-DclassName="com.example.OrderResource" \
-Dpath="/orders" \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql"
cd data-oriented-quarkusYou now have a runnable Quarkus project.
Next, we configure Dev Services so the database starts automatically.
Configure Dev Services
Edit src/main/resources/application.properties.
We want Quarkus to bring up a PostgreSQL container automatically and recreate the schema on each run.
# Quarkus automatically starts PostgreSQL via Dev Services
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.schema-management.strategy=drop-and-create
quarkus.hibernate-orm.log.sql=true
# Dev mode debug logging
%dev.quarkus.log.category.”com.example”.level=DEBUGWith the configuration ready, we can move on to defining the domain.
This is where Data-Oriented Programming starts influencing the shape of our code.
Define the Domain as Algebraic Data Types
A data-oriented system treats data as simple structures with no hidden behavior.
Quarkus Panache fits this naturally because entities use public fields without boilerplate.
Create:
src/main/java/com/example/domain/Order.java
package com.example.domain;
import java.math.BigDecimal;
import java.time.Instant;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = “orders”)
public class Order extends PanacheEntity {
public String customerEmail;
public String productName;
public Integer quantity;
public BigDecimal totalAmount;
public String status;
public Instant createdAt;
}Create:
src/main/java/com/example/domain/Product.java
package com.example.domain;
import java.math.BigDecimal;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;
@Entity
@Table(name = “products”)
public class Product extends PanacheEntity {
public String name;
public BigDecimal price;
public Integer stockQuantity;
}Both classes express structures rather than objects with behavior.
Next, we define the data flowing through the API.
Create Data Transfer Records
We use records and sealed types to encode all valid outcomes.
This eliminates exceptions for control flow and forces the compiler to enforce exhaustiveness.
Create: src/main/java/com/example/api/OrderRequest.java
package com.example.api;
import java.math.BigDecimal;
// Input from client
public record OrderRequest(
String customerEmail,
String productName,
Integer quantity
) {
public OrderRequest {
if (quantity == null || quantity <= 0) {
throw new IllegalArgumentException(”Quantity must be positive”);
}
if (customerEmail == null || customerEmail.isBlank()) {
throw new IllegalArgumentException(”Customer email required”);
}
}
}
Create: src/main/java/com/example/api/OrderResult.java
package com.example.api;
import java.math.BigDecimal;
// Sealed result type
public sealed interface OrderResult
permits OrderResult.Success,
OrderResult.OutOfStock,
OrderResult.ProductNotFound,
OrderResult.InvalidRequest {
record Success(
Long orderId,
String customerEmail,
String productName,
Integer quantity,
BigDecimal totalAmount) implements OrderResult {
}
record OutOfStock(
String productName,
Integer available,
Integer requested) implements OrderResult {
}
record ProductNotFound(String productName) implements OrderResult {
}
record InvalidRequest(String message) implements OrderResult {
}
}Create: src/main/java/com/example/api/OrderView.java
package com.example.api;
import java.math.BigDecimal;
// Read model for queries
public record OrderView(
Long id,
String customerEmail,
String productName,
Integer quantity,
BigDecimal totalAmount,
String status,
String createdAt) {
}Now that the data is defined, it is time to implement our business logic. Without a service class.
Create Static Operations (No Services)
This is the heart of the tutorial.
All application logic lives in a single utility class with pure functions.
No DI. No mocks. No ceremony.
Create:
src/main/java/com/example/operations/OrderOperations.java
package com.example.operations;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import com.example.api.OrderRequest;
import com.example.api.OrderResult;
import com.example.api.OrderResult.OutOfStock;
import com.example.api.OrderResult.ProductNotFound;
import com.example.api.OrderResult.Success;
import com.example.api.OrderView;
import com.example.domain.Order;
import com.example.domain.Product;
public final class OrderOperations {
private OrderOperations() {
} // Utility class
/**
* Pure business logic - transforms request into result
*/
public static OrderResult placeOrder(OrderRequest request) {
Product product = Product.find(”name”, request.productName()).firstResult();
if (product == null) {
return new ProductNotFound(request.productName());
}
return switch (compareStock(product.stockQuantity, request.quantity())) {
case SUFFICIENT -> processOrder(request, product);
case INSUFFICIENT -> new OutOfStock(
request.productName(),
product.stockQuantity,
request.quantity());
};
}
private static OrderResult processOrder(OrderRequest request, Product product) {
BigDecimal total = product.price.multiply(BigDecimal.valueOf(request.quantity()));
product.stockQuantity -= request.quantity();
product.persist();
Order order = new Order();
order.customerEmail = request.customerEmail();
order.productName = request.productName();
order.quantity = request.quantity();
order.totalAmount = total;
order.status = “CONFIRMED”;
order.createdAt = Instant.now();
order.persist();
return new Success(
order.id,
order.customerEmail,
order.productName,
order.quantity,
order.totalAmount);
}
private enum StockLevel {
SUFFICIENT, INSUFFICIENT
}
private static StockLevel compareStock(int available, int requested) {
return available >= requested ? StockLevel.SUFFICIENT : StockLevel.INSUFFICIENT;
}
public static List<OrderView> getAllOrders() {
return Order.<Order>listAll()
.stream()
.map(OrderOperations::toView)
.toList();
}
public static OrderView toView(Order order) {
return new OrderView(
order.id,
order.customerEmail,
order.productName,
order.quantity,
order.totalAmount,
order.status,
order.createdAt.toString());
}
}With the operations complete, the final missing piece is exposing them via a REST resource.
Create the Resource Using Pattern Matching
This layer simply translates HTTP to function calls.
Pattern matching ensures we handle every possible result.
Create:
src/main/java/com/example/api/OrderResource.java
package com.example.api;
import java.util.List;
import com.example.api.OrderResult.InvalidRequest;
import com.example.api.OrderResult.OutOfStock;
import com.example.api.OrderResult.ProductNotFound;
import com.example.api.OrderResult.Success;
import com.example.operations.OrderOperations;
import jakarta.transaction.Transactional;
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;
import jakarta.ws.rs.core.Response;
@Path(”/orders”)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class OrderResource {
@POST
@Transactional
public Response placeOrder(OrderRequest request) {
return switch (OrderOperations.placeOrder(request)) {
case Success success ->
Response.status(Response.Status.CREATED).entity(success).build();
case OutOfStock outOfStock ->
Response.status(Response.Status.CONFLICT).entity(outOfStock).build();
case ProductNotFound notFound ->
Response.status(Response.Status.NOT_FOUND).entity(notFound).build();
case InvalidRequest invalid ->
Response.status(Response.Status.BAD_REQUEST).entity(invalid).build();
};
}
@GET
public List<OrderView> listOrders() {
return OrderOperations.getAllOrders();
}
}Before we run the application, we seed some initial products.
Add Seed Data for Testing
Create:
src/main/resources/import.sql
insert into products (id, name, price, stockQuantity) values(1, ‘Laptop’, 999.99, 10);
insert into products (id, name, price, stockQuantity) values(2, ‘Mouse’, 29.99, 50);
insert into products (id, name, price, stockQuantity) values(3, ‘Keyboard’, 79.99, 25);
insert into products (id, name, price, stockQuantity) values(4, ‘Monitor’, 299.99, 5);
alter sequence products_seq restart with 5;Now the application is complete.
Run and Test
Start Quarkus:
./mvnw quarkus:devQuarkus will:
Start PostgreSQL automatically
Recreate the schema
Insert test products
Enable hot reload
Successful order
curl -X POST http://localhost:8080/orders \
-H “Content-Type: application/json” \
-d ‘{
“customerEmail”: “alice@example.com”,
“productName”: “Laptop”,
“quantity”: 2
}’List orders
curl http://localhost:8080/ordersOut of stock
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{
"customerEmail": "bob@example.com",
"productName": "Monitor",
"quantity": 10
}'Product not found
curl -X POST http://localhost:8080/orders \
-H "Content-Type: application/json" \
-d '{
"customerEmail": "carol@example.com",
"productName": "Tablet",
"quantity": 1
}'Next, we contrast this with the traditional layered approach.
Traditional vs Data-Oriented
Most Java developers would normally write something like:
@ApplicationScoped
public class OrderService {
@Inject OrderRepository orderRepository;
@Inject ProductRepository productRepository;
public OrderDTO placeOrder(OrderRequest request) {
// Logic wrapped in DI and exceptions
}
}Tests would look like:
@InjectMock OrderRepository orderRepository;
@InjectMock ProductRepository productRepository;You spend most of your time preparing mocks, not testing logic.
This approach is different.
Static functions:
No injection
No interfaces
No mocks
No hidden branches
A simple test:
var result = OrderOperations.placeOrder(request);
assertInstanceOf(OutOfStock.class, result);The code becomes simpler to understand and easier to verify.
Key Takeaways
This style works well when:
Logic is mostly data transformations
You want full control of branching
You want explicit, testable outcomes
You want to avoid mocking frameworks
It is less suitable when:
Managing stateful resources
Implementing cross-cutting concerns
Data-Oriented Programming removes invisible complexity. Illegal states disappear because you model all valid states explicitly. Java’s newer language features make this style natural, and Quarkus supports it without friction.
Extend the system
by adding:
A
PaymentResultsealed hierarchyOrder cancellation flow
Full unit test suite without mocks
Native executable via GraalVM
The more complex your domain becomes, the more value this style delivers.
Build data. Model outcomes. Use the compiler as your safety net.
Done.




The sealed result types approach is genuinly clean. Forcing the compiler to handle every case with exhaustive pattern matching eliminates that whole category of runtime surprises where an edge case slips through. I've definetly spent too much time debugging mocked service layers, so the static functions + active record model is refreshing. Only concern is whether the panache pattern scales when you need more sophisticated query building or complex transaction boundaries.
I really like this approach but I don't see how you can avoid mocking.
The test you sketch out:
```
var result = OrderOperations.placeOrder(request);
assertInstanceOf(OutOfStock.class, result);
```
will AFAIK require some mocking of the OrderOperations class or did I miss something?