Chat Room
Users talk to the room, not to each other. The Mediator routes every message, so adding group chats, moderation, or message filtering never touches the User class.
Key Abstractions
Mediator that routes all messages between users and manages room membership
Colleague that sends and receives messages only through the ChatRoom mediator
Immutable record with sender, content, timestamp, and optional target for DMs
Interface notified of room events like user joins, leaves, and new messages
Pluggable filter for profanity, spam, or content moderation in the mediator pipeline
Class Diagram
The Key Insight
In a naive chat implementation, every user holds a list of other users. Alice wants to message Bob? She calls bob.receive() directly. Now add Charlie, Dave, and 50 more people. Each user maintains references to every other user. That is O(n^2) coupling, and it falls apart the moment someone joins or leaves.
The Mediator pattern flips this. Users know about exactly one object: the ChatRoom. When Alice sends a message, she calls chatRoom.sendMessage(). The room decides who gets it. Alice has no idea who is in the room, how many people there are, or whether anyone is even online. She just talks to the room.
This is powerful because every new feature becomes a change to one class. Want DMs? Add routing logic in ChatRoom. Want profanity filtering? Plug a filter into ChatRoom. Want to log every message? Attach an observer to ChatRoom. The User class never changes. It just sends and receives.
Requirements
Functional
- Users join and leave named chat rooms
- Any user can broadcast a message to all other users in the room
- Users can send direct messages to a specific user in the same room
- Room-level events (user joined, user left, new message) notify all registered observers
- The room maintains a full message history that survives users disconnecting
Non-Functional
- Adding new features (filters, logging, message types) should not require modifying User
- Disconnected or absent users should not cause errors during message delivery
- Message objects should be immutable once created
- The design should support pluggable content filters without altering the routing logic
Design Decisions
Why Mediator over direct user-to-user communication?
With 10 users in a room, direct communication means each user holds references to 9 others. That is 90 directed connections. Now someone leaves. Every remaining user needs to clean up their reference to that person. Add moderation logic? You are editing 10 different classes.
With a mediator, the connection count drops to 10. Each user holds one reference to the ChatRoom. The room holds a map of users. Join? Add to the map. Leave? Remove from the map. Moderation? One class. The coupling is linear, not quadratic.
Why Observer for room events but not for messages?
Messages need routing decisions. A broadcast goes to everyone except the sender. A DM goes to exactly one person. That is mediation logic with specific rules about who receives what.
Room events are different. "Alice joined" is a notification. There is no routing decision. Everyone who cares should hear about it. That is the textbook Observer use case. Keeping these two concerns separate means you can change how events are delivered (add a webhook observer, a metrics collector) without touching message routing at all.
Why is Message immutable?
If a message object is mutable, someone could alter its content after delivery. Bob receives "Hello" and then the sender changes it to "Goodbye." Now what Bob received and what the history shows are different things. That is a debugging nightmare.
Immutable messages guarantee consistency. What was sent is what was received is what the history stores. If you need edit functionality, create a new Message with an "edited" flag and the original message ID. The history stays clean.
Why store history in ChatRoom, not in User?
Users are transient. They connect, chat, disconnect, maybe reconnect later. If messages live in User objects, that history vanishes when the user goes offline. Worse, the same conversation is fragmented across multiple User instances with no single source of truth.
The room owns the conversation. It was happening before any particular user joined, and it continues after they leave. Storing history in the mediator gives you search, pagination, and audit trails in one place.
Interview Follow-ups
- "How would you add message persistence?" Swap the in-memory list in ChatRoom for a repository interface backed by a database. The mediator calls repository.save(message) before routing. Users and observers stay unchanged.
- "How would you support multiple rooms per user?" Each User holds a map of room references instead of a single one. The send() method takes a room name parameter. The mediator pattern still applies per room.
- "How would you handle message ordering in a distributed setup?" Use a centralized sequence generator or logical clocks (Lamport timestamps) so messages across multiple server instances have a consistent total order.
- "What if the room has 10,000 users and broadcast is too slow?" Partition delivery across worker threads or a message queue. The mediator pushes messages to a queue, and worker consumers handle batched delivery. The User interface stays the same.
Code Implementation
1 from abc import ABC, abstractmethod
2 from dataclasses import dataclass, field
3 from datetime import datetime
4 from typing import Optional
5
6
7 @dataclass(frozen=True)
8 class Message:
9 """Immutable message. Once sent, it cannot be altered."""
10 sender_name: str
11 content: str
12 timestamp: datetime = field(default_factory=datetime.now)
13 target_user: Optional[str] = None
14
15 def is_broadcast(self) -> bool:
16 return self.target_user is None
17
18 def __str__(self) -> str:
19 if self.target_user:
20 return f"[DM {self.sender_name} -> {self.target_user}] {self.content}"
21 return f"[{self.sender_name}] {self.content}"
22
23
24 class ChatObserver(ABC):
25 """Notified when room-level events happen. Not for message routing."""
26
27 @abstractmethod
28 def on_event(self, event_type: str, data: str) -> None: ...
29
30
31 class MessageFilter(ABC):
32 """Pluggable content filter. The mediator runs messages through these
33 before routing. Add profanity checks, spam detection, whatever you need."""
34
35 @abstractmethod
36 def filter(self, content: str) -> str: ...
37
38
39 class ConsoleLogger(ChatObserver):
40 """Logs room events to stdout. Swap this for a database logger,
41 a webhook notifier, or anything else without touching ChatRoom."""
42
43 def on_event(self, event_type: str, data: str) -> None:
44 print(f" [LOG] {event_type}: {data}")
45
46
47 class User:
48 """A colleague in the Mediator pattern. Knows about the room,
49 knows nothing about other users. That's the whole point."""
50
51 def __init__(self, name: str):
52 self._name = name
53 self._chat_room: Optional["ChatRoom"] = None
54
55 @property
56 def name(self) -> str:
57 return self._name
58
59 @property
60 def chat_room(self) -> Optional["ChatRoom"]:
61 return self._chat_room
62
63 @chat_room.setter
64 def chat_room(self, room: "ChatRoom") -> None:
65 self._chat_room = room
66
67 def send(self, content: str) -> None:
68 if self._chat_room is None:
69 raise RuntimeError(f"{self._name} is not in any room")
70 self._chat_room.send_message(self._name, content)
71
72 def send_dm(self, target_name: str, content: str) -> None:
73 if self._chat_room is None:
74 raise RuntimeError(f"{self._name} is not in any room")
75 self._chat_room.send_direct_message(self._name, target_name, content)
76
77 def receive(self, message: Message) -> None:
78 print(f" {self._name} sees: {message}")
79
80
81 class ChatRoom:
82 """The mediator. Every message flows through here. Users never talk
83 to each other directly. Want to add moderation? Change this class.
84 Want to add message filtering? Change this class. Users stay clean."""
85
86 def __init__(self, name: str):
87 self._name = name
88 self._users: dict[str, User] = {}
89 self._message_history: list[Message] = []
90 self._observers: list[ChatObserver] = []
91 self._filters: list[MessageFilter] = []
92
93 @property
94 def name(self) -> str:
95 return self._name
96
97 def add_observer(self, observer: ChatObserver) -> None:
98 self._observers.append(observer)
99
100 def add_filter(self, msg_filter: MessageFilter) -> None:
101 self._filters.append(msg_filter)
102
103 def _notify_observers(self, event_type: str, data: str) -> None:
104 for observer in self._observers:
105 observer.on_event(event_type, data)
106
107 def _apply_filters(self, content: str) -> str:
108 for f in self._filters:
109 content = f.filter(content)
110 return content
111
112 def add_user(self, user: User) -> None:
113 if user.name in self._users:
114 return
115 self._users[user.name] = user
116 user.chat_room = self
117 self._notify_observers("USER_JOINED", f"{user.name} joined {self._name}")
118
119 def remove_user(self, user_name: str) -> None:
120 user = self._users.pop(user_name, None)
121 if user is None:
122 return
123 self._notify_observers("USER_LEFT", f"{user_name} left {self._name}")
124
125 def send_message(self, sender_name: str, content: str) -> None:
126 if sender_name not in self._users:
127 raise ValueError(f"{sender_name} is not in room {self._name}")
128
129 content = self._apply_filters(content)
130 message = Message(sender_name=sender_name, content=content)
131 self._message_history.append(message)
132 self._notify_observers("MESSAGE", f"{sender_name}: {content}")
133
134 for name, user in self._users.items():
135 if name != sender_name:
136 user.receive(message)
137
138 def send_direct_message(self, sender_name: str, target_name: str, content: str) -> None:
139 if sender_name not in self._users:
140 raise ValueError(f"{sender_name} is not in room {self._name}")
141 target = self._users.get(target_name)
142 if target is None:
143 print(f" [SYSTEM] {target_name} is not in the room. DM not delivered.")
144 return
145
146 content = self._apply_filters(content)
147 message = Message(sender_name=sender_name, content=content, target_user=target_name)
148 self._message_history.append(message)
149 target.receive(message)
150
151 def get_history(self) -> list[Message]:
152 return list(self._message_history)
153
154
155 if __name__ == "__main__":
156 # Set up room and logger
157 room = ChatRoom("engineering")
158 logger = ConsoleLogger()
159 room.add_observer(logger)
160
161 # Create users
162 alice = User("Alice")
163 bob = User("Bob")
164 charlie = User("Charlie")
165
166 # Users join the room
167 print("=== Users joining ===")
168 room.add_user(alice)
169 room.add_user(bob)
170 room.add_user(charlie)
171
172 # Broadcast messages
173 print("\n=== Broadcast messages ===")
174 alice.send("Hey everyone, standup in 5 minutes")
175 bob.send("Got it, thanks Alice")
176
177 # Direct message
178 print("\n=== Direct message ===")
179 alice.send_dm("Charlie", "Can you review my PR before standup?")
180
181 # User leaves
182 print("\n=== Charlie leaves ===")
183 room.remove_user("Charlie")
184
185 # Message after Charlie left
186 print("\n=== Message after Charlie left ===")
187 bob.send("Looks like Charlie dropped off")
188
189 # DM to disconnected user
190 print("\n=== DM to disconnected user ===")
191 alice.send_dm("Charlie", "Are you still there?")
192
193 # Check message history
194 print("\n=== Message history ===")
195 for msg in room.get_history():
196 print(f" {msg}")
197
198 print(f"\nTotal messages in history: {len(room.get_history())}")
199 assert len(room.get_history()) == 4 # DM to offline user was not stored
200 assert room.get_history()[0].is_broadcast() is True
201 assert room.get_history()[2].is_broadcast() is False
202 assert room.get_history()[2].target_user == "Charlie"
203 print("All assertions passed.")Common Mistakes
- ✗Letting users reference each other directly. That creates O(n^2) coupling and breaks when users join or leave.
- ✗Mixing message routing with event notification. Mediator routes messages. Observer broadcasts events. Keep them separate.
- ✗Not handling disconnected users. The mediator should silently skip offline users, not throw errors.
- ✗Storing messages in User objects. Messages belong to the room. Users come and go, but chat history persists.
Key Points
- ✓Users never hold references to other users. All communication goes through the ChatRoom mediator.
- ✓Adding DMs, group chats, or broadcast is a change inside ChatRoom only. User stays untouched.
- ✓Observer handles room-level events (joins, leaves). Mediator handles message routing. Different concerns.
- ✓Message history lives in the mediator, giving you search and pagination without touching User.