Quarkus Integration

Early-stage support: The fun-quarkus module 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>
// Gradle
implementation("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().

@ApplicationScoped
public 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:

OutcomeTransaction
Action returns Result.isOk()Commits
Action returns Result.isError()Rolls back
Action throws an unchecked exceptionRolls 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.

@ApplicationScoped
public 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().

@ApplicationScoped
public 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:

OutcomeTransaction
Action returns Try.isSuccess()Commits
Action returns Try.isFailure()Rolls back
Action throws an unchecked exceptionRolls 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 ----
@Transactional
public 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 ----
@Transactional
public 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 / @TransactionalResult when your pipeline returns Result<V,E> for typed domain errors.
  • Choose TxTry / @TransactionalTry when your pipeline uses Try<V> to wrap exception-throwing code.
  • Fall back to @Transactional when 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 @Transactionalfun-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

@ApplicationScoped
public 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
}
}
OutcomeTransaction
Returns Result.isOk()Commits
Returns Result.isError()Rolls back
Throws unchecked exceptionRolls back, rethrows

@TransactionalTry

@ApplicationScoped
public 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
}
}
OutcomeTransaction
Returns Try.isSuccess()Commits
Returns Try.isFailure()Rolls back
Throws unchecked exceptionRolls 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.

TxTypeExisting tx presentNo active tx
REQUIRED (default)Joins the existing tx; marks rollback-only on errorBegins a new tx; commits or rolls back
REQUIRES_NEWSuspends outer tx, starts fresh tx, resumes outer afterStarts a fresh tx
MANDATORYJoins the existing txThrows TransactionalException
SUPPORTSJoins the existing tx; marks rollback-only on errorRuns without a tx (no begin/commit)
NOT_SUPPORTEDSuspends outer tx, runs without one, resumes outer afterRuns without a tx
NEVERThrows TransactionalExceptionRuns 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.

@ApplicationScoped
public 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
@ApplicationScoped
public class OrderService {
public Result<Order, OrderError> createOrder(OrderRequest req) { ... }
public Result<Void, OrderError> cancelOrder(String id) { ... }
}

Programmatic vs declarative

// ── Programmatic (TxResult) ──────────────────────────────────────────────────
@ApplicationScoped
public 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) ──────────────────────────────────────
@ApplicationScoped
public 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 @Inject dependencies and adding one more is natural.

Choose declarative (@TransactionalResult / @TransactionalTry) when:

  • You prefer the familiar @Transactional annotation 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 versionStatus
3.11.3tested
3.21.4tested
3.31.4tested
3.35.1tested

To test locally against a specific version:

Terminal window
./gradlew :frameworks:quarkus:runtime:test -PquarkusVersion=3.21.4