Reactive State Machines in Java: Building a Credit Approval Workflow with Quarkus
Master transactional flows, email automation, and background jobs using Quarkus, Panache, and Qute in a real-world financial application.
In this tutorial, we’ll build a stateful credit line approval application using Quarkus. Users submit credit requests, which are evaluated automatically. Approved requests receive welcome emails generated with Qute templates. Failures are handled gracefully, and a scheduled job retries stuck processes. Technologies covered include:
Quarkus REST and reactive email
Panache ORM with PostgreSQL
Qute templating for transactional emails
Mailpit for local smtp features in development
Quarkus Mailer
Scheduled retry with
quarkus-scheduler
Project Setup
Start by scaffolding the Quarkus project with all required extensions:
quarkus create app credit-line-app \
--extensions="quarkus-rest-jackson,quarkus-qute,quarkus-mailer,quarkus-hibernate-orm-panache,quarkus-jdbc-postgresql,quarkus-scheduler,quarkus-mailpit"
cd credit-line-app
This gives you everything: REST endpoints, mailer support, database access via Hibernate Panache, HTML templating, and background job scheduling. If you want the full demo, grab it from my Github repository.
Define the CreditLine Entity
The heart of the application is a CreditLine
entity that tracks customer info, state, and processing timestamps.
Create src/main/java/org/acme/model/CreditLine.java
@Entity
public class CreditLine extends PanacheEntityBase {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
public Long id;
public String customerName;
public String customerEmail;
public double requestedAmount;
@Enumerated(EnumType.STRING)
public CreditLineState state;
public LocalDateTime creationTimestamp;
public LocalDateTime lastUpdatedTimestamp;
public LocalDateTime approvalTimestamp;
public LocalDateTime emailSentTimestamp;
public CreditLine() {}
public CreditLine(String customerName, String customerEmail, double requestedAmount) {
this.customerName = customerName;
this.customerEmail = customerEmail;
this.requestedAmount = requestedAmount;
this.state = CreditLineState.INITIATED;
this.creationTimestamp = LocalDateTime.now();
this.lastUpdatedTimestamp = LocalDateTime.now();
}
}
This code defines the
CreditLine
entity, which represents a credit line application.It includes fields for customer information, the requested amount, the current state of the application, and timestamps for various events.
@Entity
annotation marks this class as a JPA entity.PanacheEntityBase
provides convenient methods for database interactions. If you don’t want to bother defining getters/setters for your entities, you can make them extend PanacheEntityBase and Quarkus will generate them for you. You can even extend PanacheEntity and take advantage of the default ID it provides.
Define the CreditLine State Machine
Use an enum
to describe the application lifecycle. Create:
src/main/java/org/acme/model/CreditLineState.java
public enum CreditLineState {
INITIATED, // Customer requests a credit line
PENDING_APPROVAL, // After basic info is submitted
APPROVED, // Backend approves the credit line
EMAIL_SENT, // Welcome email sent to the customer
REJECTED, // Backend rejects the credit line
ERROR // An error occurred during processing
}
This structure enables a robust internal state machine with explicit transitions.
Build the CreditLine Service Logic
The service handles state transitions, approval simulation, and reactive email sending. Create:
src/main/java/org/acme/service/CreditLineService.java
@ApplicationScoped
public class CreditLineService {
private static final Logger LOG = Logger.getLogger(CreditLineService.class);
@Inject
ReactiveMailer mailer;
@Inject
Template welcomeEmail; // Injected Qute template
private final Random random = new Random();
@Transactional
public CreditLine initiateCreditLine(String customerName, String customerEmail, double requestedAmount) {
CreditLine creditLine = new CreditLine(customerName, customerEmail, requestedAmount);
creditLine.state = CreditLineState.PENDING_APPROVAL;
creditLine.persist();
LOG.infof("Credit line initiated for customer: %s with amount: %.2f. State: %s",
customerName, requestedAmount, creditLine.state);
return creditLine;
}
@Transactional
public void processApproval(Long creditLineId) {
CreditLine creditLine = CreditLine.findById(creditLineId);
if (creditLine == null) {
LOG.warnf("Credit Line with ID %d not found for approval processing.", creditLineId);
return;
}
if (creditLine.state != CreditLineState.PENDING_APPROVAL) {
LOG.warnf("Credit Line ID %d is not in PENDING_APPROVAL state. Current state: %s",
creditLineId, creditLine.state);
return;
}
// Simulate backend approval
// 50/50 chance of approval
boolean approved = random.nextBoolean();
if (approved) {
creditLine.state = CreditLineState.APPROVED;
creditLine.approvalTimestamp = LocalDateTime.now();
creditLine.lastUpdatedTimestamp = LocalDateTime.now();
creditLine.persist();
LOG.infof("Credit Line ID %d approved. State: %s", creditLineId, creditLine.state);
// Now send the email, and update state in a non-blocking way
sendWelcomeEmail(creditLine).subscribe().with(
unused -> {
// Run the blocking state update on a worker thread
Uni.createFrom().voidItem()
.runSubscriptionOn(
io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool())
.subscribe().with(
ignored -> updateCreditLineState(creditLine.id, CreditLineState.EMAIL_SENT));
},
failure -> {
Uni.createFrom().voidItem()
.runSubscriptionOn(
io.smallrye.mutiny.infrastructure.Infrastructure.getDefaultWorkerPool())
.subscribe().with(
ignored -> updateCreditLineState(creditLine.id, CreditLineState.ERROR));
LOG.errorf(failure, "Failed to send welcome email for Credit Line ID %d", creditLine.id);
});
} else {
creditLine.state = CreditLineState.REJECTED;
creditLine.lastUpdatedTimestamp = LocalDateTime.now();
creditLine.persist();
LOG.warnf("Credit Line ID %d rejected. State: %s", creditLineId, creditLine.state);
}
}
public Uni<Void> sendWelcomeEmail(CreditLine creditLine) {
DecimalFormat df = new DecimalFormat("###.##");
String formattedAmount = df.format(creditLine.requestedAmount);
TemplateInstance emailContent = welcomeEmail
.data("customerName", creditLine.customerName)
.data("requestedAmount", formattedAmount);
return mailer
.send(Mail.withHtml(creditLine.customerEmail, "Welcome to Our Credit Line Service!",
emailContent.render()));
}
@Transactional
public Optional<CreditLine> findById(Long id) {
return Optional.ofNullable(CreditLine.findById(id));
}
// Since you are calling updateCreditLineState from a new thread, ensure it
// starts a new transaction:
@Transactional(TxType.REQUIRES_NEW)
public void updateCreditLineState(Long id, CreditLineState newState) {
CreditLine creditLine = CreditLine.findById(id);
if (creditLine == null) {
LOG.warnf("Credit Line with ID %d not found for state update.", id);
return;
}
// Only update if state is actually changing
if (creditLine.state != newState) {
creditLine.state = newState;
creditLine.lastUpdatedTimestamp = LocalDateTime.now();
if (newState == CreditLineState.EMAIL_SENT) {
creditLine.emailSentTimestamp = LocalDateTime.now();
}
creditLine.persist();
LOG.infof("Credit Line ID %d state updated to %s", id, newState);
} else {
LOG.debugf("Credit Line ID %d already in state %s, no update performed.", id, newState);
}
}
}
This service class contains the business logic for managing
CreditLine
entities.initiateCreditLine
creates a newCreditLine
and sets its initial state.processApproval
simulates the approval process (50/50 chance) and updates theCreditLine
state accordingly.sendWelcomeEmail
sends a welcome email to the customer upon approval, using a Qute template for the email body.@Transactional
annotation ensures that these methods are executed within a transaction.
REST API Resource
Expose the logic via REST endpoints. Rename the GreetingResource
to CreditLineResource
and change it to:
src/main/java/org/acme/CreditLineResource.java
@Path("/credit-lines")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CreditLineResource {
@Inject
CreditLineService creditLineService;
public static class CreditLineRequest {
public String customerName;
public String customerEmail;
public double requestedAmount;
}
@POST
public Response initiateCreditLine(CreditLineRequest request) {
CreditLine creditLine = creditLineService.initiateCreditLine(
request.customerName,
request.customerEmail,
request.requestedAmount);
// In a real application, you might queue the approval process for asynchronous
// execution For simplicity, we'll call it directly here.
creditLineService.processApproval(creditLine.id);
return Response.status(Response.Status.CREATED).entity(creditLine).build();
}
@GET
@Path("/{id}")
public Response getCreditLineStatus(@PathParam("id") Long id) {
Optional<CreditLine> creditLine = creditLineService.findById(id);
if (creditLine.isPresent()) {
return Response.ok(creditLine.get()).build();
} else {
return Response.status(Response.Status.NOT_FOUND).build();
}
}
}
This class defines the REST endpoints for interacting with
CreditLine
applications.@Path("/credit-lines")
specifies the base path for these endpoints.@POST
endpoint allows customers to initiate a new credit line.@GET
endpoint allows customers to retrieve the status of their credit line application by ID.
We don’t want the example tests to break, so we just delete them here ;) We’ll add some new ones later. Promissed.
Create the Email Template with Qute
File: src/main/resources/templates/welcomeEmail.html
<h1>Welcome, {customerName}!</h1>
<p>Your credit line for <strong>€{requestedAmount, ###.##}</strong> has been approved.</p>
<p>Thank you for trusting us.</p>
This will be rendered with dynamic values and sent using the reactive mailer.
This HTML file defines the structure and content of the welcome email.
Qute expressions like
{customerName}
and{requestedAmount, ###.##}
are used to dynamically insert data into the email.
Scheduled Retry with Quarkus Scheduler
We'll use quarkus-scheduler
to periodically check credit lines that might be stuck in a PENDING_APPROVAL
or APPROVED
state (if the email sending failed for some reason). Create:
src/main/java/org/acme/scheduler/CreditLineMonitor.java
package org.acme.scheduler;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.List;
import org.acme.model.CreditLine;
import org.acme.model.CreditLineState;
import org.acme.service.CreditLineService;
import org.jboss.logging.Logger;
import io.quarkus.scheduler.Scheduled;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class CreditLineMonitor {
private static final Logger LOG = Logger.getLogger(CreditLineMonitor.class);
@Inject
CreditLineService creditLineService;
// Configurable time frame for checks
// Default to 1 hour if not specified in application.properties
@org.eclipse.microprofile.config.inject.ConfigProperty(name = "creditline.check.timeout.minutes", defaultValue = "60")
long checkTimeoutMinutes;
@Scheduled(every = "5m")
@Transactional
public void checkPendingApprovals() {
LOG.debug("Running scheduled check for pending approvals...");
LocalDateTime timeoutThreshold = LocalDateTime.now().minus(checkTimeoutMinutes, ChronoUnit.MINUTES);
List<CreditLine> pendingApprovals = CreditLine.find("state = ?1 and creationTimestamp <= ?2",
CreditLineState.PENDING_APPROVAL, timeoutThreshold).list();
if (!pendingApprovals.isEmpty()) {
LOG.warnf("Found %d credit lines stuck in PENDING_APPROVAL state older than %d minutes.",
pendingApprovals.size(), checkTimeoutMinutes);
}
for (CreditLine creditLine : pendingApprovals) {
LOG.infof("Attempting to re-process approval for Credit Line ID: %d (Customer: %s)",
creditLine.id, creditLine.customerName);
creditLineService.processApproval(creditLine.id);
}
}
@Scheduled(every = "10m")
@Transactional
public void checkApprovedWithoutEmail() {
LOG.debug("Running scheduled check for approved credit lines without email...");
LocalDateTime timeoutThreshold = LocalDateTime.now().minus(checkTimeoutMinutes, ChronoUnit.MINUTES);
List<CreditLine> approvedWithoutEmail = CreditLine.find("state = ?1 and approvalTimestamp <= ?2",
CreditLineState.APPROVED, timeoutThreshold).list();
if (!approvedWithoutEmail.isEmpty()) {
LOG.warnf("Found %d credit lines stuck in APPROVED state (email not sent) older than %d minutes.",
approvedWithoutEmail.size(), checkTimeoutMinutes);
}
for (CreditLine creditLine : approvedWithoutEmail) {
LOG.infof("Attempting to re-send welcome email for Credit Line ID: %d (Customer: %s)",
creditLine.id, creditLine.customerName);
creditLineService.sendWelcomeEmail(creditLine).subscribe().with(
success -> LOG.debugf("Email re-sent for Credit Line ID: %d", creditLine.id),
failure -> LOG.errorf(failure, "Failed to re-send email for Credit Line ID: %d", creditLine.id));
}
}
}
Some Configuration
Update your src/main/resources/application.properties
file with database and Scheduler configuration:
# Database Configuration (PostgreSQL)
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
# Scheduler Configuration
creditline.check.timeout.minutes=5 # Configure the timeout for state checks (e.g., 5 minutes)
quarkus.scheduler.enabled=true
#Statically define the mapped HTTP port that the container user interface exposes
quarkus.mailpit.mapped-http-port=8025
# Define a global from email address
quarkus.mailer.from=applications@creditline.org
Let’s run the application
Fire it up with
quarkus dev
Once the application is running:
Initiate a Credit Line Request (HTTP POST): Use
curl
or a tool like Postman.
curl -X POST -H "Content-Type: application/json" -d '{
"customerName": "John Doe",
"customerEmail": "john.doe@example.com",
"requestedAmount": 5000.00
}' http://localhost:8080/credit-lines
The response will include the
CreditLine
object with itsid
and initial state (PENDING_APPROVAL
). Shortly after, theprocessApproval
method will be called, potentially moving it toAPPROVED
orREJECTED
, and if approved, the email will be sent.Check Credit Line Status (HTTP GET): Replace
[credit_line_id]
with theid
from the previous step.
curl http://localhost:8080/credit-lines/[credit_line_id]
You'll see the current state of the credit line.
Check Mailpit UI: Open your browser to
http://localhost:8025
to see if the welcome email was sent.
Observe Scheduler Logs: In your application's console, you'll see logs from
CreditLineMonitor
as it performs its scheduled checks. If a credit line gets "stuck" in a state longer than thecreditline.check.timeout.minutes
you configured, the scheduler will attempt to re-process it.
Wrap-Up
You now have a fully reactive Quarkus application that simulates a real-world credit line workflow:
Tracks customer applications
Automates state transitions
Sends emails on approval
Monitors and recovers from failures
It’s an elegant example of combining Quarkus features for a business use case that’s asynchronous, reactive, and production-ready.