The solution using synchronized block and a mutable map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher
) {
private val details = mutableMapOf<Company, CompanyDetails>()
private val lock = Any()
suspend fun getDetails(company: Company): CompanyDetails {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
synchronized(lock) {
details[company] = companyDetails
}
return companyDetails
}
return current
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
synchronized(lock) {
details[company]
}
fun getReadyDetails(): Map<Company, CompanyDetails> =
synchronized(lock) {
details.toMap()
}
fun clear() = synchronized(lock) {
details.clear()
}
}
The solution using synchronized block and a read-only map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher
) {
@Volatile
private var details = mapOf<Company, CompanyDetails>()
private val lock = Any()
suspend fun getDetails(company: Company): CompanyDetails {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
synchronized(lock) {
details = details + (company to companyDetails)
}
return companyDetails
}
return current
}
// Access to value is atomic so we don't need to synchronize
fun getDetailsOrNull(company: Company): CompanyDetails? =
details[company]
// Access to value is atomic so we don't need to synchronize
fun getReadyDetails(): Map<Company, CompanyDetails> = details
// Setting value is atomic so we don't need to synchronize
fun clear() {
details = emptyMap()
}
}
The solution using a dispatcher limited to a single thread and a mutable map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher,
) {
private val details = mutableMapOf<Company, CompanyDetails>()
private val dispatcher = dispatcher.limitedParallelism(1)
suspend fun getDetails(company: Company): CompanyDetails =
withContext(dispatcher) {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
details[company] = companyDetails
companyDetails
} else {
current
}
}
suspend fun getDetailsOrNull(
company: Company
): CompanyDetails? = withContext(dispatcher) {
details[company]
}
suspend fun getReadyDetails(): Map<Company, CompanyDetails> =
withContext(dispatcher) {
details.toMap()
}
suspend fun clear() = withContext(dispatcher) {
details.clear()
}
}
The solution using a dispatcher limited to a single thread and a read-only map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher,
) {
@Volatile
private var details = mapOf<Company, CompanyDetails>()
private val dispatcher = dispatcher.limitedParallelism(1)
suspend fun getDetails(company: Company): CompanyDetails =
withContext(dispatcher) {
val current = details[company]
if (current == null) {
val companyDetails = client.fetchDetails(company)
details += (company to companyDetails)
companyDetails
} else {
current
}
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
details[company]
fun getReadyDetails(): Map<Company, CompanyDetails> = details
fun clear() {
details = emptyMap()
}
}
The solution using AtomicReference and read-only map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher
) {
private var details =
AtomicReference<Map<Company, CompanyDetails>>(emptyMap())
suspend fun getDetails(company: Company): CompanyDetails {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
details.updateAndGet {
it + (company to companyDetails)
}
return companyDetails
}
return current
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
details.get()[company]
fun getReadyDetails(): Map<Company, CompanyDetails> =
details.get()
fun clear() {
details.set(emptyMap())
}
}
This solution is not efficient, because for in case of conflict, updateAndGet will recalculate its update. Since adding an element to a read-only collection is heavy, that is not a good solution.
The solution using a concurrent collection from java.util.concurrent package:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher,
) {
private var details =
ConcurrentHashMap<Company, CompanyDetails>()
suspend fun getDetails(company: Company): CompanyDetails {
val current = details[company]
if (current == null) {
val companyDetails = client.fetchDetails(company)
details[company] = companyDetails
return companyDetails
}
return current
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
details[company]
fun getReadyDetails(): Map<Company, CompanyDetails> =
details.toMap()
fun clear() {
details.clear()
}
}
The solution using a mutex and a mutable map:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher
) {
private val details = mutableMapOf<Company, CompanyDetails>()
private val mutex = Mutex()
suspend fun getDetails(company: Company): CompanyDetails {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
mutex.withLock {
details[company] = companyDetails
}
return companyDetails
}
return current
}
suspend fun getDetailsOrNull(
company: Company
): CompanyDetails? = mutex.withLock {
details[company]
}
suspend fun getReadyDetails(): Map<Company, CompanyDetails> =
mutex.withLock {
details.toMap()
}
suspend fun clear() = mutex.withLock {
details.clear()
}
}
The solution using concurrent collection with suspending lazy objects (suspendLazy implementation from the exercise Suspended lazy):
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher,
) {
private var details =
ConcurrentHashMap<Company, SuspendLazy<CompanyDetails>>()
suspend fun getDetails(company: Company): CompanyDetails {
details.computeIfAbsent(company) {
suspendLazy { client.fetchDetails(company) }
}
return details[company]!!.invoke()
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
details[company]?.valueOrNull()
fun getReadyDetails(): Map<Company, CompanyDetails> = details
.toMap()
.mapNotNull { (k, v) -> v.valueOrNull()?.let { k to it } }
.toMap()
fun clear() {
details.clear()
}
}
The solution using caching library to cache details:
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher,
) {
private var cache = cacheBuilder<Company, CompanyDetails> {
this.dispatcher = dispatcher
}.build()
suspend fun getDetails(company: Company): CompanyDetails =
cache.get(company) { client.fetchDetails(company) }
fun getDetailsOrNull(company: Company): CompanyDetails? =
cache.getOrNull(company)
fun getReadyDetails(): Map<Company, CompanyDetails> =
cache.asDeferredMap()
.filter { it.value.isCompleted }
.mapValues { it.value.getCompleted() }
fun clear() {
cache.invalidateAll()
}
}
Here is how much time different operations took on my machine (MacBook M2 Pro 2022, time in ms or 10,000 calls, concurrently on 10,000 coroutines). Only consider the order of magnitude, not the exact values due to error margins.
getDetail
getDetailOrNull
getReadyDetails
Synchronization block, mutable map
6
2
4417
Synchronization block, read-only map
2
1
1
Mutex, mutable map
11
3
4193
Mutex, read-only map
2
1
1
Single thread dispatcher, mutable map
19
5
7925
Single thread dispatcher, read-only map
12
1
1
ConcurrentHashMap
2
1
1260
Suspended lazy map
2
2
3159
Cache
2
1
4970
The solution that is both efficient, simple, and works best is using caching library. It can also be configured for more advanced use cases.
Regarding simpler solutions, concurrent collection, if it is enough for your use case, is the best choice. It is simple and efficient.
Using a dispatcher is the easiest solution, but it requires making more functions suspending. Synchronized block or mutex requires fine-grained control over the shared state, but it is efficient and helps achieve simpler API.
Using mutable collection is better when you more ofen need to modify the collection than making its copy, what is the most common case.
Example solution in playground
import kotlinx.coroutines.*
import kotlinx.coroutines.test.currentTime
import kotlinx.coroutines.test.runTest
import java.math.BigDecimal
import org.junit.Ignore
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.time.measureTime
class CompanyDetailsRepository(
private val client: CompanyDetailsClient,
dispatcher: CoroutineDispatcher
) {
private val details = mutableMapOf<Company, CompanyDetails>()
private val lock = Any()
suspend fun getDetails(company: Company): CompanyDetails {
val current = getDetailsOrNull(company)
if (current == null) {
val companyDetails = client.fetchDetails(company)
synchronized(lock) {
details[company] = companyDetails
}
return companyDetails
}
return current
}
fun getDetailsOrNull(company: Company): CompanyDetails? =
synchronized(lock) {
details[company]
}
fun getReadyDetails(): Map<Company, CompanyDetails> =
synchronized(lock) {
details.toMap()
}
fun clear() = synchronized(lock) {
details.clear()
}
}
// Run in main
suspend fun performanceTest(): Unit = coroutineScope {
val companies = (0 until 100_000).map { Company(it.toString()) }
val client = FakeCompanyDetailsClient(
details = buildMap {
companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) }
}
)
val repository = CompanyDetailsRepository(client, Dispatchers.IO)
val dispatcher = newFixedThreadPoolContext(100, "test")
// The time of getting and storing details
measureTime {
companies.map { async(dispatcher) { repository.getDetails(it) } }.awaitAll()
}.also {
val averageTime = it.inWholeNanoseconds / companies.size
println("Average time of getting details: $averageTime ns")
}
// The time of getting details from cache
measureTime {
companies.map { async(dispatcher) { repository.getDetailsOrNull(it) } }.awaitAll()
}.also {
val averageTime = it.inWholeNanoseconds / companies.size
println("Average time of getting details from cache: $averageTime ns")
}
// The time of getting all details
val repeats = 1000
measureTime {
coroutineScope {
repeat(repeats) {
launch(dispatcher) {
repository.getReadyDetails()
}
}
}
}.also {
val averageTime = it.inWholeNanoseconds / repeats
println("Time of getting all details: $averageTime ns")
}
}
interface CompanyDetailsClient {
suspend fun fetchDetails(company: Company): CompanyDetails
}
data class CompanyDetails(val name: String, val address: String, val revenue: BigDecimal)
data class Company(val id: String)
@OptIn(ExperimentalStdlibApi::class)
class CompanyDetailsRepositoryTest {
@Test
fun `detailsFor should fetch details from client`() = runTest {
// given
val company = Company("1")
val details = CompanyDetails("Company", "Address", BigDecimal.TEN)
val client = FakeCompanyDetailsClient()
client.setDetails(company, details)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
// when
val result = repository.getDetails(company)
// then
assertEquals(details, result)
}
@Test
fun `detailsFor should cache details`() = runTest {
// given
val company = Company("1")
val details = CompanyDetails("Company", "Address", BigDecimal.TEN)
val client = FakeCompanyDetailsClient(
details = mapOf(company to details),
delay = 1000
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
// when
val result1 = repository.getDetails(company)
// then
assertEquals(details, result1)
assertEquals(1000, currentTime)
// when
client.clear()
// then
val result = repository.getDetails(company)
assertEquals(details, result)
}
@Test
fun `getReadyDetails should return all details`() = runTest {
// given
val company1 = Company("1")
val company2 = Company("2")
val details1 = CompanyDetails("Company1", "Address1", BigDecimal.TEN)
val details2 = CompanyDetails("Company2", "Address2", BigDecimal.ONE)
val client = FakeCompanyDetailsClient(
details = mapOf(company1 to details1, company2 to details2)
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
// when
repository.getDetails(company1)
repository.getDetails(company2)
val result = repository.getReadyDetails()
// then
assertEquals(mapOf(company1 to details1, company2 to details2), result)
}
@Test
fun `getReadyDetails should fetch details asynchronously`() = runTest {
// given
val company1 = Company("1")
val company2 = Company("2")
val details1 = CompanyDetails("Company1", "Address1", BigDecimal.TEN)
val details2 = CompanyDetails("Company2", "Address2", BigDecimal.ONE)
val client = FakeCompanyDetailsClient(
details = mapOf(company1 to details1, company2 to details2),
delay = 1000,
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
// when
coroutineScope {
launch {
repository.getDetails(company1)
assertEquals(1000, currentTime)
}
launch {
repository.getDetails(company2)
assertEquals(1000, currentTime)
}
}
val result = repository.getReadyDetails()
// then
assertEquals(mapOf(company1 to details1, company2 to details2), result)
assertEquals(1000, currentTime)
}
@Test
fun `should not expose internal collection`() = runTest {
// given
val company = Company("1")
val details = CompanyDetails("Company", "Address", BigDecimal.TEN)
val client = FakeCompanyDetailsClient(
details = mapOf(company to details)
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
val detailsMap = repository.getReadyDetails()
// when
repository.getDetails(company)
// then
assertEquals(mapOf(), detailsMap)
}
@Test
fun `getDetailsOrNull should return details if exists`() = runTest {
// given
val company = Company("1")
val company2 = Company("2")
val details = CompanyDetails("Company", "Address", BigDecimal.TEN)
val client = FakeCompanyDetailsClient(
details = mapOf(company to details)
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
// then
assertEquals(null, repository.getDetailsOrNull(company))
assertEquals(null, repository.getDetailsOrNull(company2))
// when
repository.getDetails(company)
// then
assertEquals(details, repository.getDetailsOrNull(company))
assertEquals(null, repository.getDetailsOrNull(company2))
}
// Synchronization tests
@Test
fun `should cache all details`() = runBlocking(Dispatchers.IO) {
val parallelCalls = 10_000
val companies = (0 until parallelCalls).map { Company(it.toString()) }
val client = FakeCompanyDetailsClient(
details = buildMap {
companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) }
}
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
coroutineScope {
for (company in companies) {
launch {
val details = repository.getDetails(company)
assertEquals(
CompanyDetails("Company${company.id}", "Address${company.id}", BigDecimal.TEN),
details
)
}
}
}
assertEquals(parallelCalls, repository.getReadyDetails().size)
}
@Test
fun `should not have conflict when changing details and getting all`() = runBlocking(Dispatchers.IO) {
val parallelCalls = 10_000
val companies = (0 until parallelCalls).map { Company(it.toString()) }
val client = FakeCompanyDetailsClient(
details = buildMap {
companies.forEach { put(it, CompanyDetails("Company${it.id}", "Address${it.id}", BigDecimal.TEN)) }
}
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
for (company in companies) {
launch { repository.getDetails(company) }
}
repeat(1000) {
launch { repository.getReadyDetails() }
}
}
@Test
@Ignore
fun `should not fetch the same details twice`() = runTest {
val company = Company("1")
val details = CompanyDetails("Company", "Address", BigDecimal.TEN)
val client = FakeCompanyDetailsClient(
details = mapOf(company to details),
delay = 1000
)
val repository = CompanyDetailsRepository(client, coroutineContext[CoroutineDispatcher]!!)
coroutineScope {
launch { repository.getDetails(company) }
launch { repository.getDetails(company) }
}
assertEquals(1, client.calls)
}
}
class FakeCompanyDetailsClient(
details: Map<Company, CompanyDetails> = emptyMap(),
var delay: Long = 0,
) : CompanyDetailsClient {
private val details: MutableMap<Company, CompanyDetails> = details.toMutableMap()
var calls = 0
private set
override suspend fun fetchDetails(company: Company): CompanyDetails {
calls++
delay(delay)
return details[company] ?: error("Company not found")
}
fun setDetails(company: Company, details: CompanyDetails) {
this.details[company] = details
}
fun clear() {
details.clear()
delay = 0
}
}
Marcin Moskala is a highly experienced developer and Kotlin instructor as the founder of Kt. Academy, an official JetBrains partner specializing in Kotlin training, Google Developers Expert, known for his significant contributions to the Kotlin community. Moskala is the author of several widely recognized books, including "Effective Kotlin," "Kotlin Coroutines," "Functional Kotlin," "Advanced Kotlin," "Kotlin Essentials," and "Android Development with Kotlin."
Beyond his literary achievements, Moskala is the author of the largest Medium publication dedicated to Kotlin. As a respected speaker, he has been invited to share his insights at numerous programming conferences, including events such as Droidcon and the prestigious Kotlin Conf, the premier conference dedicated to the Kotlin programming language.