Why Your Spring @Transactional Fails: 7 Common Pitfalls and How to Fix Them

This article analyzes seven typical reasons why Spring transactions become ineffective or fail to roll back—such as wrong method visibility, final modifiers, self‑invocation, unmanaged beans, multithreading, non‑transactional tables, mis‑configured propagation or exception handling—and provides concrete code examples and practical solutions.

Java Web Project
Java Web Project
Java Web Project
Why Your Spring @Transactional Fails: 7 Common Pitfalls and How to Fix Them

1. Transaction Does Not Take Effect

Spring declarative transactions are implemented with AOP proxies. A method is only wrapped with transaction logic when the proxy can intercept the call. If the proxy cannot intercept, the method runs without a transaction.

1.1 Access‑modifier limitation

Only public methods are eligible for proxying. When a method is declared private, protected or package‑private,

AbstractFallbackTransactionAttributeSource.computeTransactionAttribute

returns null, so no TransactionAttribute is applied.

@Service
public class UserService {
    @Transactional
    private void add(UserModel user) {
        saveData(user);
        updateData(user);
    }
}

1.2 Final method

A final method cannot be overridden by a CGLIB proxy; therefore the proxy cannot insert transaction advice.

@Service
public class UserService {
    @Transactional
    public final void add(UserModel user) {
        saveData(user);
        updateData(user);
    }
}

1.3 Self‑invocation (internal method call)

Calling a @Transactional method from another method of the same class uses this, bypassing the proxy. The inner method executes without a transaction.

@Service
public class UserService {
    public void add(UserModel user) {
        userMapper.insertUser(user);
        updateStatus(user); // no transaction here
    }

    @Transactional
    public void updateStatus(UserModel user) {
        doSameThing();
    }
}

Typical fixes:

Extract the transactional logic into a separate service and inject it.

Inject the service into itself (self‑injection).

Obtain the current proxy via AopContext.currentProxy() and invoke the method through the proxy.

1.4 Bean not managed by Spring

If a class lacks a stereotype annotation ( @Component, @Service, @Controller, @Repository), Spring does not create a bean, so no proxy is generated.

//@Service   // missing annotation
public class UserService {
    @Transactional
    public void add(UserModel user) {
        saveData(user);
        updateData(user);
    }
}

1.5 Multithreaded invocation

Spring binds a database connection to the current thread via a ThreadLocal. When a transactional method spawns a new thread, the child thread receives a different connection, resulting in two independent transactions. An exception in the child thread will not cause the parent transaction to roll back.

@Service
public class UserService {
    @Autowired
    private RoleService roleService;

    @Transactional
    public void add(UserModel user) throws Exception {
        userMapper.insertUser(user);
        new Thread(() -> {
            roleService.doOtherThing(); // runs in another transaction
        }).start();
    }
}

@Service
public class RoleService {
    @Transactional
    public void doOtherThing() {
        System.out.println("save role data");
    }
}

1.6 Table engine does not support transactions

MyISAM tables (default before MySQL 5) do not support transactions. Even if the service method is correctly proxied, changes to a MyISAM table cannot be rolled back.

CREATE TABLE `category` (
    `id` BIGINT NOT NULL AUTO_INCREMENT,
    `one_category` VARCHAR(20) COLLATE utf8mb4_bin DEFAULT NULL,
    `two_category` VARCHAR(20) COLLATE utf8mb4_bin DEFAULT NULL,
    `three_category` VARCHAR(20) COLLATE utf8mb4_bin DEFAULT NULL,
    `four_category` VARCHAR(20) COLLATE utf8mb4_bin DEFAULT NULL,
    PRIMARY KEY (`id`)
) ENGINE=MyISAM AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

1.7 Transaction not enabled (XML configuration)

In a classic Spring project you must declare a DataSourceTransactionManager and an AOP pointcut. Missing or incorrect configuration disables transaction management.

<!-- Transaction manager -->
<bean class="org.springframework.jdbc.datasource.DataSourceTransactionManager" id="transactionManager">
    <property name="dataSource" ref="dataSource"/>
</bean>

<tx:advice id="advice" transaction-manager="transactionManager">
    <tx:attributes>
        <tx:method name="*" propagation="REQUIRED"/>
    </tx:attributes>
</tx:advice>

<aop:config>
    <aop:pointcut expression="execution(* com.example.*.*(..))" id="pointcut"/>
    <aop:advisor advice-ref="advice" pointcut-ref="pointcut"/>
</aop:config>

2. Transaction Does Not Roll Back

2.1 Wrong propagation attribute

Setting propagation = Propagation.NEVER tells Spring to reject any existing transaction, causing the method to run without a transaction. Only REQUIRED, REQUIRES_NEW and NESTED create a new transaction.

@Service
public class UserService {
    @Transactional(propagation = Propagation.NEVER)
    public void add(UserModel user) {
        saveData(user);
        updateData(user);
    }
}

2.2 Swallowing exceptions

If a transactional method catches an exception and does not re‑throw it, Spring assumes the operation succeeded and will not roll back.

@Transactional
public void add(UserModel user) {
    try {
        saveData(user);
        updateData(user);
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        // no re‑throw → no rollback
    }
}

2.3 Throwing a checked exception

Spring rolls back automatically only for RuntimeException and Error. Throwing a checked Exception without configuring rollbackFor prevents rollback.

@Transactional
public void add(UserModel user) throws Exception {
    try {
        saveData(user);
        updateData(user);
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        throw new Exception(e); // checked exception → no rollback
    }
}

2.4 Mis‑configured rollbackFor

If rollbackFor = BusinessException.class is set but the actual exception is SqlException or DuplicateKeyException, the transaction will not roll back. Using rollbackFor = Exception.class or Throwable.class avoids this pitfall.

@Transactional(rollbackFor = BusinessException.class)
public void add(UserModel user) throws Exception {
    // SqlException occurs → not rolled back because it is not BusinessException
    saveData(user);
    updateData(user);
}

2.5 Nested transaction rollback scope

When an inner method uses propagation = Propagation.NESTED and throws an exception, the outer transaction also rolls back because the exception propagates out of the proxy.

@Transactional
public void add(UserModel user) throws Exception {
    userMapper.insertUser(user);
    roleService.doOtherThing(); // NESTED
}

@Service
public class RoleService {
    @Transactional(propagation = Propagation.NESTED)
    public void doOtherThing() {
        System.out.println("save role data");
        // exception here will cause outer transaction to roll back as well
    }
}

To roll back only the inner transaction, catch the exception inside the outer method and do not re‑throw it.

@Transactional
public void add(UserModel user) throws Exception {
    userMapper.insertUser(user);
    try {
        roleService.doOtherThing();
    } catch (Exception e) {
        log.error(e.getMessage(), e);
        // inner transaction rolled back, outer continues
    }
}

3. Other Common Pitfalls

3.1 Large transaction scope

Placing @Transactional on a method that also performs many read‑only queries enlarges the transaction, leading to lock contention and performance degradation. Only the statements that modify data should be inside the transaction.

roleService.save(user);
update(user);

3.2 Programmatic (declarative vs. programmatic) transaction

Spring provides TransactionTemplate for explicit transaction control. This avoids proxy‑related pitfalls and gives finer‑grained scope.

@Autowired
private TransactionTemplate transactionTemplate;

public void save(final User user) {
    queryData1();
    queryData2();
    transactionTemplate.execute(status -> {
        addData1();
        updateData2();
        return Boolean.TRUE;
    });
}

Advantages of programmatic transactions:

Avoids AOP proxy failures that cause transaction loss.

Provides clear, small‑granularity control over transaction boundaries.

3.3 Self‑injection and proxy retrieval examples

When a service needs to call its own transactional method, you can either create a separate service, inject the service into itself, or retrieve the current proxy.

@Service
public class ServiceA {
    @Autowired
    private ServiceB serviceB;

    public void save(User user) {
        queryData1();
        queryData2();
        serviceB.doSave(user);
    }
}

@Service
public class ServiceB {
    @Transactional(rollbackFor = Exception.class)
    public void doSave(User user) {
        addData1();
        updateData2();
    }
}

Self‑injection (no extra class):

@Service
public class ServiceA {
    @Autowired
    private ServiceA self;

    public void save(User user) {
        queryData1();
        queryData2();
        self.doSave(user);
    }

    @Transactional(rollbackFor = Exception.class)
    public void doSave(User user) {
        addData1();
        updateData2();
    }
}

Proxy retrieval via AopContext.currentProxy():

@Service
public class ServiceA {
    public void save(User user) {
        queryData1();
        queryData2();
        ((ServiceA) AopContext.currentProxy()).doSave(user);
    }

    @Transactional(rollbackFor = Exception.class)
    public void doSave(User user) {
        addData1();
        updateData2();
    }
}

3.4 Multithreaded transaction caveat

Spring stores transactional resources in a ThreadLocal<Map<Object, Object>>. Each thread gets its own connection, so a transaction started in the parent thread is not visible to child threads. Therefore, an exception in a child thread cannot trigger a rollback of the parent transaction.

private static final ThreadLocal<Map<Object, Object>> resources =
    new NamedThreadLocal<>("Transactional resources");

3.5 Non‑transactional storage engine

If the underlying table engine (e.g., MyISAM) does not support transactions, even a correctly proxied method cannot roll back changes. Use InnoDB or another transactional engine for tables that participate in multi‑table updates.

3.6 Transaction manager not configured (Spring Boot vs. classic Spring)

Spring Boot automatically configures DataSourceTransactionManagerAutoConfiguration when a datasource is present. In a classic Spring setup you must declare the manager and the tx:advice / aop:config manually, as shown in section 1.7. A mismatched pointcut expression will also prevent the intended beans from being advised.

Conclusion

Spring transaction failures usually stem from proxy limitations (non‑public, final, static, self‑invocation), missing bean registration, multithreaded execution, non‑transactional storage engines, mis‑configured propagation or rollbackFor, and overly large transaction scopes. Understanding the AOP proxy mechanism, ensuring beans are managed, using proper method visibility, handling exceptions correctly, and limiting transaction boundaries (or switching to TransactionTemplate) makes transactional code reliable and performant.

backendJavatransactionAOPSpringSpring Boot
Java Web Project
Written by

Java Web Project

Focused on Java backend technologies, trending internet tech, and the latest industry developments. The platform serves over 200,000 Java developers, inviting you to learn and exchange ideas together. Check the menu for Java learning resources.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.