Spring transactions can be declared read-only when your code reads but does not modify data. Read-only transactions can be a useful optimization in some cases, such as when you use Hibernate or Neo4j OGM. With Spring Data Neo4j and Neo4j OGM read-only transactions are an integral part of causal cluster. They don’t prevent write queries from happening. However, it’s often nice to check if appropriate settings are used throughout your architecture.

1. Problem

I want to make sure that services with transactions marked as @Transactional(readOnly = true) don’t execute nested write transactions.

2. Solution

To quote the Spring Framework documentation:

By default, a participating transaction joins the characteristics of the outer scope, silently ignoring the local isolation level, timeout value, or read-only flag (if any). Consider switching the validateExistingTransactions flag to true on your transaction manager if you want isolation level declarations to be rejected when participating in an existing transaction with a different isolation level. This non-lenient mode also rejects read-only mismatches (that is, an inner read-write transaction that tries to participate in a read-only outer scope).

— Spring Framework Documentation

The Neo4j specific Neo4jTransactionManager extends from the AbstractPlatformTransactionManager and thus supports enabling validation.

Listing 1 shows the most easy solution, applicable to a Spring Boot based application. It uses org.springframework.boot.autoconfigure.transaction.PlatformTransactionManagerCustomizer to hook into the automatic creation of a transaction manager and customizes it.

Listing 1. TransactionManagerConfiguration.java (The Spring Boot way)
import org.springframework.boot.autoconfigure.transaction.PlatformTransactionManagerCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.support.AbstractPlatformTransactionManager;

class TransactionManagerConfiguration {

        public PlatformTransactionManagerCustomizer<AbstractPlatformTransactionManager> transactionManagementConfigurer() {
                return (AbstractPlatformTransactionManager transactionManager) -> transactionManager

In case you’re on a plain Spring and Spring Data Neo4j based application, you have to instantiate the transaction manager yourself. You can use the automatically configured SessionFactory for creating the transaction manager. Listing 2 shows one way of doing that

Listing 2. TransactionManagerConfiguration.java (Without Spring Boot)
import org.neo4j.ogm.session.SessionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.data.neo4j.transaction.Neo4jTransactionManager;
import org.springframework.transaction.PlatformTransactionManager;

class TransactionManagerConfigurationPlainSDN {

        private final SessionFactory sessionFactory;

        public TransactionManagerConfigurationPlainSDN(SessionFactory sessionFactory) {
                this.sessionFactory = sessionFactory;

        public PlatformTransactionManager transactionManager() {
                Neo4jTransactionManager transactionManager = new Neo4jTransactionManager(sessionFactory);
                return transactionManager;
Those two configurations are not meant to be used together in one project!

Given a "broken" service like the one in Listing 3, the transaction manager now refuses to execute the method.

Listing 3. BrokenService.java
public class BrokenService {
        private final ThingRepository thingRepository;

        public BrokenService(ThingRepository thingRepository) {
                this.thingRepository = thingRepository;

        @Transactional(readOnly = true)
        public ThingEntity tryToWriteInReadOnlyTx() {
                return this.thingRepository.save(new ThingEntity("A thing")); (1)
1 This is broken. #save is pretty obvious a write transaction.

The following Spring Boot test demonstrate the desired behaviour (it’s a JUnit 5 test, so we can autowire our dependency with constructor injection):

Listing 4. BrokenServiceTest.java
public class BrokenServiceTest {

        private static final Predicate<String> MESSAGE_INDICATES_READ_ONLY_MISMATCH = Pattern
                .compile(".*is not marked as read-only but existing transaction is.*").asPredicate();

        private final BrokenService brokenService;

        public BrokenServiceTest(BrokenService brokenService) {
                this.brokenService = brokenService;

        @DisplayName("Wrong transaction usage should fail")
        void wrongTransactionUsageShouldFail() {
                var caughtException = assertThrows(IllegalTransactionStateException.class,
                        () -> brokenService.tryToWriteInReadOnlyTx());
                assertTrue(() -> MESSAGE_INDICATES_READ_ONLY_MISMATCH.test(caughtException.getMessage()));

The full project with all sources and build script is available here: validate-transaction-settings.