Design Stack Overflow
Questions, answers, votes, comments, tags and reputation rules.
- From requirements to classes — Nouns, verbs and the use-case walk that finds the classes.
- Relationships and multiplicity — Association, aggregation, composition, and the numbers on the lines.
- Observer and event listeners — Telling interested objects something happened, without knowing who they are.
- Specification and rule objects — Business rules as composable objects you can and, or and not.
- Law of Demeter and tell-don't-ask — Talk to your neighbours, not your neighbours' insides.
01The design, in the six-step framework
1. Requirements and use cases
Stack Overflow is a modelling exercise: many nouns, a few rules, and one derived number (reputation) that must stay consistent. Start with the actors and resist drawing every feature.
- A member asks a question with tags, and answers questions.
- A member comments on questions and answers.
- A member votes up or down, once per post, and may change that vote.
- The question's asker accepts one answer.
- Votes and acceptance change reputation, and reputation gates privileges such as downvoting.
- A moderator closes a question; a closed question takes no new answers.
- A visitor searches by tag.
Out of scope: full-text search, badges in depth, edit history, moderation queues. Non-functional constraints: one vote per member per post; reputation must be explainable from its events; a new privilege rule must not touch posting code.
2. Core entities and relationships
- Member: id, name, reputation.
- Post: the shared base of Question and Answer: author, body, score, comments, votes.
- Question: title, tags, answers, accepted answer, status.
- Answer: belongs to one question.
- Comment: text on a post; has no votes here.
- Vote: a member's direction on a post.
- Tag: a value used for grouping.
- Privilege: a rule checking whether a member may act.
- ReputationPolicy: an observer turning events into reputation changes.
- QuestionService: the use cases.
Inheritance is fair here: a question and an answer really are both votable, commentable posts, and either substitutes for Post in vote. A question composes its answers, so deleting it deletes them. Members are aggregated: they outlive every post.
3. Interfaces and key methods
class Privilege(Protocol):
def allows(self, member: Member) -> bool: ...
class Post:
def cast(self, member: Member, value: int) -> int:
"""Record +1 or -1, replacing any earlier vote; returns the change in score."""
def score(self) -> int: ...
def add_comment(self, author: Member, text: str) -> Comment: ...
class ReputationPolicy:
def on_event(self, event: Event) -> None:
"""Translate an event into a reputation delta for the right member."""
class QuestionService:
def ask(self, member: Member, title: str, body: str, tags: set[str]) -> Question: ...
def answer(self, member: Member, question: Question, body: str) -> Answer: ...
def vote(self, member: Member, post: Post, value: int) -> None:
"""Refuse self-votes; check the privilege; emit an event with the delta."""
def accept(self, member: Member, answer: Answer) -> None:
"""Only the asker; replaces any earlier accepted answer."""
def by_tag(self, tag: str) -> list[Question]: ...
Invariants: each member has at most one entry in a post's votes; score is always the sum of votes; a closed question gains no answers; a question's accepted is one of its own answers.
4. Design patterns and the choices behind them
Reputation as a reaction, not a side effect
If vote edits reputation directly, then every future reputation rule edits voting code. Instead the service emits an event and a ReputationPolicy reacts: an observer. Badges and notifications subscribe the same way. Changing a vote emits the difference, so flipping from down to up gives the author the right total.
Privileges as specifications
"Needs 125 reputation to downvote" and "moderators may close" are small rules. A specification object per rule, checked by the service, keeps thresholds in one place and composes with and/or. Hard-coded numbers inside vote scatter policy across the code.
Votes in a dictionary on the post
One vote per member per post is an invariant, so a map from member id to value expresses it directly, and score is a sum or a maintained counter. The multiplicity does the work; a separate Vote table scanned for duplicates would not.
Tell, don't ask
The service says post.cast(...); it does not read post.votes and rewrite it. That is the law of Demeter, and it keeps each invariant enforced in one class. The noun list is long, so name what you will skip at the start.
5. Core flow and code
voterefuses self-votes and checks the downvote privilege when the value is negative.post.castreplaces any previous vote and returns the score change.- The service publishes a
VoteChangedevent with the delta;ReputationPolicycredits the author. acceptchecks the asker, clears any previous acceptance through the policy, sets the new one and emitsAnswerAccepted.
from __future__ import annotations
import itertools
from dataclasses import dataclass, field
from typing import Callable, Optional, Protocol
class NotAllowed(Exception): ...
@dataclass
class Member:
id: str
reputation: int = 1
is_moderator: bool = False
@dataclass(frozen=True)
class Comment:
author: Member
text: str
class Post:
def __init__(self, post_id: str, author: Member, body: str) -> None:
self.id, self.author, self.body = post_id, author, body
self.votes: dict[str, int] = {}
self.comments: list[Comment] = []
def cast(self, member: Member, value: int) -> int:
if value not in (1, -1):
raise ValueError("vote must be +1 or -1")
old = self.votes.get(member.id, 0)
self.votes[member.id] = value
return value - old
def score(self) -> int:
return sum(self.votes.values())
def add_comment(self, author: Member, text: str) -> Comment:
c = Comment(author, text)
self.comments.append(c)
return c
class Answer(Post):
def __init__(self, post_id: str, author: Member, body: str, question: Question) -> None:
super().__init__(post_id, author, body)
self.question = question
class Question(Post):
def __init__(self, post_id: str, author: Member, title: str, body: str, tags: set[str]) -> None:
super().__init__(post_id, author, body)
self.title, self.tags = title, tags
self.answers: list[Answer] = []
self.accepted: Optional[Answer] = None
self.closed = False
@dataclass(frozen=True)
class VoteChanged:
post: Post
delta: int
@dataclass(frozen=True)
class AnswerAccepted:
answer: Answer
previous: Optional[Answer]
class Privilege(Protocol):
def allows(self, member: Member) -> bool: ...
@dataclass(frozen=True)
class MinReputation:
points: int
def allows(self, member: Member) -> bool: return member.reputation >= self.points
class ModeratorOnly:
def allows(self, member: Member) -> bool: return member.is_moderator
class ReputationPolicy:
UP, DOWN, ACCEPT = 10, 2, 15
def on_event(self, event: object) -> None:
if isinstance(event, VoteChanged):
# a flip from -1 to +1 has delta 2: refund the penalty, add the gain
d = event.delta
if d > 0:
gain = self.UP + (self.DOWN if d == 2 else 0)
else:
gain = -self.DOWN - (self.UP if d == -2 else 0)
event.post.author.reputation += gain
elif isinstance(event, AnswerAccepted):
event.answer.author.reputation += self.ACCEPT
if event.previous:
event.previous.author.reputation -= self.ACCEPT
class QuestionService:
def __init__(self, downvote: Privilege, close: Privilege) -> None:
self.downvote, self.close_rule = downvote, close
self.questions: dict[str, Question] = {}
self.tag_index: dict[str, list[Question]] = {}
self.listeners: list[Callable[[object], None]] = []
self._ids = itertools.count(1)
def _emit(self, event: object) -> None:
for fn in self.listeners:
fn(event)
def ask(self, member: Member, title: str, body: str, tags: set[str]) -> Question:
q = Question(f"q{next(self._ids)}", member, title, body, tags)
self.questions[q.id] = q
for t in tags:
self.tag_index.setdefault(t, []).append(q)
return q
def answer(self, member: Member, question: Question, body: str) -> Answer:
if question.closed:
raise NotAllowed("question is closed")
a = Answer(f"a{next(self._ids)}", member, body, question)
question.answers.append(a)
return a
def vote(self, member: Member, post: Post, value: int) -> None:
if member.id == post.author.id:
raise NotAllowed("cannot vote on your own post")
if value < 0 and not self.downvote.allows(member):
raise NotAllowed("not enough reputation to downvote")
delta = post.cast(member, value)
if delta:
self._emit(VoteChanged(post, delta))
def accept(self, member: Member, answer: Answer) -> None:
q = answer.question
if member.id != q.author.id:
raise NotAllowed("only the asker can accept")
previous, q.accepted = q.accepted, answer
if previous is not answer:
self._emit(AnswerAccepted(answer, previous))
def close(self, member: Member, question: Question) -> None:
if not self.close_rule.allows(member):
raise NotAllowed("not allowed to close")
question.closed = True
def by_tag(self, tag: str) -> list[Question]:
return list(self.tag_index.get(tag, []))
6. Extensions, concurrency and what interviewers probe
- Vote changes: reversing a vote must undo its effect, not add to it. The delta from
castis what makes reputation correct, and interviewers often test this exact case. - Bounties and badges are more listeners on the same events; no posting code changes.
- Commenting on votes: if comments need votes, move voting into a
Votablemixin rather than makingCommentaPost, which would falsely give comments answers and tags. - Concurrency: two members voting on one post race on
votesand on the author's reputation. A lock per post covers the first; reputation should be a separate atomic add. Per-post locks must not nest with member locks, or order them. - Search: the tag index is the only search here. Text search is an inverted index kept up to date by another listener.
- Test first: voting up twice changes the score once; flipping from down to up nets the author 12; a self-vote raises; accepting a second answer moves the 15 points; an answer on a closed question raises.
- Where to stop: edits, flags and review queues are real features, and each is one more class around
Post. In a 45-minute round, say that they exist, show where they would attach, and spend the time on voting and reputation, because those carry the invariants. - Common mistake: storing reputation changes inline in five methods, after which no one can explain a member's total.
02Where this question was reported
- Educative · Grokking LLD chapter 20
- DesignGurus · DesignGurus case study
- GitHub · awesome-low-level-design, Easy
- blog.algomaster.io · AlgoMaster worked example for its 5-step method (questions, answers, votes, reputation)
- YouTube · Video: 'Stack Overflow Low Level Design Google Interview Question Solved' (uploader's label)