Solution: CompanyDetailsRepository

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 ) { 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, ) { 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.

getDetailgetDetailOrNullgetReadyDetails
Synchronization block, mutable map624417
Synchronization block, read-only map211
Mutex, mutable map1134193
Mutex, read-only map211
Single thread dispatcher, mutable map1957925
Single thread dispatcher, read-only map1211
ConcurrentHashMap211260
Suspended lazy map223159
Cache214970

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 } }