DEV Community

Cover image for Structured Concurrency in Practice: CoroutineScope vs StructuredTaskScope [Part 1]
Filip Egeric
Filip Egeric

Posted on

Structured Concurrency in Practice: CoroutineScope vs StructuredTaskScope [Part 1]

This post series assumes familiarity with Kotlin, Java, and Spring Boot.

No AI was used during the writing of this post series.

What we're building

We have an old library and we want to sell all the books. For that purpose we decided to build a simple bookstore app. We decide to use the best tools for the job, which in this case are Kotlin and Spring Boot.

We want our customers to be able to see the details of each book, along with the reviews. For that we have the following controller:

package com.bookstore.books

import com.bookstore.reviews.ReviewsService
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/books")
class BooksController(
    private val booksRepository: BooksRepository,
    private val reviewsService: ReviewsService,
) {
    private val log = LoggerFactory.getLogger(BooksController::class.java)

    @GetMapping("/{id}")
    fun fetchBook(@PathVariable id: Long): Book {
        log.info("[START] $id")

        val book = findBook(id)
        val reviews = fetchReviewsFor(id)

        return book
            .copy(reviews = reviews)
            .also { log.info("[SUCCESS] Returning ${it.id}") }
    }

    private fun findBook(id: Long) =
        booksRepository.findById(id) ?: throw BookNotFoundException(id)

    private fun fetchReviewsFor(id: Long) =
        reviewsService.fetchReviewsFor(id)
}
Enter fullscreen mode Exit fullscreen mode

The fetchBook method is quite straightforward: We find the book in the database, fetch the reviews for it from a 3rd party service, and finally return the book together with the reviews.

After trying it out, we see that the response time for this endpoint is somewhat slow. When calling the endpoint multiple times, we see that the response times are consistently around 2s:

➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/5'
{"id":5,"title":"One Hundred Years of Solitude","author":"Gabriel García Márquez","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 2.022749s
➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/7'
{"id":7,"title":"The Hobbit","author":"J.R.R. Tolkien","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 2.026064s
➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/1'
{"id":1,"title":"To Kill a Mockingbird","author":"Harper Lee","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 2.020895s
➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"id":3,"title":"Pride and Prejudice","author":"Jane Austen","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 2.018304s
Enter fullscreen mode Exit fullscreen mode

And the logs show the following:

07:32:51.494 ... BooksController - [START] 5
07:32:51.494 ... BooksRepository - [START] Fetching book with id 5
07:32:52.501 ... BooksRepository - [DONE] Fetched book with id 5
07:32:52.502 ... ReviewsService - [START] Fetching reviews for book with id 5
07:32:53.506 ... ReviewsService - [DONE] Fetched reviews for book with id 5
07:32:53.506 ... BooksController - [SUCCESS] Returning 5
Enter fullscreen mode Exit fullscreen mode

From the logs, it's obvious that it takes about 1s to find the book in the database and only after that we start fetching the reviews, which also takes around 1s, adding up to 2s in total.

Making it faster

Zooming in at the code, we see that the 2 calls we make (one to the database and the other to the reviews service) are completely independent:

@GetMapping("/{id}")
fun fetchBook(@PathVariable id: Long): Book {
    log.info("[START] $id")

    val book = findBook(id)
    val reviews = fetchReviewsFor(id)

    return book
        .copy(reviews = reviews)
        .also { log.info("[SUCCESS] Returning ${it.id}") }
}
Enter fullscreen mode Exit fullscreen mode

This means that there is no reason for them to be sequential, they can be executed in parallel.
Since we are using Kotlin, if we google or ask an AI (Accelerated Inference) tool the following: "How to make 2 calls in parallel in Kotlin", the first answer will most likely be: "Use coroutineScope and async coroutine builders."

After implementing it (or letting AI do it), our code looks like this:

@GetMapping("/{id}")
suspend fun fetchBook(@PathVariable id: Long): Book = coroutineScope {
    log.info("[START] $id")

    val book = async { findBook(id) }
    val reviews = async { fetchReviewsFor(id) }

    book.await()
        .copy(reviews = reviews.await())
        .also { log.info("[SUCCESS] Returning ${it.id}") }
}
Enter fullscreen mode Exit fullscreen mode

However, this doesn't compile. To understand why, we have to look at build.gradle.kts:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webmvc")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.springframework.boot:spring-boot-starter-data-jdbc")
// ... more dependencies
}
Enter fullscreen mode Exit fullscreen mode

From the dependecies block, we see that we are using webmvc and jdbc. This means that we are using the blocking style of Spring boot, which doesn't include coroutines by default. In order to have access to coroutines, we would have to switch from webmvc to webflux. But this would mean a complete paradigm change and rewriting most of the codebase to make it non-blocking (using r2dbc instead of jdbc for example).

Since we just want to add coroutine support and we don't want to rewrite anything, the easy option is to explicitly add coroutines to our build.gradle.kts:

dependencies {
    // ...
    val coroutinesVersion = "1.10.2"
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
    // ...
}
Enter fullscreen mode Exit fullscreen mode

Now the code compiles, so we can try to run it:

➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"timestamp":"2026-03-29T05:56:55.398Z","status":500,"error":"Internal Server Error","trace":"java.lang.NoClassDefFoundError: org/reactivestreams/Publisher\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeSuspendingFunction(InvocableHandlerMethod.java:294)...
Enter fullscreen mode Exit fullscreen mode

Oops, that did not work. :(

Looking at the stacktrace, we sse that we are missing org/reactivestreams/Publisher class. This class comes included if we use webflux, but since we are using webmvc the class is not there.

Digging a little deeper, we find that the reason this is happening is because we used suspend modifier on a handler method in a controller. Spring boot has first-class support for coroutines and we can mark handler methods with suspend, but it assumes usage of webflux underneath the hood in this case.

But we are using webmvc which is blocking, so why not just block:

@GetMapping("/{id}")
fun fetchBook(@PathVariable id: Long): Book = runBlocking {
    log.info("[START] $id")

    val book = async { findBook(id) }
    val reviews = async { fetchReviewsFor(id) }

    book.await()
        .copy(reviews = reviews.await())
        .also { log.info("[SUCCESS] Returning ${it.id}") }
}
Enter fullscreen mode Exit fullscreen mode

We removed the suspend modifier and replaced coroutineScope {... with runBlocking {....

It's usually not recommended to use runBlocking outside of the main method or tests, but in this case we find it acceptable, because the context in which we are running is already blocking, so we are not blocking any threads that we are not supposed to block. If we were using webflux instead, then runBlocking would be a big no-no.

Now we can call the endpoint again:

➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"id":3,"title":"Pride and Prejudice","author":"Jane Austen","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 2.206648s
Enter fullscreen mode Exit fullscreen mode

It works, but it still takes 2s :(

To understand why, we have to understand how coroutines and runBlocking work. Here's the (important parts of) docs for runBlocking:

Runs a new coroutine and **blocks** the current thread _interruptibly_ until its completion.
...
The default [CoroutineDispatcher] for this builder is an internal implementation of event loop that processes continuations
in this blocked thread until the completion of this coroutine.
See [CoroutineDispatcher] for the other implementations that are provided by `kotlinx.coroutines`.
...
Enter fullscreen mode Exit fullscreen mode

It turns out, that the reason we are not executing anything in parallel, is because the default dispatcher for runBlocking is executing all the coroutines on a single calling thread. Luckily, the fix for this is easy, we just need to add a different dispatcher to runBlocking:

@GetMapping("/{id}")
fun fetchBook(@PathVariable id: Long): Book = runBlocking(Dispatchers.IO) { ...
Enter fullscreen mode Exit fullscreen mode

Calling the endpoint again:

➜  ~ curl -s -w '\n\nTotal time: %{time_total}s\n' 'http://localhost:9999/books/3'
{"id":3,"title":"Pride and Prejudice","author":"Jane Austen","reviews":[{"rating":5,"description":"This book is awesome!"},{"rating":4,"description":"This book is pretty good, but could be better"},{"rating":3,"description":"I don't like this book at all"},{"rating":1,"description":"This book sucks, I've given it a 1/5"}]}

Total time: 1.186013s
Enter fullscreen mode Exit fullscreen mode

It now returns in about 1s!

Mission accomplished, right?

Well, if we vibe coded the entire thing, we can just put it to rest, because the happy path works. However, if we don't want to be woken up at 2am by alerts from production, we may want to dig a little deeper.

That digging will happen in part 2.

Top comments (0)