OOD / 06
Design a chat server
Users join rooms, send messages, see who's online. The OOD version stays in-memory (the system-design version is in the playbook). What the interview tests: separating User from Session, picking the right pattern for "deliver this to N subscribers," and being honest about what concurrency the design needs.
1 · Clarifying questions
| 1:1 or rooms? | Both. Direct messages are a room with two participants. |
| Persistence? | In-memory for the OOD round; mention that pluggable persistence is a clean extension. |
| Delivery semantics? | At-least-once. The receiver dedupes by message_id. |
| Presence? | Yes — show online/offline. Last-seen timestamp on disconnect. |
| Multiple sessions per user? | Yes (phone + laptop). The User is one; Sessions are many. Messages fan out to all of a user's sessions. |
| Search / history? | Out of scope for the in-memory cut; history would push to persistent storage. |
2 · Entities
User— identity, list of rooms, current sessions.Session— a single connected device. Holds a reference to a transport (in real life: a WebSocket).Room— name, list of members, message log (recent N).Message— id, sender, room, timestamp, body.ChatServer— the top-level. Maps user_id → User, room_id → Room. Handles join, send, presence broadcast.
3 · Class sketch (Observer / Publish-subscribe)
class User:
id: str
display_name: str
rooms: set[Room]
sessions: set[Session]
last_seen: datetime
def online() -> bool: return bool(self.sessions)
class Session:
id: str
user: User
transport: Transport # WebSocket, SSE, whatever
def deliver(message): self.transport.send(message)
class Room:
id: str
name: str
members: set[User]
recent: deque[Message] # bounded; full history goes to storage
def join(user):
self.members.add(user)
user.rooms.add(self)
def leave(user):
self.members.discard(user)
user.rooms.discard(self)
def publish(message):
self.recent.append(message)
for u in self.members:
for s in u.sessions:
if s.user != message.sender: # don't echo to sender's own device twice
s.deliver(message)
class Message:
id: str # ULID or UUID
sender: User
room: Room
body: str
sent_at: datetime
class ChatServer:
users: dict[str, User]
rooms: dict[str, Room]
def connect(user_id, transport) -> Session:
user = self.users.setdefault(user_id, User(...))
session = Session(uuid4(), user, transport)
user.sessions.add(session)
self._broadcast_presence(user, online=True)
return session
def disconnect(session):
session.user.sessions.discard(session)
if not session.user.online():
session.user.last_seen = now()
self._broadcast_presence(session.user, online=False)
def send(session, room_id, body):
msg = Message(uuid4(), session.user, self.rooms[room_id], body, now())
self.rooms[room_id].publish(msg)Room.publish is the Observer-pattern hot spot — the room knows its members, the members know their sessions, the sessions write to their transport. Coupling stays local.
4 · Edge cases
- Sender sees their own message. Usually yes (acknowledgement of "sent"). The transport delivers it back; the UI swaps in the server-confirmed message_id.
- Slow consumer. One client's transport is backed up. Don't block the room's broadcast loop on it — bounded send buffer per session, drop or disconnect if full.
- User disconnects mid-publish. The session reference may be stale by the time you deliver. Catch the transport error, mark session dead, continue with the rest.
- Join while a message is in flight. The new joiner shouldn't see the message — they weren't a member when it was published. The recent-log lets them see history afterwards.
- Duplicate session. Same device reconnects after a network blip. Old session is detected dead by ping/pong, dropped; new session takes over.
5 · Concurrency
Two design choices: locks per room, or actor model.
- Lock per room. Joins, leaves, publishes serialise within a room. Different rooms run concurrently. Simple; works at small/medium scale.
- Actor per room. Each room is a goroutine / actor; all messages to it go through a mailbox. No locks; ordering is total within the room. Scales well; harder to reason about across rooms.
Pick the actor model if the room count is moderate (thousands) and each room is busy. Pick locks per room if you have many quiet rooms.
6 · Extensions
- Persistence. The publish path also writes the message to a store (Postgres for ordering, S3 for archive). Reads come from store + recent in-memory log.
- Typing indicators. Lighter publish — sender-only, no persistence, time-decayed. Same Room.publish fan-out, different message type.
- Read receipts. Each user tracks "last read message_id per room." Update on every visible message; broadcast to other members.
- End-to-end encryption. Server sees ciphertext only. The recent-log still works; search is gone unless the client maintains its own index.
- Federation. Rooms can span servers (Matrix-style). Messages flow over a server-to-server protocol.
7 · What gets graded
- Did you separate User and Session?
- Did you locate the fan-out inside Room (not inside ChatServer)?
- Did you mention the slow-consumer problem and bounded buffers?
- Did you discuss the concurrency model unprompted?
Found this useful?