Distributed Transactions Made Simple with Quarkus and JTA
Learn how to build atomic operations across PostgreSQL and MSSql databases using XA transactions in three real-world banking examples.
Modern enterprise applications often span multiple databases. One business operation might update several data stores at once. Without proper transaction management, partial updates can leave your system in an inconsistent state.
This tutorial demonstrates how to build a Quarkus application that performs atomic transfers across two different databases using JTA (Java Transaction API) with Narayana as the transaction manager. The key is to configure both datasources with XA support so they participate in the same distributed transaction.
We will create three simple simple scenarios with two different databases:
customers_db
stores customer information.accounts_db
stores financial accounts and balances.
A transfer must succeed or fail as a whole. If anything goes wrong everything is rolled back. Let’s start by creating the project. If you want to start with the source code and prefer to not read my writing, feel free to directly go to my Github repository.
Create the Quarkus Project
Run the following command to generate the project with all required extensions:
mvn io.quarkus.platform:quarkus-maven-plugin:create \
-DprojectGroupId=com.example \
-DprojectArtifactId=xa-transaction-tutorial \
-Dextensions="rest-jackson,hibernate-orm-panache,jdbc-postgresql,jdbc-mssql"
cd xa-transaction-tutorial
Extensions included:
rest-jackson to expose REST endpoints.
hibernate-orm-panache for simple JPA entity management.
jdbc-postgresql for PostgreSQL connectivity.
jdbc-mssql for MSSql connectivity.
You can directly jump to the complete example in my Github repository.
Configure Two XA Datasources
Open src/main/resources/application.properties
and add:
# --- Default datasource for customers ---
quarkus.datasource.db-kind=postgresql
quarkus.datasource.username=quarkus
quarkus.datasource.password=quarkus
quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/customers_db?sslmode=disable
quarkus.datasource.jdbc.transactions=xa
# --- Named datasource for accounts ---
quarkus.datasource.accounts.db-kind=mssql
quarkus.datasource.accounts.username=sa
quarkus.datasource.accounts.password=Quarkus123!
quarkus.datasource.accounts.jdbc.url=jdbc:sqlserver://localhost:1433;databaseName=master;encrypt=true;trustServerCertificate=true
quarkus.datasource.accounts.jdbc.transactions=xa
# --- Hibernate ORM configuration ---
quarkus.hibernate-orm.accounts.datasource=accounts
quarkus.hibernate-orm.accounts.datasource.encrypt=true
quarkus.hibernate-orm.packages=com.example.customer
quarkus.hibernate-orm.accounts.packages=com.example.account
# Auto-create schema for demo
quarkus.hibernate-orm.schema-management.strategy=create
quarkus.hibernate-orm.accounts.schema-management.strategy=create
quarkus.transaction-manager.enable-recovery=true
Here we define a default datasource and a named datasource called accounts
, both XA-enabled. Hibernate is configured to scan separate packages so that Customer
entities map to customers_db
and Account
entities map to accounts_db
.
Compose Dev Services
To make the two databases available to Quarkus during development we are going to use the Compose Dev Services so Quarkus can spin up the containers before it starts the application. Create a compose-devservices.yml
in your project root folder and copy the following content into it:
name: xa-transaction-tutorial
services:
postgresql:
image: docker.io/postgres:17
healthcheck:
test: pg_isready -U quarkus -d customers_db
interval: 5s
timeout: 3s
retries: 3
ports:
- "5432:5432"
environment:
POSTGRES_USER: quarkus
POSTGRES_PASSWORD: quarkus
POSTGRES_DB: customers_db
command: ["postgres", "-c", "max_prepared_transactions=10"]
mssql:
image: mcr.microsoft.com/mssql/server:2022-latest
ports:
- "1433:1433"
- "135:135"
environment:
ACCEPT_EULA: 'Y'
MSSQL_RPC_PORT: 135
MSSQL_SA_PASSWORD: 'Quarkus123!'
MSSQL_LOGGING: "verbose"
MSSQL_PID: "Developer"
ports:
- "1433:1433"
labels:
io.quarkus.devservices.compose.jdbc.parameters: trustServerCertificate=true
Because we are using a proprietary database in this case you also need to accept the license. Quarkus does this by reading a src/main/resources/container-license-acceptance.txt
file. Add a line with the image name and tag of the database:
mcr.microsoft.com/mssql/server:2022-latest
The default mssql server image does not enable XA transactions. So you need to do this manually. Do this after you start the application. So, not right now. Connect with podman and execute the XA install.
podman exec -it xa-transaction-tutorial_mssql_1 /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P Quarkus123! -C
1> EXEC sp_sqljdbc_xa_install
2> GO
If you are not fast enough changing this after you start the applications later, you might see this warning:
2025-07-30 11:18:29,963 WARN [com.arj.ats.jta] (Periodic Recovery) ARJUNA016027: Local XARecoveryModule.xaRecovery got XA exception XAException.XAER_RMFAIL: javax.transaction.xa.XAException: com.microsoft.sqlserver.jdbc.SQLServerException: Failed to create the XA control connection. Error: "Could not find stored procedure 'master..xp_sqljdbc_xa_init_ex'."
Just ignore it and make the changes.
Define the Entities
Let’s continue our preparations for the demo.
Customer Entity
src/main/java/com/example/customer/Customer.java
package com.example.customer;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
@Entity
public class Customer extends PanacheEntity {
public String name;
public String email;
public String phone;
public String status = "STANDARD"; // STANDARD, PREMIUM, SUSPENDED
public Integer loyaltyPoints = 0;
public LocalDateTime lastLoginDate;
public Integer totalTransactions = 0;
public LocalDateTime createdAt;
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}
TransactionLog Entity
src/main/java/com/example/account/
TransactionLog.java
package com.example.customer;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
@Entity
public class TransactionLog extends PanacheEntity {
public Long customerId;
public Long fromAccountId;
public Long toAccountId;
public BigDecimal amount;
public String transactionType; // TRANSFER, DEPOSIT, WITHDRAWAL, FREEZE, UNFREEZE
public String status; // SUCCESS, FAILED, PENDING
public String description;
public LocalDateTime timestamp;
@PrePersist
public void prePersist() {
timestamp = LocalDateTime.now();
}
}
Account Entity
src/main/java/com/example/account/Account.java
package com.example.account;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
@Entity
public class Account extends PanacheEntity {
public Long customerId;
public BigDecimal balance;
public String accountType = "CHECKING"; // CHECKING, SAVINGS, CREDIT
public String status = "ACTIVE"; // ACTIVE, FROZEN, CLOSED
public BigDecimal dailyLimit = new BigDecimal("5000.00");
public BigDecimal availableLimit = new BigDecimal("5000.00");
public LocalDateTime lastTransactionDate;
public Integer transactionCount = 0;
public LocalDateTime createdAt;
public LocalDateTime updatedAt;
@PrePersist
public void prePersist() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
lastTransactionDate = LocalDateTime.now();
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}
Create the Transactional Service
src/main/java/com/example/TransferService.java
package com.example;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.Objects;
import com.example.account.Account;
import com.example.customer.Customer;
import com.example.customer.TransactionLog;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
@ApplicationScoped
public class TransferService {
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount) {
transfer(fromAccountId, toAccountId, amount, false);
}
@Transactional
public void transfer(Long fromAccountId, Long toAccountId, BigDecimal amount, boolean simulateFailure) {
// === SQL SERVER OPERATIONS (Accounts) ===
Account fromAccount = Account.findById(fromAccountId);
Account toAccount = Account.findById(toAccountId);
Objects.requireNonNull(fromAccount, "Source account not found");
Objects.requireNonNull(toAccount, "Destination account not found");
if (fromAccount.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds!");
}
// Update account balances in SQL Server
fromAccount.balance = fromAccount.balance.subtract(amount);
toAccount.balance = toAccount.balance.add(amount);
fromAccount.persist();
toAccount.persist();
// === POSTGRESQL OPERATIONS (Customers) ===
// Update customer records to reflect the transaction
Customer fromCustomer = Customer.findById(fromAccount.customerId);
Customer toCustomer = Customer.findById(toAccount.customerId);
Objects.requireNonNull(fromCustomer, "Source customer not found");
Objects.requireNonNull(toCustomer, "Destination customer not found");
// Update customer names to show transaction activity (PostgreSQL)
fromCustomer.name = fromCustomer.name.replaceAll(" \\(.*\\)", "") + " (Sent $" + amount + ")";
toCustomer.name = toCustomer.name.replaceAll(" \\(.*\\)", "") + " (Received $" + amount + ")";
fromCustomer.persist();
toCustomer.persist();
// === XA TRANSACTION DEMO ===
// Optional failure simulation - will rollback BOTH SQL Server AND PostgreSQL
// changes
if (simulateFailure) {
throw new RuntimeException("Simulated failure to demonstrate XA rollback!");
}
}
// ===========================
// SCENARIO 1: LOYALTY POINTS + ACCOUNT TRANSFER
// ===========================
@Transactional
public void transferWithLoyalty(Long fromAccountId, Long toAccountId, BigDecimal amount, boolean simulateFailure) {
// === SQL SERVER OPERATIONS (Accounts) ===
Account fromAccount = Account.findById(fromAccountId);
Account toAccount = Account.findById(toAccountId);
Objects.requireNonNull(fromAccount, "Source account not found");
Objects.requireNonNull(toAccount, "Destination account not found");
if (fromAccount.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds!");
}
// Update account balances and transaction counts
fromAccount.balance = fromAccount.balance.subtract(amount);
toAccount.balance = toAccount.balance.add(amount);
fromAccount.transactionCount++;
toAccount.transactionCount++;
fromAccount.lastTransactionDate = LocalDateTime.now();
toAccount.lastTransactionDate = LocalDateTime.now();
fromAccount.persist();
toAccount.persist();
// === POSTGRESQL OPERATIONS (Customers & Logs) ===
// Award loyalty points to sender
Customer fromCustomer = Customer.findById(fromAccount.customerId);
Objects.requireNonNull(fromCustomer, "Source customer not found");
int loyaltyPoints = calculateLoyaltyPoints(amount);
fromCustomer.loyaltyPoints += loyaltyPoints;
fromCustomer.totalTransactions++;
fromCustomer.lastLoginDate = LocalDateTime.now();
fromCustomer.persist();
// Log transaction in PostgreSQL
TransactionLog log = new TransactionLog();
log.customerId = fromAccount.customerId;
log.fromAccountId = fromAccountId;
log.toAccountId = toAccountId;
log.amount = amount;
log.transactionType = "TRANSFER";
log.status = "SUCCESS";
log.description = "Transfer with " + loyaltyPoints + " loyalty points awarded";
log.persist();
// Optional failure simulation
if (simulateFailure) {
throw new RuntimeException("Simulated failure - loyalty points and transfer will be rolled back!");
}
}
// ===========================
// SCENARIO 2: ACCOUNT LIMITS + CUSTOMER STATUS
// ===========================
@Transactional
public void transferWithLimits(Long accountId, BigDecimal amount, boolean simulateFailure) {
// === SQL SERVER OPERATIONS (Account) ===
Account account = Account.findById(accountId);
Objects.requireNonNull(account, "Account not found");
if (account.balance.compareTo(amount) < 0) {
throw new IllegalStateException("Insufficient funds!");
}
if (account.availableLimit.compareTo(amount) < 0) {
throw new IllegalStateException("Daily limit exceeded! Available: $" + account.availableLimit);
}
// Update account limits and balance
account.availableLimit = account.availableLimit.subtract(amount);
account.balance = account.balance.subtract(amount);
account.transactionCount++;
account.lastTransactionDate = LocalDateTime.now();
account.persist();
// === POSTGRESQL OPERATIONS (Customer Status) ===
Customer customer = Customer.findById(account.customerId);
Objects.requireNonNull(customer, "Customer not found");
customer.totalTransactions++;
// Auto-upgrade customer status based on activity
if (customer.totalTransactions >= 100 && "STANDARD".equals(customer.status)) {
customer.status = "PREMIUM";
customer.loyaltyPoints += 1000; // Bonus points for premium upgrade
}
customer.persist();
// Log the transaction and status change
TransactionLog log = new TransactionLog();
log.customerId = account.customerId;
log.fromAccountId = accountId;
log.amount = amount;
log.transactionType = "WITHDRAWAL";
log.status = "SUCCESS";
log.description = "Withdrawal with limit check. Customer status: " + customer.status;
log.persist();
// Optional failure simulation
if (simulateFailure) {
throw new RuntimeException("Simulated failure - account and customer status changes will be rolled back!");
}
}
// ===========================
// SCENARIO 3: ACCOUNT FREEZE + CUSTOMER NOTIFICATION
// ===========================
@Transactional
public void freezeAccountWithNotification(Long accountId, String reason, boolean simulateFailure) {
// === SQL SERVER OPERATIONS (Account) ===
Account account = Account.findById(accountId);
Objects.requireNonNull(account, "Account not found");
String previousStatus = account.status;
account.status = "FROZEN";
account.lastTransactionDate = LocalDateTime.now();
account.persist();
// === POSTGRESQL OPERATIONS (Customer & Logs) ===
Customer customer = Customer.findById(account.customerId);
Objects.requireNonNull(customer, "Customer not found");
String previousCustomerStatus = customer.status;
customer.status = "SUSPENDED";
customer.updatedAt = LocalDateTime.now();
customer.persist();
// Log the freeze action
TransactionLog log = new TransactionLog();
log.customerId = account.customerId;
log.fromAccountId = accountId;
log.transactionType = "FREEZE";
log.status = "SUCCESS";
log.description = "Account frozen: " + reason +
". Account status: " + previousStatus + " -> FROZEN" +
". Customer status: " + previousCustomerStatus + " -> SUSPENDED";
log.persist();
// Optional failure simulation
if (simulateFailure) {
throw new RuntimeException(
"Simulated failure - account freeze and customer suspension will be rolled back!");
}
}
// ===========================
// UTILITY METHODS
// ===========================
private int calculateLoyaltyPoints(BigDecimal amount) {
// 1 point per $10 transferred, minimum 1 point
return Math.max(1, amount.divide(new BigDecimal("10")).intValue());
}
}
The @Transactional
annotation ensures that operations execute in a single JTA transaction. Any exception rolls back the entire operation. But you will see that later when I introduce the scenarios that you can test.
Build the REST API
src/main/java/com/example/BankResource.java
package com.example;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import com.example.account.Account;
import com.example.customer.Customer;
import com.example.customer.TransactionLog;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Response;
@Path("/bank")
public class BankResource {
@Inject
TransferService transferService;
@POST
@Path("/setup")
@Transactional
public Response setupData() {
Customer alice = new Customer();
alice.name = "Alice";
alice.email = "alice@example.com";
alice.phone = "555-0101";
alice.persist();
Customer bob = new Customer();
bob.name = "Bob";
bob.email = "bob@example.com";
bob.phone = "555-0102";
bob.persist();
Account acc1 = new Account();
acc1.customerId = alice.id;
acc1.balance = new BigDecimal("1000.00");
acc1.accountType = "CHECKING";
acc1.persist();
Account acc2 = new Account();
acc2.customerId = bob.id;
acc2.balance = new BigDecimal("500.00");
acc2.accountType = "SAVINGS";
acc2.persist();
return Response.ok("Initial data created with enhanced fields!").build();
}
@POST
@Path("/transfer")
public Response transfer(@QueryParam("from") Long from, @QueryParam("to") Long to,
@QueryParam("amount") BigDecimal amount,
@QueryParam("fail") @DefaultValue("false") boolean simulateFailure) {
try {
transferService.transfer(from, to, amount, simulateFailure);
return Response.ok("Transfer completed successfully!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity(e.getMessage()).build();
}
}
@GET
@Path("/status")
public Response getStatus() {
List<Account> accounts = Account.listAll();
List<Customer> customers = Customer.listAll();
List<TransactionLog> logs = TransactionLog.listAll();
return Response.ok(Map.of(
"accounts", accounts,
"customers", customers,
"transaction_logs", logs
)).build();
}
@GET
@Path("/accounts")
public List<Account> listAccounts() {
return Account.listAll();
}
@GET
@Path("/customers")
public List<Customer> listCustomers() {
return Customer.listAll();
}
@GET
@Path("/demo/success")
public Response successfulTransfer() {
try {
transferService.transfer(1L, 2L, new BigDecimal("50.00"), false);
return Response.ok("Successful transfer - both databases updated").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Transfer failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/failure")
public Response failedTransfer() {
try {
transferService.transfer(1L, 2L, new BigDecimal("50.00"), true);
return Response.status(500).entity("This should not happen - transfer should have failed").build();
} catch (Exception e) {
return Response.ok("Failed transfer - both databases rolled back: " + e.getMessage()).build();
}
}
// ===========================
// SCENARIO 1: LOYALTY POINTS + ACCOUNT TRANSFER
// ===========================
@POST
@Path("/transfer-loyalty")
public Response transferWithLoyalty(@QueryParam("from") Long from, @QueryParam("to") Long to,
@QueryParam("amount") BigDecimal amount,
@QueryParam("fail") @DefaultValue("false") boolean simulateFailure) {
try {
transferService.transferWithLoyalty(from, to, amount, simulateFailure);
return Response.ok("Transfer with loyalty points completed successfully!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Transfer failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/loyalty-success")
public Response loyaltyTransferSuccess() {
try {
transferService.transferWithLoyalty(1L, 2L, new BigDecimal("100.00"), false);
return Response.ok("Loyalty transfer success - check accounts, customers, and logs!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Transfer failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/loyalty-failure")
public Response loyaltyTransferFailure() {
try {
transferService.transferWithLoyalty(1L, 2L, new BigDecimal("50.00"), true);
return Response.status(500).entity("This should not happen").build();
} catch (Exception e) {
return Response.ok("Loyalty transfer failed - all changes rolled back: " + e.getMessage()).build();
}
}
// ===========================
// SCENARIO 2: ACCOUNT LIMITS + CUSTOMER STATUS
// ===========================
@POST
@Path("/withdraw-limits")
public Response withdrawWithLimits(@QueryParam("account") Long accountId,
@QueryParam("amount") BigDecimal amount,
@QueryParam("fail") @DefaultValue("false") boolean simulateFailure) {
try {
transferService.transferWithLimits(accountId, amount, simulateFailure);
return Response.ok("Withdrawal with limit check completed successfully!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Withdrawal failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/limits-success")
public Response limitsWithdrawalSuccess() {
try {
transferService.transferWithLimits(1L, new BigDecimal("200.00"), false);
return Response.ok("Limits withdrawal success - check account and customer status!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Withdrawal failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/limits-failure")
public Response limitsWithdrawalFailure() {
try {
transferService.transferWithLimits(1L, new BigDecimal("100.00"), true);
return Response.status(500).entity("This should not happen").build();
} catch (Exception e) {
return Response.ok("Limits withdrawal failed - all changes rolled back: " + e.getMessage()).build();
}
}
// ===========================
// SCENARIO 3: ACCOUNT FREEZE + CUSTOMER NOTIFICATION
// ===========================
@POST
@Path("/freeze-account")
public Response freezeAccount(@QueryParam("account") Long accountId,
@QueryParam("reason") @DefaultValue("Suspicious activity") String reason,
@QueryParam("fail") @DefaultValue("false") boolean simulateFailure) {
try {
transferService.freezeAccountWithNotification(accountId, reason, simulateFailure);
return Response.ok("Account frozen and customer notified successfully!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Freeze operation failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/freeze-success")
public Response freezeAccountSuccess() {
try {
transferService.freezeAccountWithNotification(2L, "Security review", false);
return Response.ok("Account freeze success - check account/customer status and logs!").build();
} catch (Exception e) {
return Response.status(Response.Status.BAD_REQUEST).entity("Freeze failed: " + e.getMessage()).build();
}
}
@GET
@Path("/demo/freeze-failure")
public Response freezeAccountFailure() {
try {
transferService.freezeAccountWithNotification(1L, "Test freeze", true);
return Response.status(500).entity("This should not happen").build();
} catch (Exception e) {
return Response.ok("Account freeze failed - all changes rolled back: " + e.getMessage()).build();
}
}
// ===========================
// ADDITIONAL ENDPOINTS
// ===========================
@GET
@Path("/logs")
public List<TransactionLog> listTransactionLogs() {
return TransactionLog.listAll();
}
@GET
@Path("/reset-limits")
@Transactional
public Response resetDailyLimits() {
List<Account> accounts = Account.listAll();
for (Account account : accounts) {
account.availableLimit = account.dailyLimit;
account.persist();
}
return Response.ok("Daily limits reset for all accounts").build();
}
}
Run the application
Start Quarkus in dev mode:
./mvnw quarkus:dev
Three XA Transaction Scenarios
These three scenarios demonstrate real-world distributed transactions across PostgreSQL and SQL Server databases. Each scenario shows how business operations require atomic updates across multiple databases, making XA transactions essential for data consistency.
Before testing, set up your initial data:
# Create initial customers and accounts
curl -X POST http://localhost:8080/bank/setup
# Check initial state
curl http://localhost:8080/bank/status
Scenario 1: Loyalty Points + Account Transfer
This scenario shows a bank transfer with loyalty rewards - a common business requirement where:
Financial transaction (SQL Server) must complete
Customer loyalty points (PostgreSQL) must be awarded
Transaction audit (PostgreSQL) must be logged
If any step fails, everything must rollback to maintain data integrity.
Database Operations:
SQL Server (Accounts): Update balances, transaction counts, timestamps
PostgreSQL (Customers): Award loyalty points, update activity metrics
PostgreSQL (Logs): Create detailed transaction audit trail
Let’s test the successful case:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/loyalty-success
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/transfer-loyalty?from=1&to=2&amount=100&fail=false"
curl http://localhost:8080/bank/status
Alice's account: $900 balance (SQL Server)
Bob's account: $600 balance (SQL Server)
Alice's loyalty points: +10 points (PostgreSQL)
Transaction log: New entry with success status (PostgreSQL)
Now let’s see what happens if a failure occurs in this flow:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/loyalty-failure
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/transfer-loyalty?from=1&to=2&amount=50&fail=true"
curl http://localhost:8080/bank/status
All balances unchanged (SQL Server rollback)
No loyalty points awarded (PostgreSQL rollback)
No transaction log entry (PostgreSQL rollback)
Scenario 2: Account Limits + Customer Status
This scenario shows withdrawal with daily limits and automatic customer upgrades - demonstrating how:
Account limits (SQL Server) must be enforced
Customer status (PostgreSQL) gets automatically upgraded based on activity
Business rules span multiple databases atomically
Database Operations:
SQL Server (Accounts): Check/update daily limits, process withdrawal
PostgreSQL (Customers): Auto-upgrade status (STANDARD→PREMIUM), award bonus points
PostgreSQL (Logs): Record withdrawal and status changes
Successful test:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/limits-success
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/withdraw-limits?account=1&amount=200&fail=false"
curl http://localhost:8080/bank/status
Account balance: Reduced by $200 (SQL Server)
Daily limit: Reduced by $200 (SQL Server)
Customer status: May upgrade to PREMIUM if 100+ transactions (PostgreSQL)
Bonus points: +1000 if upgraded (PostgreSQL)
Now let’s see what happens if a failure occurs in this flow:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/limits-failure
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/withdraw-limits?account=1&amount=100&fail=true"
curl http://localhost:8080/bank/status
Account balance unchanged (SQL Server rollback)
Daily limit unchanged (SQL Server rollback)
Customer status unchanged (PostgreSQL rollback)
No bonus points (PostgreSQL rollback)
You can play around more with these endpoints:
# Reset daily limits to test limit enforcement
curl http://localhost:8080/bank/reset-limits
# Try exceeding daily limit (should fail with business rule)
curl -X POST "http://localhost:8080/bank/withdraw-limits?account=1&amount=6000&fail=false"
Scenario 3: Account Freeze + Customer Notification
This scenario shows security operations where:
Account must be frozen (SQL Server) for security
Customer status must be suspended (PostgreSQL) for compliance
Detailed audit trail (PostgreSQL) must be created for regulations
This represents compliance-critical operations where partial completion is unacceptable.
Database Operations:
SQL Server (Accounts): Change status to FROZEN, update timestamps
PostgreSQL (Customers): Update status to SUSPENDED
PostgreSQL (Logs): Create detailed audit record with before/after states
Let’s test the happy path:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/freeze-success
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/freeze-account?account=2&reason=Security+review&fail=false"
curl http://localhost:8080/bank/status
Account status: FROZEN (SQL Server)
Customer status: SUSPENDED (PostgreSQL)
Transaction log: Detailed audit entry with reason and state changes (PostgreSQL)
And for a third time, the failure flow:
# Method 1: One-click demo
curl http://localhost:8080/bank/demo/freeze-failure
# Method 2: Manual control
curl -X POST "http://localhost:8080/bank/freeze-account?account=1&reason=Test+freeze&fail=true"
curl http://localhost:8080/bank/status
Account status unchanged (SQL Server rollback)
Customer status unchanged (PostgreSQL rollback)
No audit entry created (PostgreSQL rollback)
Individual Data View Endpoints
I also added some convenience endpoints for individual data views:
# SQL Server data only
curl http://localhost:8080/bank/accounts
# PostgreSQL customer data only
curl http://localhost:8080/bank/customers
# PostgreSQL audit logs only
curl http://localhost:8080/bank/logs
Key XA Transaction Benefits Demonstrated
Data Consistency: All-or-nothing updates across databases
Business Logic Integrity: Complex workflows remain atomic
Compliance: Audit trails always match operational changes
Error Recovery: Automatic rollback prevents partial updates
Real-world Scenarios: Practical use cases
Each scenario shows how modern applications require distributed transactions to maintain data integrity across database boundaries. The XA protocol ensures that business operations either complete entirely or fail safely with full rollback.
You have successfully implemented distributed transactions across two databases in Quarkus using JTA. Congratulations!
For further reading:
This example can be extended with proper authentication, error handling, and observability for production use.
Thanks for sharing. I think that, as a matter of fact, XA and 2PC are the right solution for distributed transactions, far from these fantasist Saga ones.
However, I have a few comments:
1. I don't see any trace of the qurkus-narayana-jta extension in your pom.xml.
2. The Quarkus documentation states that "Quarkus doesn't support distributed transactions".
Could you please clarify ?