DEV Community

Vishesh
Vishesh

Posted on • Edited 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, @Retryable 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 commonly used annotations and how to properly use them in kotlin.

@Transactional

This is supported as per recent update. Maybe your LLM like chatgpt and others maynot have the latest context. So do not believe it, here is the closed github issue https://github.com/spring-projects/spring-framework/issues/24226

@Retryable

The spring's interceptor AOP will be unable to process the suspended function and catch its exceptions to retry. Best way is to add a custom retry mechanism. Below is sample of what works. this is testable and no need to mock during tests as well.
Note: But do not create a custom spring annotation again here as it will again lead to using spring AOPs and the logic wont work.

/** * Utility class for retry operations with configurable backoff strategy. */ public class Retry { /** * Executes a block of code with retry logic and configurable backoff. * * @param <T> The return type of the block. * @param maxAttempts Maximum number of retry attempts. * @param initialDelayMs Initial delay in milliseconds. * @param maxDelayMs Maximum delay cap in milliseconds. * @param backoffFactor Multiplier for exponential backoff (default is 1.0 - no backoff). * @param retryOn List of exception types that should trigger retry. * @param block The block to execute with retry logic. * @return The result of the block if successful. * @throws Exception If all retries fail or a non-retryable exception is thrown. */ public static <T> T withRetry( int maxAttempts, long initialDelayMs, long maxDelayMs, double backoffFactor, List<Class<? extends Throwable>> retryOn, Callable<T> block ) throws Exception { int attempts = 0; Throwable lastException = null; long currentDelay = initialDelayMs; while (attempts < maxAttempts) { try { return block.call(); } catch (Throwable e) { boolean shouldRetry = false; for (Class<? extends Throwable> retryClass : retryOn) { if (retryClass.isInstance(e)) { shouldRetry = true; break; } } if (shouldRetry) { lastException = e; attempts++; if (attempts >= maxAttempts) { break; } // Calculate exponential backoff if (attempts > 1 && backoffFactor > 1) { currentDelay = calculateBackoffDelay( initialDelayMs, maxDelayMs, backoffFactor, attempts ); } try { Thread.sleep(currentDelay); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw ie; } } else { // For exceptions we throw immediately, no retry throw e; } } } if (lastException instanceof Exception) { throw (Exception) lastException; } else if (lastException != null) { throw new RuntimeException(lastException); } else { throw new IllegalStateException("Retry failed for unknown reason"); } } /** * Calculate delay with exponential backoff. * Example: * With maxAttempts = 3, initialDelayMs = 100ms and backoffFactor = 2.0: * Retry Attempt 1: 100ms (initial delay) * Retry Attempt 2: 100 * (2^1) = 200ms * Retry Attempt 3: No delay as max attempt reached */ private static long calculateBackoffDelay( long initialDelayMs, long maxDelayMs, double backoffFactor, int attempt ) { long exponentialDelay = (long) (initialDelayMs * Math.pow(backoffFactor, attempt - 1)); // Cap at maximum delay return Math.min(exponentialDelay, maxDelayMs); } } 
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)