Zombie Apps Beware: Measuring Real Business Value with Quarkus Business Score
A hands-on guide for Java developers to detect when services stop delivering value, even if they look healthy.
Enterprise systems can look healthy and still deliver zero value. Metrics are green. Health checks are up. Logs are busy. Meanwhile, customers get nothing. One of my coworkers sums it up perfectly: “only code in production is valuable code.”
Quarkus Business Score gives you a simple, opinionated way to measure whether your service is producing actual business value right now. You record score events at the moments that matter (an order placed, a payment captured, a ticket closed). The extension keeps a sliding time window of those events, exposes them over HTTP and health, and can even self-check on a schedule and alert you if your service turns into a zombie.
In this tutorial you’ll build a tiny “Order Service” where value is created only when an order is completed. Along the way, you’ll learn how to decide what to score, how much, and over what time window. Because the hardest part isn’t the code, it’s defining the right thresholds for your business.
Prerequisites
Java 21
Maven 3.9+
Quarkus CLI (optional)
Bootstrap the project
Let’s start fresh with a Quarkus app:
quarkus create app com.acme:order-service:1.0.0 \
-x rest-jackson,scheduler,smallrye-health,io.quarkiverse.businessscore:quarkus-business-score:1.0.0.Alpha4,io.quarkiverse.businessscore:quarkus-business-score-http:1.0.0.Alpha4 \
--no-code
cd order-service
These bring in the core API, REST endpoints, health checks, and automatic zombie self-checks. Take a look at the running example in my Github repository.
Configure Business Score
Now we tell the extension what “alive” means for our service.
src/main/resources/application.properties
:
quarkus.application.name=order-service
quarkus.application.version=1.0.0
# A service is a zombie if the score falls below 10 in 1 hour
quarkus.business-score.zombie-threshold=10
quarkus.business-score.time-window=1h
# Periodic check
quarkus.business-score.self-check.cron=*/60 * * * * ?
This config says: “If fewer than 10 valuable events occur in the last hour, the app is a zombie.” Later, we’ll refine how to pick those numbers.
Implement the real-world flow
Let’s implement a simple order system where business value is created only when an order is completed. That’s when we record a Business Score.
We’ll define three pieces:
An
Order
record to hold state.An
OrderService
that manages orders and increments the Business Score.An
OrderResource
REST endpoint so we can test it with HTTP calls.
Order record
src/main/java/com/acme/order/Order.java
package com.acme.order;
public record Order(String id, String item, int qty, String status) {
}
A compact immutable type that captures the order ID, item, quantity, and current status.
Order service
src/main/java/com/acme/order/OrderService.java
package com.acme.order;
package com.acme.order;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import io.quarkiverse.businessscore.BusinessScore;
import io.quarkiverse.businessscore.Scored;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped
public class OrderService {
private final Map<String, Order> db = new ConcurrentHashMap<>();
@Inject
BusinessScore businessScore;
public Order create(String item, int qty) {
String id = UUID.randomUUID().toString();
Order o = new Order(id, item, qty, "CREATED");
db.put(id, o);
return o;
}
public Order get(String id) {
return db.get(id);
}
/**
* Completing an order is the business moment that matters.
*
* @Scored ensures Business Score increments when this method succeeds.
*/
@Scored(5)
public Order complete(String id) {
Order o = db.get(id);
if (o == null)
return null;
Order completed = new Order(o.id(), o.item(), o.qty(), "COMPLETED");
db.put(id, completed);
return completed;
}
/**
* Example of scoring programmatically, in case the increment depends on logic.
*/
public void scoreManually(int value) {
businessScore.score(value);
}
}
Notice how @Scored(5)
is the key piece: every successful completion adds 5 points to the current score window.
Order resource
src/main/java/com/acme/order/OrderResource.java
package com.acme.order;
package com.acme.order;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
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 {
@Inject
OrderService service;
public record CreateOrderRequest(String item, int qty) {
}
@POST
public Response create(CreateOrderRequest req) {
if (req == null || req.item() == null || req.item().isBlank() || req.qty() <= 0) {
return Response.status(Response.Status.BAD_REQUEST).entity("invalid order").build();
}
return Response.ok(service.create(req.item(), req.qty())).build();
}
@GET
@Path("{id}")
public Response get(@PathParam("id") String id) {
Order o = service.get(id);
if (o == null)
return Response.status(Response.Status.NOT_FOUND).build();
return Response.ok(o).build();
}
@POST
@Path("{id}/complete")
public Response complete(@PathParam("id") String id) {
Order o = service.complete(id);
if (o == null)
return Response.status(Response.Status.NOT_FOUND).build();
return Response.ok(o).build();
}
}
This REST layer gives us three endpoints:
POST /orders
to create a new order.GET /orders/{id}
to fetch an order.POST /orders/{id}/complete
to complete an order (and trigger Business Score).
Add a zombie alert listener
Create src/main/java/com/acme/order/ZombieAlertListener.java
:
package com.acme.order;
package com.acme.order;
import org.jboss.logging.Logger;
import io.quarkiverse.businessscore.BusinessScore;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.event.Observes;
/**
* Listens for zombie transitions from Business Score self-checks.
* Logs the event and, if ALERT_WEBHOOK_URL is set, sends a JSON payload to that
* URL.
*/
@ApplicationScoped
public class ZombieAlertListener {
private static final Logger LOG = Logger.getLogger(ZombieAlertListener.class);
void onZombie(@Observes BusinessScore.ZombieStatus status) {
if (!status.isZombie()) {
// Optional: you could log recovery events here.
LOG.infof("Business Score recovered: score=%d threshold=%d window=%s",
status.score(), status.threshold(), status.timeWindow());
return;
}
LOG.warnf("ZOMBIE DETECTED: score=%d threshold=%d window=%s",
status.score(), status.threshold(), status.timeWindow());
}
}
Observing
BusinessScore.ZombieStatus
gives you a clean hook into incidents.
Run and verify
Start Quarkus in dev mode:
./quarkus dev
Create and complete an order:
OID=$(curl -s -X POST http://localhost:8080/orders \
-H 'Content-Type: application/json' \
-d '{"item":"coffee","qty":2}' | jq -r .id)
curl -s -X POST "http://localhost:8080/orders/$OID/complete" | jq .
Result:
{
"id": "91a56d2f-0b4e-47df-af6f-efef0ee80923",
"item": "coffee",
"qty": 2,
"status": "COMPLETED"
}
Check the score:
curl -s http://localhost:8080/q/business-score | jq .
You should see something like. Unfortunately the http extension is still using an outdated way to grab the application name. I filed an issue. Maybe it is fixed soon.
{
"zombie": false,
"score": 5,
"zombieThreshold": 10,
"timeWindow": "PT1H"
}
The health endpoint (/q/health/well
) does reflect this status correctly.
{
"status": "UP",
"checks": [
{
"name": "business-score",
"status": "UP",
"data": {
"score": 5,
"zombieThreshold": 10,
"timeWindow": "PT1H"
}
}
]
}
From numbers to meaning
At this point, you have code that scores events and endpoints that report status. But the crucial question remains: are these numbers meaningful? If you set the threshold too high, you’ll get constant false alarms. Too low, and you’ll miss real incidents. This is where SLOs come into play.
SLO Worksheet for Business Score
Think of this as a short exercise to translate your service’s reliability goals into concrete Business Score Objective (SLO) settings. Grab a notepad and walk through these steps:
Step 1: Identify the value event
Ask: What outcome proves this service is alive to a customer?
Examples: a successful checkout, a signed document, a resolved ticket.
Step 2: Decide the score unit
Flat scores: every event is equal.
Weighted scores: more value for premium or critical events.
Step 3: Measure normal throughput
Look at logs or metrics: how many of these events happen per hour or per day? Note both the average and the slowest healthy periods.
Step 4: Set the threshold
Pick a number just below the minimum you normally see. For example: if healthy throughput is 100 events/hour but the slowest healthy hour saw 90, set the threshold at 80.
Step 5: Choose the time window
Match your business rhythm:
Real-time services: 5–15 minutes.
Consumer-facing apps: 1 hour.
Back-office batch jobs: 24 hours.
Step 6: Validate against SLOs
If your SLO says “99% of orders must complete within one hour,” then your threshold should reflect the minimum number of completions that keeps you within that SLO.
Step 7: Iterate
Run the system for a week. Did the score drop during real problems? Did it stay stable otherwise? Adjust until it tracks reality.
In the end, you should be able to write down:
Value event = …
Score weight = …
Normal throughput = …
Zombie threshold = …
Time window = …
That’s your living Business Score definition.
Operations and SRE notes
With thresholds tuned, the extension integrates smoothly into operations:
Health checks:
/q/health/well
turns DOWN if the service is a zombie.Alerts: observe the CDI
ZombieStatus
event and push to Slack, PagerDuty, or Opsgenie.Dashboards: scrape
/q/business-score
into Prometheus or another metrics system for visibility.Incident review: after each outage, check if the score behaved as expected. Adjust thresholds or windows if needed.
Final thought
Traditional monitoring asks “is the service up?” Business Score asks “is the service delivering value?” That’s the only metric your customers care about. And it’s exactly why only code in production is valuable code.