Solution using a synchronized block and a read-only set:
class InMemoryUserRepository {
private var users = setOf<User>()
private val lock = Any()
fun addUser(user: User) = synchronized(lock) {
users += user
}
fun getUsers() = users
fun hasUser(user: User): Boolean = user in users
fun changeSurname(
userId: Int,
newSurname: String
) = synchronized(lock) {
val oldUser = users
.find { it.id == userId }
?: return@synchronized
val newUser = oldUser.copy(surname = newSurname)
users = users - oldUser + newUser
}
fun changeAllSurnames(
newSurname: String
) = synchronized(lock) {
users = users
.map { it.copy(surname = newSurname) }
.toSet()
}
data class User(
val id: Int,
val name: String,
val surname: String
)
}
Solution using a synchronized block and a mutable set:
class InMemoryUserRepository {
private val users = mutableSetOf<User>()
private val lock = Any()
fun addUser(user: User) = synchronized(lock) {
users += user
}
// Synchronization is needed here, because making a copy
// requires iterating over the whole set, and we don't
// want to have a concurrent modification exception
fun getUsers() = synchronized(lock) {
users.toSet()
}
fun hasUser(user: User): Boolean = synchronized(lock) {
user in users
}
fun changeSurname(
userId: Int,
newSurname: String
) = synchronized(lock) {
val oldUser = users
.find { it.id == userId }
?: return@synchronized
val newUser = oldUser.copy(surname = newSurname)
users -= oldUser
users += newUser
}
fun changeAllSurnames(
newSurname: String
) = synchronized(lock) {
val newUsers = users
.map { it.copy(surname = newSurname) }
users.clear()
users.addAll(newUsers)
}
data class User(
val id: Int,
val name: String,
val surname: String
)
}
This problem cannot be safely solved using ConcurrentHashMap because it does not allow compound operations like changeSurname or changeAllSurnames to be secured. It is possible to use ConcurrentHashMap for addUser and hasUser, but it is not possible to use it for getUsers because it does not allow iteration over the whole set.
class InMemoryUserRepository {
private val users = ConcurrentHashMap.newKeySet<User>()
fun addUser(user: User) {
users += user
}
fun getUsers() = users.toSet()
fun hasUser(user: User): Boolean = user in users
data class User(
val id: Int,
val name: String,
val surname: String
)
}
Example solution in playground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import java.lang.reflect.InvocationTargetException
import kotlin.reflect.full.memberFunctions
import kotlin.reflect.typeOf
import kotlin.test.*
class InMemoryUserRepository {
private var users = setOf<User>()
private val lock = Any()
fun addUser(user: User) = synchronized(lock) {
users += user
}
fun getUsers() = users
fun hasUser(user: User): Boolean = user in users
fun changeSurname(
userId: Int,
newSurname: String
) = synchronized(lock) {
val oldUser = users
.find { it.id == userId }
?: return@synchronized
val newUser = oldUser.copy(surname = newSurname)
users = users - oldUser + newUser
}
fun changeAllSurnames(
newSurname: String
) = synchronized(lock) {
users = users
.map { it.copy(surname = newSurname) }
.toSet()
}
data class User(
val id: Int,
val name: String,
val surname: String
)
}
class InMemoryNewsRepositoryTest {
lateinit var repo: InMemoryUserRepository
@Before
fun setup() {
repo = InMemoryUserRepository()
List(1000) { InMemoryUserRepository.User(it * 2, "Name$it", "Surname$it") }
.forEach { repo.addUser(it) }
}
@OptIn(ExperimentalStdlibApi::class)
@Test
fun `getUsers should not expose mutation point`() {
assertEquals(typeOf<Set<InMemoryUserRepository.User>>(), InMemoryUserRepository::getUsers.returnType)
}
@Test
fun `should allow concurrent user addition`(): Unit = runBlocking(Dispatchers.IO) {
val newUsers = List(1000) { InMemoryUserRepository.User(it * 2, "NewName$it", "NewSurname$it") }
coroutineScope {
for (newUser in newUsers) {
launch {
repo.addUser(newUser)
}
}
}
assertEquals(2000, repo.getUsers().size)
}
@Test
fun `should allow concurrent username change`(): Unit = runBlocking(Dispatchers.IO) {
val users = repo.getUsers()
coroutineScope {
for (user in users) {
launch {
repo.changeSurname(user.id, "NewSurname")
}
}
}
assertEquals(1000, repo.getUsers().size)
assertEquals(List(1000) { "NewSurname" }, repo.getUsers().map { it.surname })
}
@Test
fun `should not have user lost after surname change`() {
val users = repo.getUsers()
val newUsers = mutableListOf<InMemoryUserRepository.User>()
for (user in users) {
val newSurname = "NewSurname${user.id}"
repo.changeSurname(user.id, newSurname)
newUsers.add(user.copy(surname = newSurname))
}
for (user in newUsers) {
assertTrue(repo.hasUser(user))
}
}
@Test
fun `should allow parallel write and read`(): Unit = runBlocking(Dispatchers.IO) {
repeat(10) {
launch {
repeat(1000) {
repo.addUser(InMemoryUserRepository.User(it * 2 + 1, "NewUserName$it", "NewUserSurname$it"))
}
}
launch {
repeat(1000) {
val users = repo.getUsers()
assertTrue(users.count() >= 1000, "Problem with $users, size ${users.size}")
assertTrue(users.sumOf { it.id } > 500_000)
}
}
}
}
@Test
fun `When we set new surnames, they should all be the same`(): Unit = runBlocking(Dispatchers.IO) {
repo.changeAllSurnames("NewSurname")
launch {
repeat(1000) {
repo.changeAllSurnames("NewSurname$it")
}
}
launch {
repeat(1000) {
assertEquals(1, repo.getUsers().distinctBy { it.surname }.size)
}
}
}
}
class GettingUserTest {
@Test
fun `changeUserSurname when user cannot be found, proper error is displayed`() {
val repo = InMemoryUserRepository()
val exception = assertThrows(IllegalArgumentException::class.java) { repo.changeSurname(123, "XXX") }
assertEquals("No such user in the repository", exception.message, "Function has correct message")
}
@ExperimentalStdlibApi
@Test
fun `getById has correct signature`() {
val repoClass = InMemoryUserRepository::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getById" }
assertNotNull(method, "Method getById needs to be implemented")
assertFalse(method.returnType.isMarkedNullable, "Return type should not be nullable")
assertEquals(typeOf<InMemoryUserRepository.User>(), method.returnType, "Function should return User")
assertTrue(method.parameters.size == 2, "There is only a single expected argument (+ dispatch receiver)")
assertEquals(typeOf<InMemoryUserRepository>(), method.parameters[0].type, "Parameter type should be Int")
assertEquals(typeOf<Int>(), method.parameters[1].type, "Parameter type should be Int")
assertTrue(method.typeParameters.isEmpty(), "There should be no type parameters")
}
@Test
fun `getById works correctly`() {
val repo = InMemoryUserRepository()
val repoClass = repo::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getById" }
assertNotNull(method, "Method getById needs to be implemented")
val user = InMemoryUserRepository.User(10, "A", "B")
repo.addUser(user)
assertEquals(user, method.call(repo, user.id))
val exception =
assertThrows(InvocationTargetException::class.java) { // All errors should be wrapped into this type by reflection
method.call(repo, 0)
}.targetException // We unpack to get actual exception throw by this function
assertTrue(exception is NoSuchElementException, "The type of error should be NoSuchElementException")
assertEquals("No user with id 0", exception.message, "Check for concrete error message")
}
@ExperimentalStdlibApi
@Test
fun `getByIdOrNull has correct signature`() {
val repoClass = InMemoryUserRepository::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getByIdOrNull" }
assertNotNull(method, "Method getByIdOrNull needs to be implemented")
assertTrue(method.returnType.isMarkedNullable, "Return type should be nullable")
assertEquals(typeOf<InMemoryUserRepository.User?>(), method.returnType, "Function should return User")
assertTrue(method.parameters.size == 2, "There is only a single expected argument (+ dispatch receiver)")
assertEquals(typeOf<InMemoryUserRepository>(), method.parameters[0].type, "Parameter type should be Int")
assertEquals(typeOf<Int>(), method.parameters[1].type, "Parameter type should be Int")
assertTrue(method.typeParameters.isEmpty(), "There should be no type parameters")
}
@Test
fun `getByIdOrNull works correctly`() {
val repo = InMemoryUserRepository()
val repoClass = repo::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getByIdOrNull" }
assertNotNull(method, "Method getByIdOrNull needs to be implemented")
val user = InMemoryUserRepository.User(10, "A", "B")
repo.addUser(user)
assertEquals(user, method.call(repo, user.id))
val result = method.call(repo, 0)
assertNull(result, "Function should return null when no user with given id")
}
@ExperimentalStdlibApi
@Test
fun `getByIdOrDefault has correct signature`() {
val repoClass = InMemoryUserRepository::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getByIdOrDefault" }
assertNotNull(method, "Method getByIdOrDefault needs to be implemented")
assertFalse(method.returnType.isMarkedNullable, "Return type should not be nullable")
assertEquals(typeOf<InMemoryUserRepository.User>(), method.returnType, "Function should return User")
assertTrue(method.parameters.size == 3, "There are two parameters in this function (+ dispatch receiver)")
val (dispatchReceiver, param1, param2) = method.parameters
assertEquals(typeOf<InMemoryUserRepository>(), dispatchReceiver.type)
assertEquals(typeOf<Int>(), param1.type, "The type of the first parameter should be Int")
assertEquals(
typeOf<InMemoryUserRepository.User>(),
param2.type,
"The type of the second parameter should be User"
)
assertTrue(method.typeParameters.isEmpty(), "There should be no type parameters")
}
@Test
fun `getByIdOrDefault works correctly`() {
val repo = InMemoryUserRepository()
val repoClass = repo::class
val method = repoClass.memberFunctions.singleOrNull { it.name == "getByIdOrDefault" }
assertNotNull(method, "Method getByIdOrDefault needs to be implemented")
val default = InMemoryUserRepository.User(100, "C", "D")
val user = InMemoryUserRepository.User(10, "A", "B")
repo.addUser(user)
assertEquals(user, method.call(repo, user.id, default))
val result = method.call(repo, 0, default)
assertEquals(default, result, "Function should return null when no user with given id")
}
}
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.