1. Overview
Spring's @Transactional annotation provides a nice declarative API to mark transactional boundaries.
Behind the scenes, an aspect takes care of creating and maintaining transactions as they are defined in each occurrence of the @Transactional annotation. This approach makes it easy to decouple our core business logic from cross-cutting concerns like transaction management.
In this tutorial, we'll see that this isn't always the best approach. We'll explore what programmatic alternatives Spring provides, like TransactionTemplate, and our reasons for using them.
2. Trouble in Paradise
Let's suppose we're mixing two different types of I/O in a simple service:
@Transactional public void initialPayment(PaymentRequest request) { savePaymentRequest(request); // DB callThePaymentProviderApi(request); // API updatePaymentState(request); // DB saveHistoryForAuditing(request); // DB }
Here, we have a few database calls alongside a possibly expensive REST API call. At first glance, it might make sense to make the whole method transactional, since we may want to use one EntityManager to perform the whole operation atomically.
However, if that external API takes longer than usual to respond, for whatever reason, we may soon run out of database connections!
2.1. The Harsh Nature of Reality
Here's what happens when we call the initialPayment method:
- The transactional aspect creates a new EntityManager and starts a new transaction – so, it borrows one Connection from the connection pool
- After the first database call, it calls the external API while keeping the borrowed Connection
- Finally, it uses that Connection to perform the remaining database calls
If the API call responds very slowly for a while, this method would hog the borrowed Connection while waiting for the response.
Imagine that during this period, we get a burst of calls to the initialPayment method. Then, all Connections may wait for a response from the API call. That's why we may run out of database connections — because of a slow back-end service!
Mixing the database I/O with other types of I/O in a transactional context is a bad smell. So, the first solution for these sorts of problems is to separate these types of I/O altogether. If for whatever reason we can't separate them, we can still use Spring APIs to manage transactions manually.
3. Using TransactionTemplate
TransactionTemplate provides a set of callback-based APIs to manage transactions manually. In order to use it, first, we should initialize it with a PlatformTransactionManager.
For example, we can set up this template using dependency injection:
// test annotations class ManualTransactionIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; private TransactionTemplate transactionTemplate; @BeforeEach void setUp() { transactionTemplate = new TransactionTemplate(transactionManager); } // omitted }
The PlatformTransactionManager helps the template to create, commit, or rollback transactions.
When using Spring Boot, an appropriate bean of type PlatformTransactionManager will be automatically registered, so we just need to simply inject it. Otherwise, we should manually register a PlatformTransactionManager bean.
3.1. Sample Domain Model
From now on, for the sake of demonstration, we're going to use a simplified payment domain model. In this simple domain, we have a Payment entity to encapsulate each payment's details:
@Entity public class Payment { @Id @GeneratedValue private Long id; private Long amount; @Column(unique = true) private String referenceNumber; @Enumerated(EnumType.STRING) private State state; // getters and setters public enum State { STARTED, FAILED, SUCCESSFUL } }
Also, we'll run all tests inside a test class, using the Testcontainers library to run a PostgreSQL instance before each test case:
@DataJpaTest @Testcontainers @ActiveProfiles("test") @AutoConfigureTestDatabase(replace = NONE) @Transactional(propagation = NOT_SUPPORTED) // we're going to handle transactions manually public class ManualTransactionIntegrationTest { @Autowired private PlatformTransactionManager transactionManager; @Autowired private EntityManager entityManager; @Container private static PostgreSQLContainer<?> pg = initPostgres(); private TransactionTemplate transactionTemplate; @BeforeEach public void setUp() { transactionTemplate = new TransactionTemplate(transactionManager); } // tests private static PostgreSQLContainer<?> initPostgres() { PostgreSQLContainer<?> pg = new PostgreSQLContainer<>("postgres:11.1") .withDatabaseName("baeldung") .withUsername("test") .withPassword("test"); pg.setPortBindings(singletonList("54320:5432")); return pg; } }
3.2. Transactions with Results
The TransactionTemplate offers a method called execute, which can run any given block of code inside a transaction and then return some result:
@Test void givenAPayment_WhenNotDuplicate_ThenShouldCommit() { Long id = transactionTemplate.execute(status -> { Payment payment = new Payment(); payment.setAmount(1000L); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); return payment.getId(); }); Payment payment = entityManager.find(Payment.class, id); assertThat(payment).isNotNull(); }
Here, we're persisting a new Payment instance into the database and then returning its auto-generated id.
Similar to the declarative approach, the template can guarantee atomicity for us. That is, if one of the operations inside a transaction fails to complete, it rolls back all of them:
@Test void givenTwoPayments_WhenRefIsDuplicate_ThenShouldRollback() { try { transactionTemplate.execute(status -> { Payment first = new Payment(); first.setAmount(1000L); first.setReferenceNumber("Ref-1"); first.setState(Payment.State.SUCCESSFUL); Payment second = new Payment(); second.setAmount(2000L); second.setReferenceNumber("Ref-1"); // same reference number second.setState(Payment.State.SUCCESSFUL); entityManager.persist(first); // ok entityManager.persist(second); // fails return "Ref-1"; }); } catch (Exception ignored) {} assertThat(entityManager.createQuery("select p from Payment p").getResultList()).isEmpty(); }
Since the second referenceNumber is a duplicate, the database rejects the second persist operation, causing the whole transaction to rollback. Therefore, the database does not contain any payments after the transaction. It's also possible to manually trigger a rollback by calling the setRollbackOnly() on TransactionStatus:
@Test void givenAPayment_WhenMarkAsRollback_ThenShouldRollback() { transactionTemplate.execute(status -> { Payment payment = new Payment(); payment.setAmount(1000L); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); status.setRollbackOnly(); return payment.getId(); }); assertThat(entityManager.createQuery("select p from Payment p").getResultList()).isEmpty(); }
3.3. Transactions without Results
If we don't intend to return anything from the transaction, we can use the TransactionCallbackWithoutResult callback class:
@Test void givenAPayment_WhenNotExpectingAnyResult_ThenShouldCommit() { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { Payment payment = new Payment(); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); } }); assertThat(entityManager.createQuery("select p from Payment p").getResultList()).hasSize(1); }
3.4. Custom Transaction Configurations
Up until now, we used the TransactionTemplate with its default configuration. Although this default is more than enough most of the time, it's still possible to change configuration settings.
For example, we can set the transaction isolation level:
transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ);
Similarly, we can change the transaction propagation behavior:
transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
Or we can set a timeout, in seconds, for the transaction:
transactionTemplate.setTimeout(1000);
It's even possible to benefit from optimizations for read-only transactions:
transactionTemplate.setReadOnly(true);
Anyway, once we create a TransactionTemplate with a configuration, all transactions will use that configuration to execute. So, if we need multiple configurations, we should create multiple template instances.
4. Using PlatformTransactionManager
In addition to the TransactionTemplate, we can use an even lower-level API like PlatformTransactionManager to manage transactions manually. Quite interestingly, both @Transactional and TransactionTemplate use this API to manage their transactions internally.
4.1. Configuring Transactions
Before using this API, we should define how our transaction is going to look. For example, we can set a three-second timeout with the repeatable read transaction isolation level:
DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); definition.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); definition.setTimeout(3);
Transaction definitions are similar to TransactionTemplate configurations. However, we can use multiple definitions with just one PlatformTransactionManager.
4.2. Maintaining Transactions
After configuring our transaction, we can programmatically manage transactions:
@Test void givenAPayment_WhenUsingTxManager_ThenShouldCommit() { // transaction definition TransactionStatus status = transactionManager.getTransaction(definition); try { Payment payment = new Payment(); payment.setReferenceNumber("Ref-1"); payment.setState(Payment.State.SUCCESSFUL); entityManager.persist(payment); transactionManager.commit(status); } catch (Exception ex) { transactionManager.rollback(status); } assertThat(entityManager.createQuery("select p from Payment p").getResultList()).hasSize(1); }
5. Conclusion
In this tutorial, first, we saw when one should choose programmatic transaction management over the declarative approach. Then, by introducing two different APIs, we learned how to manually create, commit, or rollback any given transaction.
As usual, the sample code is available over on GitHub.