Transactional Zen: Building Reliable Java Services with Quarkus and Panache
Learn how to master database transactions in Quarkus using Hibernate ORM with Panache. From simple CRUD operations to advanced rollback, this hands-on guide covers it all.
Transactions are the unsung heroes of enterprise applications. They ensure your system behaves like a good friend, consistent, trustworthy, and able to clean up after itself when something goes wrong.
In this tutorial, you'll learn how to implement transactional logic in Quarkus using Hibernate ORM with Panache. You’ll also explore rollback mechanics, transaction propagation, and how to make the most of the lightweight transaction manager Quarkus provides. Plus how to scale up to distributed transactions with Narayana when needed.
Let’s build an AccountService
from scratch, complete with REST endpoints, automatic rollback, and audit logging that plays by its own transaction rules.
Why Transactions Matter
Let’s set the scene. You're transferring €100 from Alice to Bob. Two things need to happen: debit Alice’s account and credit Bob’s. What if your app crashes halfway through? Now Alice is €100 poorer, and Bob is still broke. That’s not a bug, it’s a lawsuit waiting to happen.
This is why we need ACID-compliant transactions:
Atomicity: all or nothing
Consistency: keep the database valid
Isolation: no cross-talk between concurrent operations
Durability: changes that stick, even after a power outage
Quarkus, like any good Java EE citizen, supports the Java Transaction API (JTA). And thanks to CDI and Panache, you can handle most use cases declaratively with just @Transactional
.
Setting Up the Project
Let’s generate a fresh Quarkus app that includes REST, Hibernate ORM with Panache, and PostgreSQL support:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=quarkus-panache-transaction-tutorial \
-DclassName="com.example.AccountResource" \
-Dpath="/accounts" \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql,hibernate-validator"
cd quarkus-panache-transaction-tutorial
Database Configuration
In application.properties
, configure the datasource and enable Hibernate features:
quarkus.datasource.db-kind=postgresql
quarkus.hibernate-orm.database.generation=drop-and-create
quarkus.hibernate-orm.log.sql=true
Step 1: Create the Account
Entity
Here’s our Account
entity using Panache's active record pattern:
@Entity
public class Account extends PanacheEntity {
@NotBlank(message = "Account holder name cannot be blank")
public String accountHolderName;
@DecimalMin(value = "0.0", inclusive = true, message = "Balance must be positive")
public BigDecimal balance;
public Account() {}
public Account(String name, BigDecimal balance) {
this.accountHolderName = name;
this.balance = balance;
}
public static Account findByAccountHolderName(String name) {
return find("accountHolderName", name).firstResult();
}
}
Step 2: Create the Audit Service
We'll use this to demonstrate how different transaction propagation settings behave.
@ApplicationScoped
public class AuditService {
private static final Logger LOG = Logger.getLogger(AuditService.class);
@Transactional(Transactional.TxType.REQUIRES_NEW)
public void logAuditEvent(String event) {
LOG.info("AUDIT LOG (New Transaction): " + event);
}
@Transactional(Transactional.TxType.REQUIRED)
public void logAuditEventInSameTransaction(String event) {
LOG.info("AUDIT LOG (Same Transaction): " + event);
}
}
Step 3: The Business Logic – AccountService
Here’s where all the transactional magic happens.
@ApplicationScoped
public class AccountService {
@Inject
AuditService auditService;
@Transactional
public Account createAccount(@Valid Account account) {
account.persist();
return account;
}
public Account getAccount(Long id) {
return Account.findById(id);
}
public List<Account> getAllAccounts() {
return Account.listAll();
}
@Transactional
public Account deposit(Long id, BigDecimal amount) {
Account account = Account.findById(id);
if (account == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Invalid deposit.");
}
account.balance = account.balance.add(amount);
return account;
}
@Transactional
public Account withdraw(Long id, BigDecimal amount) {
Account account = Account.findById(id);
if (account == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Invalid withdrawal.");
}
if (account.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds.");
}
account.balance = account.balance.subtract(amount);
return account;
}
@Transactional
public void transferFunds(Long fromId, Long toId, BigDecimal amount) {
auditService.logAuditEventInSameTransaction("Transfer attempt from " + fromId + " to " + toId);
auditService.logAuditEvent("TRANSFER_OPERATION_STARTED");
Account from = Account.findById(fromId);
Account to = Account.findById(toId);
if (from == null || to == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Invalid transfer.");
}
if (from.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds.");
}
from.balance = from.balance.subtract(amount);
// Simulate failure
// throw new RuntimeException("Simulated failure!");
to.balance = to.balance.add(amount);
}
}
Step 4: The REST Layer – AccountResource
Expose the service via REST using quarkus-rest-jackson
:
@Path("/accounts")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AccountResource {
@Inject
AccountService service;
@POST
public Response createAccount(@Valid Account account) {
return Response.status(201).entity(service.createAccount(account)).build();
}
@GET
public List<Account> all() {
return service.getAllAccounts();
}
@POST
@Path("/{id}/deposit")
public Response deposit(@PathParam("id") Long id, AmountDTO dto) {
try {
return Response.ok(service.deposit(id, dto.amount)).build();
} catch (Exception e) {
return Response.status(400).entity(new ErrorMessage(e.getMessage())).build();
}
}
@POST
@Path("/{id}/withdraw")
public Response withdraw(@PathParam("id") Long id, AmountDTO dto) {
try {
return Response.ok(service.withdraw(id, dto.amount)).build();
} catch (Exception e) {
return Response.status(400).entity(new ErrorMessage(e.getMessage())).build();
}
}
@POST
@Path("/transfer")
public Response transfer(TransferRequestDTO dto) {
try {
service.transferFunds(dto.fromAccountId, dto.toAccountId, dto.amount);
return Response.ok(new SuccessMessage("Transfer complete")).build();
} catch (RuntimeException e) {
return Response.status(500).entity(new ErrorMessage(e.getMessage())).build();
}
}
public static class AmountDTO {
public BigDecimal amount;
}
public static class TransferRequestDTO {
public Long fromAccountId;
public Long toAccountId;
public BigDecimal amount;
}
public static class ErrorMessage {
public String error;
public ErrorMessage(String error) { this.error = error; }
}
public static class SuccessMessage {
public String message;
public SuccessMessage(String msg) { this.message = msg; }
}
}
Step 5: Test It
Start the dev mode:
./mvnw quarkus:dev
Create two accounts:
curl -X POST -H "Content-Type: application/json" -d '{"accountHolderName":"Alice", "balance":1000}' http://localhost:8080/accounts
curl -X POST -H "Content-Type: application/json" -d '{"accountHolderName":"Bob", "balance":500}' http://localhost:8080/accounts
Transfer funds:
curl -X POST -H "Content-Type: application/json" -d '{"fromAccountId":1,"toAccountId":2,"amount":100}' http://localhost:8080/accounts/transfer
To test rollback, uncomment the simulated error in transferFunds()
and retry.
Advanced: Transaction Propagation & Rollbacks
Quarkus supports JTA-style propagation:
REQUIRED
: default, joins existing transactionREQUIRES_NEW
: suspends current, starts new (used inAuditService
)SUPPORTS
,MANDATORY
,NOT_SUPPORTED
,NEVER
: available for niche cases
Exceptions also matter:
Unchecked exceptions cause rollback automatically.
Checked exceptions don’t, unless specified:
@Transactional(rollbackOn = SomeCheckedException.class)
Also, Quarkus does not support distributed transactions. This means that models that propagate transaction context, such as Java Transaction Service (JTS), REST-AT, WS-Atomic Transaction, and others, are not supported by the narayana-jta extension.
Summary
You’ve now mastered:
How
@Transactional
works in QuarkusHow to propagate or isolate transactions with
TxType
Automatic rollback rules
The difference between lightweight and distributed transactions
With Quarkus and Panache, you don’t need to choose between clean code and safe data. You get both.
It may look like just a simple transfer, but in reality, every step needs to be backed up and reversible to be considered a reliable system. The “mistake-and-recover” design in your field is something other industries could really learn from.What other scenarios, besides finance, do you think truly deserve this kind of transactional protection?