Bank / Wallet
Transactions, Atomic inc/dec, Optimistic Locking
What Morphium Offers
Morphium's @MorphiumTransactional CDI interceptor wraps MongoDB multi-document
transactions in a single annotation. Combined with atomic inc()/dec()
operations and @Version-based optimistic locking, it covers the full spectrum from
simple atomic updates to complex cross-collection transactions.
The Challenge
MongoDB transactions require explicit session management, careful error handling and manual commit/abort logic. Without a framework, transaction boundaries spread across the codebase and are easy to get wrong.
Morphium Features Used
Prerequisites & Key Concepts
@MorphiumTransactionalis a CDI interceptor, not an entity annotation. It goes on the service method (e.g.transfer()), not on the entity class. The Quarkus Morphium extension registers the interceptor automatically.inc()/dec()are server-side atomic operations. They translate to MongoDB's$incoperator and execute without reading the document first. This avoids race conditions on balance updates.@Versionauto-increments on eachstore(). If two threads load the same Account (both see version 3), the first store succeeds (version → 4), and the second fails because its expected version (3) no longer matches. This is optimistic locking — no database locks are held.- Transfer is a denormalized audit log. It stores
fromAccountandtoAccountas Strings (account numbers), not as@Referencelinks. This makes the transfer history self-contained and query-friendly.
Entity Relationship
Entity Source Code
import de.caluga.morphium.annotations.CreationTime; import de.caluga.morphium.annotations.Entity; import de.caluga.morphium.annotations.Id; import de.caluga.morphium.annotations.Index; import de.caluga.morphium.annotations.Version; import de.caluga.morphium.driver.MorphiumId; import lombok.Data; import lombok.Builder; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.experimental.FieldNameConstants; import java.time.LocalDateTime; @Entity(collectionName = "accounts")1 @Data @NoArgsConstructor @AllArgsConstructor @Builder @FieldNameConstants2 public class Account { @Id3 private MorphiumId id; @Index(options = {"unique:1"})4 private String accountNumber; private String ownerName; private double balance; @Builder.Default private String currency = "EUR"; @Version5 private Long version; @CreationTime6 private LocalDateTime createdAt; }
accounts MongoDB collection.Account.Fields.accountNumber, .balance etc. for type-safe queries._id. Auto-generated if null.store() and throws VersionMismatchException on conflict.store(), never updated again.import de.caluga.morphium.annotations.CreationTime; import de.caluga.morphium.annotations.Entity; import de.caluga.morphium.annotations.Id; import de.caluga.morphium.driver.MorphiumId; import lombok.Data; import lombok.Builder; import lombok.NoArgsConstructor; import lombok.AllArgsConstructor; import lombok.experimental.FieldNameConstants; import java.time.LocalDateTime; @Entity(collectionName = "transfers")1 @Data @NoArgsConstructor @AllArgsConstructor @Builder @FieldNameConstants public class Transfer { @Id private MorphiumId id; private String fromAccount;2 private String toAccount; private double amount; private String currency; private String description; @CreationTime3 private LocalDateTime createdAt; private String status; }
transfers collection — acts as an immutable audit log.@Reference) — the record survives even if the referenced account is deleted.Transaction Code
@MorphiumTransactional Transfer
import de.caluga.morphium.Morphium; import de.caluga.morphium.quarkus.transaction.MorphiumTransactional; @MorphiumTransactional1 public TransferResult transfer(String fromNumber, String toNumber, double amount, String description) {2 // Atomic decrement on source account var fromQ = morphium.createQueryFor(Account.class) .f(Account.Fields.accountNumber).eq(fromNumber);3 morphium.inc(fromQ, Account.Fields.balance, -amount);4 // Atomic increment on target account var toQ = morphium.createQueryFor(Account.class) .f(Account.Fields.accountNumber).eq(toNumber); morphium.inc(toQ, Account.Fields.balance, amount); // Store audit log inside the same transaction Transfer t = Transfer.builder() .fromAccount(fromNumber).toAccount(toNumber) .amount(amount).description(description) .status("COMPLETED").build(); morphium.store(t);5 return new TransferResult(true, "OK", t);6 }
TransferResult record (success flag, message, transfer entity). Accepts four parameters including a human-readable description..f(Account.Fields.accountNumber) uses Lombok-generated @FieldNameConstants for type-safe field references.morphium.inc() with a negative value debits the source balance atomically via MongoDB's $inc operator — no read required.Transfer document inside the same transaction so the record is only visible after a successful commit.Transfer entity.Service Code (Deposit / Withdraw)
import de.caluga.morphium.Morphium; import de.caluga.morphium.query.Query; @Inject Morphium morphium; // Deposit — atomic server-side increment public void deposit(String accountNumber, double amount) { Query<Account> q = morphium.createQueryFor(Account.class) .f("accountNumber").eq(accountNumber); morphium.inc(q, "balance", amount);1 } // Withdraw — atomic decrement (inc with negative) public void withdraw(String accountNumber, double amount) { Query<Account> q = morphium.createQueryFor(Account.class) .f("accountNumber").eq(accountNumber); morphium.inc(q, "balance", -amount);2 } // Create account — store() with @Version for optimistic locking public void createAccount(Account account) { morphium.store(account); // @Version auto-set to 13 }
morphium.inc() translates to a MongoDB $inc update — the balance is incremented server-side without reading the document first.inc() performs a decrement. Morphium also provides dec() convenience methods (they delegate to inc() with a negated amount), so either approach works.store() Morphium initializes the @Version field to 1; every subsequent store increments it for optimistic-locking checks.Transaction Lifecycle Events
The @MorphiumTransactional interceptor doesn't just start/commit/abort the
transaction — it also fires CDI events at each phase, so other beans can react without
coupling to the transactional service method:
import de.caluga.morphium.quarkus.transaction.*; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.event.Observes; import static de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent.Phase.*;1 @ApplicationScoped public class AuditObserver { void beforeCommit(@Observes @MorphiumTxPhase(BEFORE_COMMIT) MorphiumTransactionEvent e) {2 // fired after the business method returns, before commitTransaction() } void afterCommit(@Observes @MorphiumTxPhase(AFTER_COMMIT) MorphiumTransactionEvent e) {3 // e.g. publish a domain event now that the write is durable } void afterRollback(@Observes @MorphiumTxPhase(AFTER_ROLLBACK) MorphiumTransactionEvent e) {4 log.warn("Transaction rolled back", e.getFailure());5 } }
Phase enum for readable qualifier usage: BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK.@Observes @MorphiumTxPhase(BEFORE_COMMIT) — fires once the annotated business method has returned normally, right before morphium.commitTransaction().AFTER_COMMIT — fires only after the commit has actually succeeded. Safe point to trigger side effects that require durability (outbox publish, cache invalidation, ...).AFTER_ROLLBACK — fires after abortTransaction(), triggered by any exception thrown from the business method.getFailure() returns the causing Exception — non-null only for AFTER_ROLLBACK, always null for the other two phases.This showcase's own TransactionLifecycleObserver (in the bank
package) observes all three phases and keeps an in-memory log rendered in the Demo tab —
try the “Force Rollback” button there to see AFTER_ROLLBACK fire with
a real exception.
Related Documentation
- Optimistic Locking — @Version, Conflict Detection
- Developer Guide — Transactions, @MorphiumTransactional
- API Reference — inc(), dec(), Atomic Operations