There’s a concept in HCI called an interface metaphor.
It’s when you design something in code that mimics how things work in real life,
so people don’t have to re-learn everything from scratch
A name and Unique ID . (Most houses don’t, but this one needs to.).
An owner or overseer. Someone who started the space and carries a small sense of responsibility for it
Members. Those who share the space.
A key — in this case, an invite code. Something readable, not just a long random hash.
And finally, a timestamp — the moment the space came into existence.
That mental model turned into code. The ownerId points to the user who created the room. The memberIds list contains all participants. The inviteCode works like a key to get in , generated to be short and human-friendly.
Technical Architecture — Room Entity Implementation
1. Entity Definition
Collection: rooms
Fields:
id (String, primary key, auto-generated by MongoDB)
ownerId (String, references users.id)
memberIds (List <%String>, references users.id)
inviteCode (String, unique, 6-digits)
createdAt (Instant, auto-generated)
2. Invite Code Utility
fun generateInviteCode(length: Int = 6): String {
val chars = ('a'..'z') + ('0'..'9')
return (1..length).map { chars.random() }.joinToString("")
}
3. Repository Layer
Generates a random alphanumeric code of specified length.
Designed for readability and shareability (avoids special characters or uppercase).
interface RoomRepository : MongoRepository {
fun findByInviteCode(inviteCode: String): Room?
}
Extends MongoRepository<%Room, String>.
Adds findByInviteCode() for lookup during join operations.
4. Service Layer — Join Room Flow
joinRoom(userId: String, inviteCode: String)
- Fetch room via findByInviteCode(inviteCode).
- If no room found → throw RoomNotFoundException.
- Append userId to memberIds.
- Save updated room.
- Save updated room.
After testing, I realized this wasn’t the smartest approach.
Keeping a memberIds array in the Room collection meant updating two collections for
every join operation(tight coupling). It was simpler
and more consistent to just give each User a roomId field and query membership from there.
Problem With This Design
Fetch room via findByInviteCode(inviteCode).
Tight Coupling: Membership state lived inside both Room and User, forcing
the system to maintain consistency across two collections.
Dual Writes: Any join operation required updating both entities in sequence, introducing the
possibility of write skew or partial updates if one write failed.
SData Redundancy: Membership info was
duplicated, violating single source of truth principles.
Query Complexity: Determining membership from the Room side meant scanning
embedded arrays, which becomes inefficient as membership scales
fun joinRoom(userId: String, joinCode: String): UserResponse {
val room = roomRepository.findByCode(joinCode)
?: throw NotFoundException("Room with code $joinCode not found")
val user = userRepository.findById(ObjectId(userId))
.orElseThrow { NotFoundException("User not found") }
// already in a room?
if (user.roomId != null) {
throw AlreadyExistsException("User is already in a room")
}
// get users already in the room
val usersInRoom = userRepository.findByRoomId(room.id!!)
val roleToAssign = if (usersInRoom.isEmpty()) Role.OWNER else Role.MEMBER
val updated = user.copy(
roomId = room.id,
role = roleToAssign
)
return userRepository.save(updated).toDTO()
}
6. Security Considerations
Invite codes are unique and short-lived in future versions (TTL index).
Only authenticated users can create or join rooms
Owner field is immutable post-creation.
Next step is to implement all the controller endpoints