Quarkus Integration
Early-stage support: The
fun-quarkusmodule is in its early stages. The API is functional and tested, but you may encounter rough edges in real-world usage. Please proceed with care and report any issues you find — feedback is very welcome and helps drive the module forward.
The fun-quarkus module adds first-class Quarkus transaction support for Result<V,E> and
Try<V>. It is an optional dependency — the core fun library has no runtime dependency
on Quarkus.
The problem
Quarkus’s @Transactional (backed by Narayana JTA) rolls back only when an unchecked
exception escapes the annotated method. Result and Try capture failure as return values,
so no exception escapes — and the transaction commits even when the operation failed,
silently persisting partial writes.
TxResult and TxTry solve this by inspecting the return value: an error or failure
outcome marks the transaction for rollback before Narayana commits.
Adding the dependency
fun-quarkus declares quarkus-arc and quarkus-narayana-jta as compileOnly — your
own Quarkus version is not forced.
<!-- Maven --><dependency> <groupId>codes.domix</groupId> <artifactId>fun-quarkus</artifactId> <version>LATEST_VERSION</version></dependency>// Gradleimplementation("codes.domix:fun-quarkus:LATEST_VERSION")The runtime JAR ships META-INF/quarkus-extension.properties, so Quarkus build tools
automatically add fun-quarkus-deployment to the augmentation classpath. No explicit
fun-quarkus-deployment dependency is needed in your project.
TxResult
Wraps a Result<V,E>-returning action in a JTA-managed transaction. Rolls back when the
result is isError().
@ApplicationScopedpublic class OrderService { private final TxResult tx; private final OrderRepository repo; private final InventoryClient inventory;
@Inject public OrderService(TxResult tx, OrderRepository repo, InventoryClient inventory) { this.tx = tx; this.repo = repo; this.inventory = inventory; }
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validate(req) // Result<ValidOrder, OrderError> .flatMap(repo::save) // Result<Order, OrderError> .flatMap(inventory::reserve) // Result<Order, OrderError> ); // • Ok(order) → transaction commits // • Err(orderError) → transaction rolls back; partial writes undone }}Rollback contract:
| Outcome | Transaction |
|---|---|
Action returns Result.isOk() | Commits |
Action returns Result.isError() | Rolls back |
| Action throws an unchecked exception | Rolls back, rethrows |
TxResult.executeNew() — REQUIRES_NEW semantics
executeNew() suspends any currently active transaction, starts a brand-new independent
one, and resumes the original transaction after the action finishes — regardless of whether
the inner transaction committed or rolled back.
@ApplicationScopedpublic class OrderService { @Inject TxResult tx; @Inject AuditLog auditLog; @Inject OrderRepository repo;
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> { // Inner call starts a brand-new independent transaction. // Even if the outer tx rolls back, the audit entry is already committed. tx.executeNew(() -> auditLog.record("createOrder attempted", req));
return validate(req).flatMap(repo::save); // • Ok(order) → outer tx commits // • Err(orderError) → outer tx rolls back, but audit entry persists }); }}Use executeNew() when the inner operation must commit independently of the outer
transaction (audit logs, billing events, notifications).
TxTry
Wraps a Try<V>-returning action in a JTA-managed transaction. Rolls back when the result
is isFailure().
@ApplicationScopedpublic class ReportService { private final TxTry tx; private final ReportRepository repo;
@Inject public ReportService(TxTry tx, ReportRepository repo) { this.tx = tx; this.repo = repo; }
public Try<Report> generate(ReportRequest req) { return tx.execute(() -> Try.of(() -> repo.save(build(req))) // Try<Report> ); // • Success(report) → transaction commits // • Failure(cause) → transaction rolls back; partial writes undone }}Rollback contract:
| Outcome | Transaction |
|---|---|
Action returns Try.isSuccess() | Commits |
Action returns Try.isFailure() | Rolls back |
| Action throws an unchecked exception | Rolls back, rethrows |
TxTry also provides executeNew() with the same REQUIRES_NEW semantics described above.
TxResult vs TxTry vs @Transactional
// ---- @Transactional — commits even when Result is an error ----
@Transactionalpublic Result<Order, OrderError> createOrder(OrderRequest req) { return validate(req) .flatMap(repo::save); // ❌ If flatMap returns Err(orderError), the method returns normally — // no exception escapes, so Narayana commits the transaction. // Writes from the steps that succeeded before the Err are silently persisted.}
// ---- TxResult — rolls back when Result is an error ----
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validate(req) .flatMap(repo::save) ); // ✓ If flatMap returns Err(orderError), TxResult rolls back the transaction. // Partial writes are undone.}
// ---- When @Transactional is still the right choice ----
@Transactionalpublic void deleteOrder(String id) { // No return value; rollback happens naturally on RuntimeException. // @Transactional is simpler and perfectly correct here. repo.delete(id);}Rule of thumb:
- Prefer
TxResult/@TransactionalResultwhen your pipeline returnsResult<V,E>for typed domain errors. - Choose
TxTry/@TransactionalTrywhen your pipeline usesTry<V>to wrap exception-throwing code. - Fall back to
@Transactionalwhen the method has no dmx-fun return type and rollback on unchecked exception is sufficient (void methods, simple queries).
Declarative style with @TransactionalResult and @TransactionalTry
If you prefer annotation-driven transaction demarcation — the same style as Quarkus’s own
@Transactional — fun-quarkus ships two CDI interceptor binding annotations backed by
TransactionalDmxInterceptor. They carry the same rollback contract as their programmatic
counterparts.
How the interceptor routing works
Both @TransactionalResult and @TransactionalTry carry the package-private
@DmxTransactionalBinding meta-annotation. Per CDI §2.7.1.1, an interceptor binding may
transitively declare another interceptor binding, so TransactionalDmxInterceptor — which
is bound to @DmxTransactionalBinding — activates for methods annotated with either one,
without needing two separate interceptor classes.
At runtime the interceptor inspects the method’s return value: if @TransactionalResult is
present it checks Result#isError(); if @TransactionalTry is present it checks
Try#isFailure(). Either way, a truthy result triggers a rollback (or marks the joined
transaction rollback-only when the method joined an existing transaction).
@TransactionalResult
@ApplicationScopedpublic class OrderService { private final OrderRepository repo; private final InventoryClient inventory;
@TransactionalResult public Result<Order, OrderError> createOrder(OrderRequest req) { return validate(req) // Result<ValidOrder, OrderError> .flatMap(repo::save) // Result<Order, OrderError> .flatMap(inventory::reserve);// Result<Order, OrderError> // • Ok(order) → transaction commits // • Err(orderError) → transaction rolls back; partial writes undone }}| Outcome | Transaction |
|---|---|
Returns Result.isOk() | Commits |
Returns Result.isError() | Rolls back |
| Throws unchecked exception | Rolls back, rethrows |
@TransactionalTry
@ApplicationScopedpublic class ReportService { private final ReportRepository repo;
@TransactionalTry public Try<Report> generate(ReportRequest req) { return Try.of(() -> repo.save(build(req))); // • Success(report) → transaction commits // • Failure(cause) → transaction rolls back; partial writes undone }}| Outcome | Transaction |
|---|---|
Returns Try.isSuccess() | Commits |
Returns Try.isFailure() | Rolls back |
| Throws unchecked exception | Rolls back, rethrows |
Propagation semantics (TxType)
Both annotations accept an optional value() attribute of type Transactional.TxType
(default REQUIRED). The interceptor reads this value at runtime and applies the
corresponding JTA propagation behaviour.
TxType | Existing tx present | No active tx |
|---|---|---|
REQUIRED (default) | Joins the existing tx; marks rollback-only on error | Begins a new tx; commits or rolls back |
REQUIRES_NEW | Suspends outer tx, starts fresh tx, resumes outer after | Starts a fresh tx |
MANDATORY | Joins the existing tx | Throws TransactionalException |
SUPPORTS | Joins the existing tx; marks rollback-only on error | Runs without a tx (no begin/commit) |
NOT_SUPPORTED | Suspends outer tx, runs without one, resumes outer after | Runs without a tx |
NEVER | Throws TransactionalException | Runs without a tx |
Join semantics note: When a method joins an existing transaction (REQUIRED,
MANDATORY, SUPPORTS), it does not call commit() or rollback() — the caller owns
the boundary. On error or exception the interceptor calls setRollbackOnly() so the outer
transaction is guaranteed to roll back when it eventually closes.
@ApplicationScopedpublic class OrderService {
// REQUIRED (default) — join an existing tx or start a new one. // Most methods should use this. @TransactionalResult public Result<Order, OrderError> createOrder(OrderRequest req) { ... }
// REQUIRES_NEW — always suspend any active tx and start a fresh one. // Use when the operation must commit/rollback independently of the caller. @TransactionalResult(Transactional.TxType.REQUIRES_NEW) public Result<AuditEntry, String> recordAudit(AuditEvent event) { ... }
// MANDATORY — require an active transaction; throw if none is present. // Use for internal helpers that must only be called from within a tx. @TransactionalResult(Transactional.TxType.MANDATORY) public Result<Void, String> validateInTx(Order order) { ... }
// SUPPORTS — join if a tx is active; otherwise run without one. // Use for read-only operations that are safe either way. @TransactionalResult(Transactional.TxType.SUPPORTS) public Result<Order, String> findOrder(String id) { ... }
// NOT_SUPPORTED — suspend any active tx and run without one (auto-commit). // Use when the operation must not run inside a transaction at all. @TransactionalResult(Transactional.TxType.NOT_SUPPORTED) public Result<Report, String> generateReport(String id) { ... }
// NEVER — throw if a transaction is active. // Use to assert that no caller holds an open transaction. @TransactionalResult(Transactional.TxType.NEVER) public Result<Stats, String> computeStats() { ... }}Annotation placement
Both annotations can be placed on a method or on a class. Class-level placement applies the binding to every method on that class:
@TransactionalResult // all Result-returning methods on the class are transactional@ApplicationScopedpublic class OrderService { public Result<Order, OrderError> createOrder(OrderRequest req) { ... } public Result<Void, OrderError> cancelOrder(String id) { ... }}Programmatic vs declarative
// ── Programmatic (TxResult) ──────────────────────────────────────────────────
@ApplicationScopedpublic class OrderService { private final TxResult tx; // explicit dependency, easy to test without CDI private final OrderRepository repo; private final OrderValidator validator;
@Inject public OrderService(TxResult tx, OrderRepository repo, OrderValidator validator) { this.tx = tx; this.repo = repo; this.validator = validator; }
public Result<Order, OrderError> createOrder(OrderRequest req) { return tx.execute(() -> validator.validate(req).flatMap(repo::save)); }}
// ── Declarative (@TransactionalResult) ──────────────────────────────────────
@ApplicationScopedpublic class OrderService { // no TxResult field — the interceptor wraps the method transparently private final OrderRepository repo; private final OrderValidator validator;
@Inject public OrderService(OrderRepository repo, OrderValidator validator) { this.repo = repo; this.validator = validator; }
@TransactionalResult public Result<Order, OrderError> createOrder(OrderRequest req) { return validator.validate(req).flatMap(repo::save); }}Choose programmatic (TxResult / TxTry) when:
- You want an explicit, testable object — straightforward to unit-test by passing a stub
TransactionManager. - The service class already has
@Injectdependencies and adding one more is natural.
Choose declarative (@TransactionalResult / @TransactionalTry) when:
- You prefer the familiar
@Transactionalannotation style and want no extra field in the bean. - The method is already a CDI bean and Arc proxy overhead is acceptable.
Version compatibility
Tested in CI against the following Quarkus versions on every pull request that touches
the frameworks/quarkus/ module:
| Quarkus version | Status |
|---|---|
| 3.11.3 | tested |
| 3.21.4 | tested |
| 3.31.4 | tested |
| 3.35.1 | tested |
To test locally against a specific version:
./gradlew :frameworks:quarkus:runtime:test -PquarkusVersion=3.21.4