Bldev's Blog

Kotlin · Spring

[Kotlin/Spring] Transaction Management

2025. 7. 30.

Transaction Management

Transaction management refers to managing a transaction, which is a process of performing operations that change the state of a database. Database state change operations include write, update, and delete. Within a method that performs a series of operations, a decision is made to execute multiple job executions as a single transaction. If any job execution fails, the transaction is rolled back to fail the entire job execution, and if all job executions succeed, the transaction is committed. Transaction management is the management of how these multiple operations are tied into a single transaction and how other operations are handled when one operation fails.

Declarative Transaction Management

Declarative transaction management refers to managing transactions using the transaction management features of a library, without writing separate transaction management code. A typical example is using the @Transactional annotation in the Spring framework for declarative transaction management. When this annotation is applied to a method, it automatically handles operations such as beginning, concluding, and rolling back transactions for the work executed within that method.

Declarative transaction management is applied at runtime, which means application code does not depend on transaction management. In this case, transaction management becomes a cross-cutting concern.

Programmatic Transaction Management

Programmatic transaction management means managing transactions by writing specific code for transaction management. A common example is using the PlatformTransactionManager interface in the Spring framework to perform programmatic transaction management.

In general, programmatic transaction management can cause dependency issues because the transaction management code gets tied to specific data access technologies, requiring all related code to be changed when the technology is changed. An abstraction framework like Spring simplifies transaction management tasks independently of the data access technology.

The transaction manager is responsible for managing transactions by starting a new transaction, committing, and rolling back transactions. Instead of repeatedly writing code to start, commit, and roll back transactions manually through the transaction manager, the transaction template provides a method that automatically handles transactions simply by passing the block of code to which the transaction will be applied as an argument and invoking it. This makes transaction management much easier.

Coroutines and Declarative Transaction Management

Declarative transaction management can also be performed in coroutines. For coroutines, declarative transaction management can be done using the @Transactional annotation on suspend functions, and Kotlin has supported the combination of suspended functions and transactions since version 5.3. However, this support is a separate issue from thread blocking. While declarative transaction management is supported for coroutines, maintaining the advantages of coroutines is not guaranteed. If a blocking library like JDBC is used, the thread blocks, undermining the advantages of coroutines. Proper transaction support requires non-blocking reactive libraries like R2DBC. Transaction support is a matter of "whether commit/rollback boundaries can be set and units of work can be grouped," while thread blocking is a matter of "whether the thread is blocked while waiting for DB or external API responses". These two are independent.

Spring's existing transaction management interface, PlatformTransactionManager, binds transaction management to specific threads, meaning existing transactions are not propagated if the thread changes. Therefore, to manage transactions in a reactive environment, there is a ReactiveTransactionManager interface designed for reactive transaction management. In a reactive environment where threads switch, maintaining and propagating the execution context is essential, requiring a separate management approach.

It must be noted that Spring handles the @Transactional annotation through an AOP proxy and determines how to process the transaction based on the return type. The TransactionInterceptor checks the return type of the function to decide the transaction approach. If the return type is a Reactor type (Mono, Flux) or a Kotlin Flow type, it is considered a target for reactive transaction management. Other return types are treated as targets for imperative transaction management and processed normally. All other return types including void are processed through imperative transaction management.

What matters in transaction management is not whether the function is a suspend function, but whether it has reactive characteristics. Coroutines themselves do not possess reactive characteristics. A coroutine is simply an abstraction that can be suspended while running on one thread and resumed on another. Therefore, it is important to note that from a transaction management perspective, it is not automatically handled by Spring's declarative transaction management.

Whether transaction management is properly executed depends entirely on whether the transaction execution is bound to a specific thread or executed across multiple threads. Because suspend functions inherently lack the guarantee of being executed continuously on a single thread, they are not automatically supported by Spring's declarative transaction management.

Officially, Spring supports transactions for suspend functions and coroutines through a programmatic variation of reactive transaction management. It provides TransactionalOperator.executeAndAwait exclusively for suspend functions. This can be used to include operations of a suspend function within a transaction. Using this ensures that the transaction context is correctly propagated across suspension points and does not block the original thread. Additionally, it offers a Flow<T>.transactional extension for the Flow type. Rather than managing transactions via a proxy/AOP approach, this extension explicitly applies transaction boundaries to the Flow pipeline programmatically using TransactionalOperator.

Let's look at the transaction types based on the function signatures:

  • fun foo(): T: It is a regular function and since the return type is not a reactive type, it falls under imperative transaction management.
  • fun foo(): Flow<T>: Although it is a regular function, it returns a reactive type, applying to reactive transaction management.
  • suspend fun foo(): T: It is a suspend function but the return type is not a reactive type. However, it is not imperative transaction management either. Spring's traditional thread-bound transaction management does not apply here. Spring provides separate transaction support for managing coroutine transactions.

Keep in mind that the I/O operations performed within the function are not guaranteed to be non-blocking. It is impossible to achieve a fully reactive implementation while using a blocking I/O library. To handle tasks with non-blocking I/O over a completely reactive stack without thread blocking, and to manage transactions, you must use non-blocking reactive libraries like R2DBC. Reactive transaction management can be performed properly only under the assumption that the entire chain, from database request to operation processing, is non-blocking.

Transaction Management in a Reactive Programming Environment

When declarative transaction management is applied to a function that performs transactional operations, the behavior varies depending on the function type and database library as follows. Assume a coroutine extension library is used without explicitly returning a reactive type.

  • Declarative Transaction Management + Regular Function + Blocking JDBC Library
    • Pros: Declarative transaction management works.
    • Cons: Blocking IO operations block the thread. It is impossible to keep a transaction within the function while preventing blocking IO from obstructing the thread. New coroutines can be created, but since the thread executing the function and the one executing the coroutine are not the same, the transaction is not propagated.
  • Declarative Transaction Management + Regular Function + Non-Blocking R2DBC Library
    • Pros: Declarative transaction management works.
    • Cons: Calling an IO operation through the R2DBC library in a regular function blocks the thread. To execute coroutines inside a regular function, creating a new coroutine is necessary.
  • Declarative Transaction Management + Suspend Function + Blocking JDBC Library
    • Pros: Blocking IO operations within the function are handled using coroutines, preventing the IO work from blocking the current thread. You do not need to create a new coroutine to execute coroutines inside the function.
    • Cons: Declarative transaction management does not work. When managing transactions programmatically, blocking IO operations cannot be handled with coroutines.
  • Declarative Transaction Management + Suspend Function + Non-Blocking R2DBC Library
    • Pros: Declarative transaction management works. IO operations do not block the current thread. You do not need to create a new coroutine to execute coroutines inside the function.

In conclusion, when using the JDBC blocking library, performing transaction management inevitably blocks the thread. If transaction management is not needed, thread blocking can be prevented by employing coroutines to perform blocking calls on a separate thread.

When using the JDBC blocking library, managing a transaction requires a process of storing transaction information on the current thread, and if the thread changes in the midst of executing the work inside the transaction, the transaction information will not be maintained.

References