DEV Community

Vishesh
Vishesh

Posted on

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:

  1. Web Request Handling - HTTP request/response processing, validating request, serialising and deserialising request and response, processing headers, so on ...
  2. Transaction Management - Begin/commit/rollback
  3. Security - Authorization checks
  4. Caching - Store/retrieve cached results
  5. Retry Logic - Retry failed operations
  6. 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())
    }
}
Enter fullscreen mode Exit fullscreen mode

@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.
This image explain what happens if we use regular @HandlerInterceptor

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.

CoWebFilter flow

@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()
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)