Archived version v6.2.4. This page describes a past release and will not receive further updates.

View this page in v6.2.5 →

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 ​

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.

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 ​

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 ​

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 ​

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
The avatar of contributor named as Claude Opus 5.5 (1M context) Claude Opus 5.5 (1M context)

Released under the MIT License.