Skip to content

Transactions

Since v6.2.0

Transaction support is available starting from UltiTools-API v6.2.0.

UltiTools provides programmatic transaction support through the DataOperator interface. Transactions ensure that a group of operations either all succeed or all roll back on failure.

Basic Usage

These examples are atomic only on the JSON backend

On MySQL and SQLite transaction(...) runs its block with no transaction manager attached, so each write commits as it executes and a failure part way through leaves the earlier writes in place. Do not use the transfer pattern below on a relational backend; take a JDBC connection yourself and manage the boundary there, or keep the entity on the JSON backend. The mechanism is described under How It Works below, and the fix is tracked in issue #307.

Void Transaction

Use transaction(Runnable) for operations that don't return a value:

java
DataOperator<AccountEntity> dataOperator = plugin.getDataOperator(AccountEntity.class);

dataOperator.transaction(() -> {
    AccountEntity from = dataOperator.query()
        .where("playerId").eq(fromPlayer).first();
    AccountEntity to = dataOperator.query()
        .where("playerId").eq(toPlayer).first();

    from.setBalance(from.getBalance() - amount);
    to.setBalance(to.getBalance() + amount);

    try {
        dataOperator.update(from);
        dataOperator.update(to);
    } catch (IllegalAccessException e) {
        throw new RuntimeException(e);
    }
});

If any operation within the transaction throws an exception, all changes are rolled back.

Transaction with Return Value

Use transaction(Callable<R>) when you need to return a result:

java
DataOperator<AccountEntity> dataOperator = plugin.getDataOperator(AccountEntity.class);

try {
    double newBalance = dataOperator.transaction(() -> {
        AccountEntity account = dataOperator.query()
            .where("playerId").eq(playerUuid).first();
        account.setBalance(account.getBalance() + depositAmount);
        dataOperator.update(account);
        return account.getBalance();
    });
    player.sendMessage("New balance: " + newBalance);
} catch (Exception e) {
    player.sendMessage("Transaction failed: " + e.getMessage());
}

Batch Operations

The DataOperator interface provides batch methods that automatically wrap operations in a transaction:

insertAll

Insert multiple entities atomically:

java
List<HomeEntity> homes = new ArrayList<>();
homes.add(HomeEntity.builder().name("base").playerId(uuid).build());
homes.add(HomeEntity.builder().name("mine").playerId(uuid).build());
homes.add(HomeEntity.builder().name("farm").playerId(uuid).build());

dataOperator.insertAll(homes); // All inserted or none

updateAll

Update multiple entities atomically:

java
List<AccountEntity> accounts = dataOperator.getAll();
for (AccountEntity account : accounts) {
    account.setBalance(account.getBalance() * 1.05); // 5% interest
}

dataOperator.updateAll(accounts); // All updated or none

How It Works

Transactions work transparently across all storage backends:

BackendMechanism
MySQL / SQLiteUses JDBC transactions (Connection.setAutoCommit(false), commit/rollback)
JSONUses snapshot-based rollback (copies data before changes, restores on failure)

You don't need to know which backend is active — the same transaction API works for all storage types.

Only the JSON backend rolls back today

The table above describes the intended design: the JSON operator takes a deep-copy snapshot and restores it on failure, while the MySQL and SQLite operators are constructed without a transaction manager, so transaction(...) runs the callable on an autocommit connection and every statement inside it commits as it executes. Use the JSON backend where a group of writes has to be atomic, or take your own JDBC connection, turn off autocommit and commit or roll back yourself: both keep the guarantee out of the relational operators. Wiring the transaction manager so that transaction(), insertAll and updateAll become atomic on the relational backends is tracked in issue #307.

Complete Example

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.interfaces.DataOperator;

@Service
public class EconomyService {

    @Autowired
    private UltiToolsPlugin plugin;

    public boolean transfer(String fromUuid, String toUuid, double amount) {
        DataOperator<AccountEntity> dataOperator =
            plugin.getDataOperator(AccountEntity.class);

        try {
            dataOperator.transaction(() -> {
                AccountEntity from = dataOperator.query()
                    .where("playerId").eq(fromUuid).first();
                AccountEntity to = dataOperator.query()
                    .where("playerId").eq(toUuid).first();

                if (from == null || to == null) {
                    throw new RuntimeException("Account not found");
                }
                if (from.getBalance() < amount) {
                    throw new RuntimeException("Insufficient balance");
                }

                from.setBalance(from.getBalance() - amount);
                to.setBalance(to.getBalance() + amount);

                try {
                    dataOperator.update(from);
                    dataOperator.update(to);
                } catch (IllegalAccessException e) {
                    throw new RuntimeException(e);
                }
            });
            return true;
        } catch (Exception e) {
            // Transaction rolled back automatically
            return false;
        }
    }
}

TIP

For simple single-entity operations, you don't need transactions. Transactions are most useful when you need to ensure multiple operations succeed or fail together.

Declarative Transactions

Nothing in v6.2.5 reads @Transactional

The aop package that would create the proxies is not referenced anywhere outside itself in v6.2.5: no bean post processor is registered and TransactionInterceptor is never instantiated, so an annotated method takes exactly the same path as an unannotated one, with no commit, no rollback and no log line. Move these methods to the programmatic form shown earlier on this page, or drop the annotation, and do it before you upgrade: the wiring in issue #190 is merged into the development branch but is not part of v6.2.5, and once it lands a module that still declares @Transactional can be rejected at load time. Atomicity on the MySQL and SQLite backends additionally depends on the transaction manager wiring tracked in issue #307.

The @Transactional annotation provides declarative transaction management on service methods. This approach is cleaner than programmatic transactions and integrates seamlessly with the IoC container.

Prerequisites

The @Transactional annotation only works on methods within @Service beans, since transactions are implemented via CGLIB proxies:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;

@Service
public class PaymentService {
    @Transactional
    public void processPayment(String playerId, double amount) {
        // This method will be wrapped in a transaction automatically
    }
}

Basic Usage

Simply add @Transactional to a service method:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;

@Service
public class AccountService {

    @Autowired
    private UltiToolsPlugin plugin;

    @Transactional
    public void transfer(String fromPlayerId, String toPlayerId, double amount) {
        DataOperator<AccountEntity> dataOperator =
            plugin.getDataOperator(AccountEntity.class);

        AccountEntity from = dataOperator.query()
            .where("playerId").eq(fromPlayerId).first();
        AccountEntity to = dataOperator.query()
            .where("playerId").eq(toPlayerId).first();

        from.setBalance(from.getBalance() - amount);
        to.setBalance(to.getBalance() + amount);

        try {
            dataOperator.update(from);
            dataOperator.update(to);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        }
    }
}

The method executes within a transaction that commits on success or rolls back on exception.

Annotation Attributes

The @Transactional annotation accepts several configuration options:

AttributeTypeDefaultDescription
propagationPropagationREQUIREDTransaction propagation behavior
isolationIsolationDEFAULTIsolation level
timeoutint-1Timeout in seconds (-1 = no timeout)
readOnlybooleanfalseMark transaction as read-only for optimizations
rollbackForClass[]{}Exception types that trigger rollback
noRollbackForClass[]{}Exception types that do NOT trigger rollback

Propagation Modes

Three of these modes have no implementation behind them

In TransactionInterceptor the REQUIRES_NEW, NOT_SUPPORTED and NESTED branches each carry a comment and then fall through to the ordinary path, so on the interceptor's own terms REQUIRES_NEW joins the existing transaction, NOT_SUPPORTED keeps running inside it and NESTED behaves like REQUIRED; in v6.2.5 the interceptor never runs at all. Do not rely on these three rows: DataOperator exposes only transaction(Runnable) and transaction(Callable) with no way to suspend, nest or set a savepoint, so take a JDBC connection yourself and manage those boundaries there. The plan in issue #307 is to keep only the values that can be implemented, so expect these three rows to be removed rather than filled in.

The propagation attribute controls how the method behaves when called within an existing transaction:

ModeBehavior
REQUIRED (default)Joins the current transaction, or creates a new one if none exists
REQUIRES_NEWAlways creates a new transaction, suspending any existing one
SUPPORTSJoins the current transaction if one exists; executes non-transactionally otherwise
NOT_SUPPORTEDAlways executes without a transaction, suspending any existing one
MANDATORYRequires an existing transaction; throws an exception if none exists
NEVERMust not execute within a transaction; throws an exception if one exists
NESTEDExecutes within a nested transaction (savepoint) if one exists; creates a new transaction otherwise

Example with REQUIRES_NEW:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Propagation;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;

@Service
public class AuditService {

    @Autowired
    private UltiToolsPlugin plugin;

    // This method always gets its own transaction, even if called from another transactional method
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAudit(String message) {
        DataOperator<AuditLogEntity> dataOperator =
            plugin.getDataOperator(AuditLogEntity.class);
        AuditLogEntity log = AuditLogEntity.builder()
            .message(message)
            .timestamp(System.currentTimeMillis())
            .build();
        dataOperator.insert(log);
    }
}

Isolation Levels

The isolation attribute controls the isolation level for the transaction:

LevelPreventsDatabase Support
DEFAULTUses database defaultAll databases
READ_UNCOMMITTEDNone (dirty reads possible)Most databases
READ_COMMITTEDDirty readsMost databases
REPEATABLE_READDirty reads, non-repeatable readsMost databases
SERIALIZABLEAll consistency issuesAll databases

Higher isolation levels provide stronger consistency guarantees but may impact performance. Use SERIALIZABLE only when strict isolation is critical:

java
@Transactional(isolation = Isolation.SERIALIZABLE)
public void criticalTransfer(String from, String to, double amount) {
    // Ensures complete isolation from concurrent transactions
}

Custom Rollback Rules

rollbackFor replaces the default rollback rule, it does not add to it

shouldRollback checks tx.rollbackFor() first and, once it is non-empty, only rolls back for the listed types, so @Transactional(rollbackFor = BusinessException.class) stops the default RuntimeException/Error rule from running at all. List the defaults yourself: write rollbackFor = {BusinessException.class, RuntimeException.class, Error.class} whenever you add a custom type, or the exceptions you did not list will be committed instead of rolled back. Whether rollbackFor should become additive, or the javadoc should instead describe the current replace behavior, is tracked in issue #328.

By default, @Transactional rolls back on any RuntimeException or Error. Use rollbackFor to trigger rollback for additional exceptions:

java
@Transactional(rollbackFor = BusinessException.class)
public void processOrder(Order order) throws BusinessException {
    if (!order.isValid()) {
        throw new BusinessException("Invalid order");  // Triggers rollback
    }
    // Process order...
}

Use noRollbackFor to prevent rollback for specific exceptions:

java
@Transactional(noRollbackFor = WarningException.class)
public void importData(String source) throws WarningException {
    try {
        // Perform import...
    } catch (MinorIssueException e) {
        throw new WarningException("Non-critical issue, transaction commits");
    }
}

Read-Only Transactions

Mark read-only query methods with readOnly = true to allow the database to apply optimizations:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;

import java.util.List;
import java.util.UUID;

@Service
public class PlayerRepository {

    @Autowired
    private UltiToolsPlugin plugin;

    @Transactional(readOnly = true)
    public List<PlayerEntity> getAllPlayers() {
        return plugin.getDataOperator(PlayerEntity.class).getAll();
    }

    @Transactional(readOnly = true)
    public PlayerEntity getPlayerById(UUID uuid) {
        return plugin.getDataOperator(PlayerEntity.class).query()
            .where("uuid").eq(uuid.toString()).first();
    }
}

Timeout Configuration

Set a timeout (in seconds) for long-running transactions:

java
@Transactional(timeout = 30)
public void bulkProcessing() {
    // If execution exceeds 30 seconds, the transaction is rolled back
    List<DataEntity> all = getDataOperator().getAll();
    for (DataEntity entity : all) {
        processEntity(entity);
    }
}

A value of -1 (default) means no timeout.

Important Limitations

  1. Proxy-based AOP: The annotation only works on public methods of @Service beans. The method must be called through the proxy, not directly via this.

  2. Self-invocation bypass: Calling a @Transactional method from another method in the same class bypasses the proxy:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;

@Service
public class BadExample {

    @Transactional
    public void transactionalMethod() { }

    public void callingMethod() {
        // WRONG: This bypasses the proxy, transaction NOT applied
        this.transactionalMethod();
    }
}

To fix, inject the service or call via the container:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;

@Service
public class GoodExample {

    @Autowired
    private BadExample service;  // Inject yourself for external calls

    public void callingMethod() {
        // CORRECT: This goes through the proxy, transaction IS applied
        service.transactionalMethod();
    }

    @Transactional
    public void transactionalMethod() { }
}
  1. Non-final classes: The class cannot be final (CGLIB limitation). The same applies to methods — they must be overridable.

Programmatic vs Declarative

Both approaches achieve the same result. Choose based on your use case:

Use Programmatic Transactions (dataOperator.transaction()) when:

  • You need fine-grained control over transaction boundaries
  • The transaction spans multiple service calls
  • You're working outside a @Service bean
  • You need to handle nested transactions manually

Use Declarative Transactions (@Transactional) when:

  • You want cleaner, more readable service layer code
  • A single method performs all the operations that must be atomic
  • You want to leverage AOP for cross-cutting concerns
  • You're building service classes with multiple transactional methods

Example combining both:

java
package com.ultikits.docs.transactions;

import com.ultikits.ultitools.abstracts.UltiToolsPlugin;
import com.ultikits.ultitools.annotations.Autowired;
import com.ultikits.ultitools.annotations.Service;
import com.ultikits.ultitools.annotations.Transactional;
import com.ultikits.ultitools.interfaces.DataOperator;

@Service
public class ComplexService {

    @Autowired
    private UltiToolsPlugin plugin;

    // Declarative for simple method-level transactions
    @Transactional
    public void simpleOperation() {
        // Automatic transaction management
    }

    // Programmatic for complex multi-step workflows
    public void complexWorkflow() {
        DataOperator<AccountEntity> dataOp = plugin.getDataOperator(AccountEntity.class);

        // Explicit transaction with fine-grained control
        dataOp.transaction(() -> {
            // Multiple coordinated operations
            step1();
            step2();
            step3();
        });
    }

    private void step1() { }

    private void step2() { }

    private void step3() { }
}

Contributors

The avatar of contributor named as Ling Bao Ling Bao

Changelog

Released under the MIT License.