Backend Development 10 min read

Programmatic vs Declarative Transactions in Spring Boot 3: When Performance Matters

This article compares Spring Boot's declarative @Transactional annotation with programmatic transaction management using TransactionTemplate, TransactionalOperator, and TransactionManager, explains their suitable scenarios, provides code examples, and presents JMeter performance tests that show how the right choice can dramatically improve throughput.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Programmatic vs Declarative Transactions in Spring Boot 3: When Performance Matters

Programming vs Declarative Transactions

Spring Boot offers two ways to handle transactions: the declarative approach using @Transactional annotations and the programmatic approach using APIs such as TransactionTemplate , TransactionalOperator , and TransactionManager . Declarative transactions are simple and require no configuration code, while programmatic transactions give fine‑grained control and can improve performance in specific scenarios.

When to Use Programmatic Transactions

Fine‑grained control : you can explicitly start, commit, or roll back a transaction inside the code, which is useful for complex business logic that needs conditional transaction handling.

Non‑standard transaction management : when a single method must work with multiple data sources or when distributed transactions are required, the programmatic style allows you to manage each transaction separately.

When to Use Declarative Transactions

Simplify transaction management : annotations remove the need to write boilerplate transaction code.

Standard CRUD operations : for typical insert, update, delete, and select operations, declarative transactions provide automatic commit/rollback.

Programmatic Transaction APIs

1. TransactionTemplate

Spring automatically configures TransactionTemplate when dependencies such as spring-boot-starter-data-jpa are present.

<code>@Bean
@ConditionalOnMissingBean
@ConditionalOnSingleCandidate(ReactiveTransactionManager.class)
public TransactionalOperator transactionalOperator(ReactiveTransactionManager transactionManager) {
    return TransactionalOperator.create(transactionManager);
}

@Configuration(proxyBeanMethods = false)
@ConditionalOnSingleCandidate(PlatformTransactionManager.class)
public static class TransactionTemplateConfiguration {
    @Bean
    @ConditionalOnMissingBean(TransactionOperations.class)
    public TransactionTemplate transactionTemplate(PlatformTransactionManager transactionManager) {
        return new TransactionTemplate(transactionManager);
    }
}
</code>

Typical usage:

<code>@Resource
private TransactionTemplate template;

@Resource
private JdbcTemplate jdbcTemplate;

public void save(Person person) {
    template.execute(new TransactionCallback<Object>() {
        @Override
        public Object doInTransaction(TransactionStatus status) {
            try {
                int result = jdbcTemplate.update(
                    "insert into t_person (age, name) values (?, ?)",
                    person.getAge(), person.getName());
            } catch (Exception e) {
                e.printStackTrace();
                status.setRollbackOnly();
            }
            return "success";
        }
    });
}
</code>

Note: configuring isolation level or timeout on the globally auto‑configured TransactionTemplate affects all operations, so it is usually better to create a dedicated instance.

<code>public class PersonService {
    private final TransactionTemplate transactionTemplate;

    public PersonService(PlatformTransactionManager transactionManager) {
        this.transactionTemplate = new TransactionTemplate(transactionManager);
        this.transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_READ_UNCOMMITTED);
        this.transactionTemplate.setTimeout(30); // seconds
    }
}
</code>

2. TransactionalOperator (Reactive)

Example with a reactive repository:

<code>public class UserService {
    @Resource
    private R2dbcEntityTemplate template;
    private final TransactionalOperator transactionalOperator;

    public UserService(ReactiveTransactionManager transactionManager) {
        this.transactionalOperator = TransactionalOperator.create(transactionManager);
    }

    public Mono<User> save(User user) {
        return Mono.just(user)
            .then(template.insert(user))
            .doOnNext(u -> {
                // intentionally cause an exception
                System.out.println(1 / 0);
            })
            .as(transactionalOperator::transactional);
    }
}
</code>

If the as(transactionalOperator::transactional) call is omitted, the insert succeeds; with it, the transaction rolls back and no data is persisted.

<code>public Flux<Integer> save2(User user) {
    return this.transactionalOperator.execute(new TransactionCallback<Integer>() {
        @Override
        public Mono<Integer> doInTransaction(ReactiveTransaction status) {
            return Mono.just(user)
                .then(template.insert(user))
                .doOnNext(u -> System.out.println(1 / 0))
                .doOnError(RuntimeException.class, e -> status.setRollbackOnly())
                .map(User::getUid);
        }
    });
}
</code>

3. Direct TransactionManager Usage

<code>public void save() {
    Person person = new Person();
    person.setAge(36);
    person.setName("张三");
    DefaultTransactionDefinition definition = new DefaultTransactionDefinition();
    definition.setName("CustomTx");
    definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
    definition.setReadOnly(false);
    definition.setTimeout(2);
    TransactionStatus transactionStatus = tm.getTransaction(definition);
    try {
        jdbcTemplate.update("insert into t_person (age, name) values (?, ?)", person.getAge(), person.getName());
        // force an exception
        System.out.println(1 / 0);
        tm.commit(transactionStatus);
    } catch (Exception e) {
        e.printStackTrace();
        tm.rollback(transactionStatus);
    }
}
</code>

Performance Comparison (Wrong Transaction Usage)

Database connection pool was limited to 5 connections to highlight differences.

3.1 Declarative (@Transactional) Method

<code>@Transactional
public void save(Person person) {
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {}
    this.personRepository.saveAndFlush(person);
}
</code>

JMeter test showed very low throughput and a timeout error because the pool ran out of connections after 30 seconds.

3.2 Programmatic Method

<code>public void save(Person person) {
    try {
        TimeUnit.SECONDS.sleep(1);
    } catch (InterruptedException e) {}
    this.transactionTemplate.execute(new TransactionCallbackWithoutResult() {
        @Override
        protected void doInTransactionWithoutResult(TransactionStatus status) {
            personRepository.saveAndFlush(person);
        }
    });
}
</code>

With the same JMeter configuration, throughput increased dramatically and no errors occurred.

These results demonstrate that choosing the appropriate transaction style can significantly improve overall system performance.

Conclusion

Non‑transactional work should be performed outside of a transaction, or you should adopt a programmatic transaction approach when fine‑grained control or performance is required.

Javaperformance testingSpring BootTransaction ManagementDeclarative TransactionsProgrammatic Transactions
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

login 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.