Spring AOP and Kotlin coroutines – What is wrong with Kotlin + SpringBoot
Are you using Springboot and Kotlin? Have you heard about spring AOP? If not then some of spring annotations like @HandlerInterceptor, @Transactional may not work as you except.
What?
AOP Proxies and Suspending Functions:
Spring AOP primarily relies on creating proxies around your beans. When you apply an aspect to a method, Spring creates a proxy that intercepts calls to that method and applies the advice (e.g., @Around, @Before, @After). This mechanism generally works for suspending functions as well. But when using AOP based annotations. We must exercise caution and test them.
Why Spring creates proxies?
Proxies are for cross-cutting concerns:
- Web Request Handling – HTTP request/response processing, validating request, serialising and deserialising request and response, processing headers, so on …
- Transaction Management – Begin/commit/rollback
- Security – Authorization checks
- Caching – Store/retrieve cached results
- Retry Logic – Retry failed operations
- Logging/Monitoring – Method entry/exit tracking
Example of commonly used proxy enabled Spring components:
@RestController // Creates proxy for web-related concerns
@Transactional // Database transaction management
@Cacheable // Caching
@async // Asynchronous execution
@Secured // Security
@PreAuthorize // Security authorization
@Retryable // Retry logic
Reason why AOP does not work well with co routines is: Fundamental Architecture Mismatch: AOP proxies operate at method call level, coroutines operate at language/compiler level
Below are two commonly use annotations and how to properly use them in kotlin.
@Transactional
When using @Transactional with coroutines, be aware that starting new coroutines within a transactional method can break the transactional scope if those new coroutines perform database operations outside the original transaction’s context. Ensure that any database interactions within launched coroutines are either part of the same transaction (e.g., by propagating the transaction context) or are handled with separate transactional boundaries if intended.
Below is good example how to implement @Transactional functions in kotlin
@RestController // Only controller gets proxy
class InventoryController {
@Transactional // Handle transactions at controller level
suspend fun saveInventory(@RequestBody request: CreateInventoryRequest): InventoryResponse {
return inventoryService.saveInventory(request) // Service has no AOP
}
}
@Component // No @Service to avoid proxy
class InventoryService {
suspend fun saveInventory(request: CreateInventoryRequest): InventoryResponse {
// No proxy issues here
return inventoryRepository.save(request.toEntity())
}
}
@HandlerInterceptor
The spring’s interceptor AOP will be unable to process the suspended controller function. This will make the controller return coroutine object as response but meanwhile the actual controller logic will be running on the background. Hence, the client will receive empty or wrong response.
To circumvent above issue. The best way is to use CoWebFilter
. This filter is applied same as handler. It can handle request and response. Below is a sample implementation.
@Component
class HeaderInterceptor : CoWebFilter() {
// Filter runs BEFORE any controller is involved
public override suspend fun filter(
exchange: ServerWebExchange,
chain: CoWebFilterChain
) {
// Verify requests details
// Decorate the response to capture it for any processing
val decoratedExchange = decorateExchange(exchange, idempotencyKey)
// proceed to the controller
chain.filter(decoratedExchange)
}
private fun decorateExchange(
exchange: ServerWebExchange,
idempotencyKey: String
): ServerWebExchange {
val decoratedResponse =
object : ServerHttpResponseDecorator(exchange.response) {
override fun writeWith(body: Publisher<out DataBuffer>): Mono<Void> {
// Read the body and cache it
return DataBufferUtils.join(body)
.flatMap { dataBuffer ->
val bytes = ByteArray(dataBuffer.readableByteCount())
dataBuffer.read(bytes)
DataBufferUtils.release(dataBuffer)
mono {
// Add your own logic to save or modify the response body and status code
// response data is available as `bytes`. you can convert to String or DTO
}.subscribe()
// Write the original response body
super.writeWith(
Mono.just(
exchange.response.bufferFactory().wrap(bytes)
)
)
}
}
}
// Return a new exchange with the decorated response
return exchange.mutate().response(decoratedResponse).build()
}
}