Back to Distributed Transactions & Saga
Q2Question 2 of 2

How do distributed transactions and @Modifying interact?

Key Concepts
  • JTA/XA coordinates multiple resources in one atomic TX using two-phase commit: prepare (vote) then commit
  • @Modifying tells Spring Data that a @Query is a DML statement (UPDATE or DELETE), not a SELECT
  • A @Modifying query must run inside a @Transactional method, otherwise Spring throws InvalidDataAccessApiUsageException
  • clearAutomatically=true clears the persistence context after the bulk update so subsequent reads get fresh data
  • flushAutomatically=true flushes pending Hibernate changes before executing the bulk statement so they are visible to it
  • In microservices, the Transactional Outbox plus Saga pattern is preferred over JTA/XA for cross-resource atomicity

Definition

JTA/XA coordinates multiple resources atomically using two-phase commit (heavy and blocking), while @Modifying marks a Spring Data @Query as a DML statement that must run inside @Transactional and needs clearAutomatically to avoid stale entities.
  • JTA/XA -- Java Transaction API with XA drivers coordinates multiple resources (database + message broker) in one atomic transaction using two-phase commit.
  • Two-phase commit -- phase one: coordinator asks resources to prepare and vote; phase two: commit if all voted yes, rollback if any voted no; blocking protocol that holds locks across network calls.
  • @Modifying -- a Spring Data annotation on @Query methods that tells the framework the statement is an UPDATE or DELETE (DML), not a SELECT, so it calls executeUpdate() and returns affected row count.
  • clearAutomatically=true -- clears the persistence context after the bulk update so subsequent reads do not return stale entities.
  • flushAutomatically=true -- flushes pending Hibernate changes before executing the bulk statement so the statement can see them.

At the National Bank of India, JTA/XA is like a senior manager who holds the vault door and the safe deposit box open at the same time, refusing to close either until both are confirmed. It works but blocks everyone else. Meanwhile, @Modifying is like Rajesh telling the system: "This is not a balance inquiry, this is a mass update to freeze overdrawn accounts. After the update, refresh all the teller screens (clearAutomatically) so they see the new status."

Understanding the Concepts

What are distributed transactions with JTA/XA?

JTA (Java Transaction API) with XA drivers lets one logical transaction span several resources, such as a database and a message broker, or two databases. The coordinator (transaction manager) runs two phases. In the prepare phase, it asks every resource: "Can you commit?" Each resource votes YES or NO. If all vote YES, the commit phase tells every resource to make changes permanent. If any vote NO, all roll back.

The weakness is blocking. Between prepare and commit, resources hold row-level locks across network round-trips. A coordinator crash can leave resources in an "in-doubt" state holding locks indefinitely.

JAVA
package com.bank.config;

import com.atomikos.jdbc.AtomikosDataSourceBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.jta.JtaTransactionManager;

@Configuration
public class JtaConfig {

    @Bean
    public AtomikosDataSourceBean accountsXaDataSource() {
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        ds.setUniqueResourceName("accounts");
        ds.setXaDataSourceClassName(
            "org.postgresql.xa.PGXADataSource");
        return ds;
    }

    @Bean
    public JtaTransactionManager transactionManager() {
        return new JtaTransactionManager();
    }
}
DIAGRAM
JTA/XA flow:
  Coordinator         PostgreSQL        Kafka (not XA!)
       │                  │                  │
       │── PREPARE ──────▶│                  │
       │   vote YES ◀─────│                  │
       │── PREPARE ────────────────────────▶ │
       │   NOT SUPPORTED ◀───────────────────│  Kafka has no XA!
       │
  Result: cannot use 2PC with Kafka
  Alternative: Transactional Outbox + Saga

Why is JTA/XA discouraged in microservices?

In microservices, each service owns its own database and communicates via messages or HTTP. JTA/XA couples services tightly: all participants must be available at commit time. It blocks: locks are held across network boundaries. The coordinator is a single point of failure. And many cloud-native stores (Kafka, DynamoDB, MongoDB) do not support XA.

For a modern banking microservice architecture, the preferred alternative is the Transactional Outbox plus Saga pattern. Each service commits locally, an outbox event is relayed asynchronously, and compensating transactions handle failures.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class AccountMaintenanceService {

    private final AccountRepository accountRepo;
    private final OutboxRepository outboxRepo;

    public AccountMaintenanceService(AccountRepository accountRepo,
                                      OutboxRepository outboxRepo) {
        this.accountRepo = accountRepo;
        this.outboxRepo = outboxRepo;
    }

    @Transactional  // single local TX, no XA
    public void freezeOverdrawnAccount(String accountId) {
        accountRepo.updateStatus(accountId, "FROZEN");
        outboxRepo.save(new OutboxEvent(
            "ACCOUNT_FROZEN",
            "{\"accountId\":\"" + accountId + "\"}")
        );
        // Both committed atomically in one local TX
    }
}

What is @Modifying in Spring Data?

@Modifying is a Spring Data annotation placed on a @Query method to indicate that the query is a DML statement (UPDATE or DELETE) rather than a SELECT. Without @Modifying, Spring Data assumes the query returns results and tries to call getResultList(). With @Modifying, it calls executeUpdate() and returns the number of affected rows.

A @Modifying query must run inside an active @Transactional context. Without a transaction, Spring throws InvalidDataAccessApiUsageException.

JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.math.BigDecimal;

public interface AccountRepository extends JpaRepository<Account, String> {

    // Bulk UPDATE: freeze all overdrawn accounts
    @Modifying
    @Query("UPDATE Account a SET a.status = 'FROZEN' " +
           "WHERE a.balance < :threshold")
    int freezeOverdrawnAccounts(
        @Param("threshold") BigDecimal threshold);

    // Bulk DELETE: purge closed zero-balance accounts
    @Modifying
    @Query("DELETE FROM Account a " +
           "WHERE a.status = 'CLOSED' AND a.balance = 0")
    int purgeClosedAccounts();
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class NightlyMaintenanceService {

    private final AccountRepository accountRepo;

    public NightlyMaintenanceService(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional  // Required for @Modifying queries
    public void runNightlyMaintenance() {
        int frozen = accountRepo.freezeOverdrawnAccounts(
            BigDecimal.ZERO);
        int purged = accountRepo.purgeClosedAccounts();
        System.out.println("Frozen: " + frozen
            + ", Purged: " + purged);
    }
}
TEXT
Expected output:
  Frozen: 23, Purged: 5

What does clearAutomatically do?

A bulk UPDATE or DELETE bypasses the Hibernate persistence context. It sends SQL directly to the database without updating the in-memory entity state. If you loaded an Account entity before the bulk update, that entity still has the old status in memory even though the database row is now FROZEN.

Setting clearAutomatically = true tells Spring Data to call entityManager.clear() after the bulk statement executes. This evicts all entities from the persistence context, so the next read goes to the database and gets fresh data.

JAVA
package com.bank.repository;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

import java.math.BigDecimal;

public interface AccountRepository extends JpaRepository<Account, String> {

    // Without clearAutomatically: stale entities in memory
    @Modifying
    @Query("UPDATE Account a SET a.status = 'FROZEN' " +
           "WHERE a.balance < 0")
    int freezeOverdrawnWrong();

    // With clearAutomatically: persistence context cleared
    @Modifying(clearAutomatically = true)
    @Query("UPDATE Account a SET a.status = 'FROZEN' " +
           "WHERE a.balance < 0")
    int freezeOverdrawnCorrect();
}
JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StaleEntityDemo {

    private final AccountRepository accountRepo;

    public StaleEntityDemo(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void demonstrateStale() {
        Account acc = accountRepo.findById("SAV2001").orElseThrow();
        System.out.println("Before: " + acc.getStatus()); // ACTIVE

        accountRepo.freezeOverdrawnWrong(); // bulk UPDATE

        Account same = accountRepo.findById("SAV2001").orElseThrow();
        System.out.println("After (no clear): " + same.getStatus());
        // Still ACTIVE! Entity from persistence context cache
    }

    @Transactional
    public void demonstrateFresh() {
        Account acc = accountRepo.findById("SAV2001").orElseThrow();
        System.out.println("Before: " + acc.getStatus()); // ACTIVE

        accountRepo.freezeOverdrawnCorrect(); // bulk UPDATE + clear

        Account fresh = accountRepo.findById("SAV2001").orElseThrow();
        System.out.println("After (cleared): " + fresh.getStatus());
        // FROZEN! Fresh read from database
    }
}
TEXT
demonstrate Stale:
  Before: ACTIVE
  After (no clear): ACTIVE   <-- STALE! DB says FROZEN

demonstrateFresh:
  Before: ACTIVE
  After (cleared): FROZEN    <-- CORRECT!

What does flushAutomatically do?

flushAutomatically = true flushes all pending Hibernate changes to the database before executing the bulk statement. Without this, if you modified an entity in memory but Hibernate has not flushed yet, the bulk statement will not see those changes.

JAVA
package com.bank.service;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class FlushDemo {

    private final AccountRepository accountRepo;

    public FlushDemo(AccountRepository accountRepo) {
        this.accountRepo = accountRepo;
    }

    @Transactional
    public void withoutFlush() {
        Account acc = accountRepo.findById("SAV2001").orElseThrow();
        acc.setBalance(new BigDecimal("-500")); // in memory only

        // Without flushAutomatically: Hibernate has NOT flushed
        // The bulk UPDATE does not see the -500 balance
        int frozen = accountRepo.freezeOverdrawnAccounts(
            BigDecimal.ZERO);
        // SAV2001 may NOT be frozen because DB still has old balance
    }
}
JAVA
// With flushAutomatically=true:
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("UPDATE Account a SET a.status = 'FROZEN' " +
       "WHERE a.balance < :threshold")
int freezeOverdrawnFull(@Param("threshold") BigDecimal threshold);

// Flush happens BEFORE the UPDATE -- pending changes are visible
// Clear happens AFTER the UPDATE -- stale entities evicted

What are common mistakes?

The first mistake is forgetting @Transactional on the service method that calls a @Modifying query.

JAVA
// WRONG: no @Transactional
public void freezeAccounts() {
    accountRepo.freezeOverdrawnAccounts(BigDecimal.ZERO);
    // Throws: InvalidDataAccessApiUsageException
    // "Executing an update/delete query"
}

// CORRECT: wrapped in @Transactional
@Transactional
public void freezeAccounts() {
    accountRepo.freezeOverdrawnAccounts(BigDecimal.ZERO);
}

The second mistake is not using clearAutomatically and then reading entities that were just bulk-updated, getting stale data.

What should a senior engineer know?

A senior engineer knows that @Modifying with JPQL executes through Hibernate's query engine, while @Modifying with nativeQuery = true executes raw SQL. Native queries bypass Hibernate's entity mapping entirely, so clearAutomatically is even more critical because Hibernate has no way to know which entities were affected.

Senior engineers also know that multiple @Modifying statements within one @Transactional method commit together atomically, which is sufficient for single-database bulk operations without needing JTA. Cross-resource atomicity is where JTA applies, but the outbox pattern avoids it.

JAVA
// Native query: bypasses Hibernate entirely
@Modifying(clearAutomatically = true)
@Query(value = "UPDATE accounts SET status = 'FROZEN' " +
       "WHERE balance < 0", nativeQuery = true)
int freezeOverdrawnNative();
// Hibernate does not know which entities changed
// clearAutomatically is essential

What are common interview follow-up questions?

  • What exception does Spring throw if @Modifying runs without @Transactional?
  • Can @Modifying be used with INSERT statements in JPA?
  • What is the difference between clearAutomatically and manually calling entityManager.clear()?
  • When would you choose JTA/XA over the Outbox pattern?
  • How do you test a @Modifying query with stale entity detection in a @SpringBootTest?

Visual Overview

DIAGRAM
  @Modifying with clearAutomatically and flushAutomatically:

  ┌──────────────────────────────────────────────────┐
  │ @Transactional                                   │
  │                                                   │
  │  1. acc.setBalance(-500)  [in-memory change]     │
  │                                                   │
  │  2. flushAutomatically=true                      │
  │     └─▶ Hibernate flushes: UPDATE SET balance=-500│
  │                                                   │
  │  3. @Modifying @Query: UPDATE SET status='FROZEN' │
  │     └─▶ Now sees balance=-500 in DB              │
  │                                                   │
  │  4. clearAutomatically=true                      │
  │     └─▶ entityManager.clear()                    │
  │                                                   │
  │  5. accountRepo.findById("SAV2001")              │
  │     └─▶ Fresh read from DB: status=FROZEN ✓      │
  │                                                   │
  │  6. COMMIT                                        │
  └──────────────────────────────────────────────────┘

Key Takeaway

Distributed transactions with JTA and XA coordinate multiple resources into one atomic unit using two-phase commit. In the prepare phase every resource votes, and in the commit phase the coordinator tells them all to commit. The protocol is blocking: between prepare and commit, resources hold their locks across network round-trips, and a coordinator crash can leave them stuck. For that reason, microservices prefer the Transactional Outbox pattern with Saga, where each service commits locally and events relay asynchronously.

The @Modifying annotation tells Spring Data that a @Query is a bulk UPDATE or DELETE rather than a SELECT. These queries must run inside @Transactional. Because a bulk statement bypasses the persistence context, setting clearAutomatically=true clears the context afterward so subsequent reads get fresh data from the database. Setting flushAutomatically=true flushes pending Hibernate changes before the bulk statement so the statement sees them.

In the National Bank of India nightly maintenance job, Rajesh freezes overdrawn accounts and purges closed zero-balance accounts using @Modifying queries. Both DML statements commit together within one database, which is atomic without needing JTA. Cross-resource atomicity is where JTA would apply, but the outbox pattern gives reliability without the blocking cost.

Remember one sentence: @Modifying marks DML queries that need @Transactional, and always set clearAutomatically=true to avoid stale entities.

---

Share
Use ← → arrow keys to navigate