Design Stack Overflow¶
TL;DR¶
- You build questions, answers and comments over one
Postbase, aVoteLedgerthat enforces one ballot per(voter, post), aReputationPolicythat turns ballots into points, and aSearchServicedriven by composable Specifications. - Three decisions carry the interview: the ledger owns both the ballot row and the post score (so they cannot drift), reputation is a policy object (so "make downvotes free" is a new class), and accept-answer is guarded by a per-question lock that also handles switching the accepted answer.
- Observer feeds the inbox and the badge awarder. Command is discussed and deliberately not used.
Problem statement¶
"Design the core of a question-and-answer site. Members ask questions with tags, others answer, everyone comments and votes, and votes move reputation. The asker can accept one answer. Members search by keyword and tag, high-reputation members can close questions, and everyone gets notified about activity on their own posts. Focus on the class model, the vote path, and what happens when two people click the same arrow at the same instant."
Requirements¶
Functional
- Members with a display name, a reputation score and badges.
- Questions with a title, a body and tags; answers attached to a question; comments attached to a question or an answer.
- Upvotes and downvotes on questions and answers; comments accept upvotes only. Nobody votes on their own post, and one member holds at most one ballot per post — casting the same ballot twice is a conflict, casting the opposite one switches it, and retracting removes it.
- Reputation rules driven by votes and accepts: an answer upvote is worth more than a question upvote, downvotes cost the author, downvoting an answer costs the voter, and accepting pays the answerer and the asker.
- Exactly one accepted answer per question, set only by the asker, movable to a different answer later.
- Search by keyword and by tag, with filters that combine (
tag AND (score >= 1 OR keyword)), and a pluggable result ranking. - A tag registry with usage counts; bounties as an optional reputation prize on a question.
- Close, reopen and delete a question behind a reputation privilege.
- Notifications for activity on your posts, and badges awarded automatically and at most once.
Non-functional and constraints
- Correct under concurrency: two clicks on the same arrow must produce one ballot, and a post's score must always equal the sum of its ballots.
- In-memory, single process; every store sits behind a
Repositoryso a SQL implementation is a constructor change. - Deterministic and testable: the clock and every ID generator are injected.
Out of scope: rendering and markdown, moderation queues and flags beyond close/delete, the real inverted index, reputation recalculation jobs, chat and review queues.
Clarifying questions and assumptions¶
| Question to ask | Assumption taken here |
|---|---|
| Is a comment a post? | Yes — same base class, but upvote-only and worth no reputation. That single override replaces a pile of isinstance checks. |
| What happens if I click the same arrow twice? | It is a DuplicateVoteError. Real sites toggle; an explicit error makes the uniqueness constraint testable, and retract_vote is the toggle. |
| Can the asker change the accepted answer? | Yes. The points move with the tick, and the asker's own +2 is paid only on the first accept. |
| Can reputation go negative? | No, it floors at 1. This makes reputation deliberately non-reversible, which is a real edge case, not a bug. |
| Who can close a question? | Anyone above the policy's reputation threshold (3000 by default), because the threshold belongs to the policy, not to the service. |
| Is search a real index? | No. KeywordIn scans title and body; the Specification interface is what an inverted index would slot behind. |
| Do we need bounties and badges now? | Badges yes (they show Observer paying rent); bounties are modelled as a value object and left unwired. |
Core entities and relationships¶
- Post (abstract) with
Question,AnswerandComment. The base owns id, author, body, score and the comment list; each subclass declares itspost_typeand the ballots it accepts. - Question — title, a frozen set of tag names, a
QuestionStatus, its answer ids and at most oneaccepted_answer_id. One question owns many answers (1 → *) and many comments. - Answer — the id of its question plus an
acceptedflag. Comment — the id of the post it hangs under. - User — display name, reputation (floored at 1) and the badges awarded so far.
- Vote — an immutable ballot: id, post, voter, type, time. The pair
(voter_id, post_id)is the unique key. VoteOutcome is what a cast returns: what was applied, what it replaced, the delta and the resulting score. - VoteLedger — every ballot, sharded by post id, and the only place a post's score changes.
- ReputationService — the reputation column plus the floor; ReputationPolicy — the rule that turns a
(post_type, vote_type)pair into points. - QnAService — the facade: validate, order the collaborators, publish events. SearchService — Specification plus ranking.
- PostEvent and PostListener — the observer channel;
InboxNotifierandBadgeAwarderare the two listeners.
Multiplicities: question 1 → * answers, post 1 → * comments, post 1 → * votes with at most one per voter, user 1 → * badges, question 1 → 0..1 accepted answer.
Class diagram¶
Structure: one Post base, three subclasses, and the value objects around them.
classDiagram
class Post {
<<abstract>>
+id: str
+author_id: str
+body: str
+score: int
+comment_ids: List~str~
+post_type PostType
+accepted_vote_types tuple
+assert_accepts(vote_type)
}
class Question {
+title: str
+tags: frozenset
+status: QuestionStatus
+accepted_answer_id: str
+answer_ids: List~str~
+accepts_new_posts() bool
}
class Answer {
+question_id: str
+accepted: bool
}
class Comment {
+parent_id: str
}
class User {
+id: str
+display_name: str
+reputation: int
+badges: List~Badge~
}
class Vote {
+id: str
+post_id: str
+voter_id: str
+type: VoteType
}
class VoteOutcome {
+vote: Vote
+previous: VoteType
+score_delta: int
+score: int
}
class Tag {
+name: str
+excerpt: str
}
class Badge {
+name: str
+tier: BadgeTier
}
class Bounty {
+amount: Money
+expires_at: float
}
class PostEvent {
+type: EventType
+post_id: str
+actor_id: str
+recipient_id: str
+value: int
}
class QuestionStatus {
<<enumeration>>
OPEN
ANSWERED
CLOSED
DELETED
}
class VoteType {
<<enumeration>>
UP
DOWN
}
Post <|-- Question
Post <|-- Answer
Post <|-- Comment
Question "1" o-- "many" Answer
Question --> QuestionStatus
Question "1" o-- "0..1" Bounty
Question ..> Tag : names
Vote --> VoteType
Vote --> Post : targets
VoteOutcome ..> Vote
User "1" o-- "many" Badge
User ..> PostEvent : receives
Behaviour: the facade, the three collaborators that hold locks, and the pluggable rules.
classDiagram
class QnAService {
+ask_question(author_id, title, body, tags) Question
+post_answer(question_id, author_id, body) Answer
+cast_vote(post_id, voter_id, vote_type) VoteOutcome
+retract_vote(post_id, voter_id) VoteOutcome
+accept_answer(question_id, answer_id, actor_id)
+close_question(question_id, moderator_id, reason)
+subscribe(listener)
}
class VoteLedger {
-_shards: tuple
-_locks: tuple
+cast(post, voter_id, vote_type) VoteOutcome
+retract(post, voter_id) VoteOutcome
+ballots_for(post_id) List~Vote~
}
class ReputationService {
-_lock: Lock
+award(user_id, delta) int
+require(user_id, needed, privilege)
}
class Repository {
+add(key, item)
+get(key)
+all() list
}
class SearchService {
+search(spec, ranking, limit) List~Question~
+by_tag(tag, limit) List~Question~
}
class PostListener {
<<interface>>
+on_event(event)
}
class InboxNotifier {
+messages(user_id) List~str~
}
class BadgeAwarder {
+on_event(event)
}
class ReputationPolicy {
<<interface>>
+author_delta(post_type, vote_type) int
+voter_delta(post_type, vote_type) int
+accept_delta() tuple
+close_threshold() int
}
class StackOverflowReputation
class FlatReputation
class RankingStrategy {
<<interface>>
+rank(questions) List~Question~
}
class HighestScoreRanking
class NewestFirstRanking
class MostAnsweredRanking
class QuestionSpec {
<<abstract>>
+is_satisfied_by(question) bool
+describe() str
}
class AndSpec
class TaggedWith
class KeywordIn
class MinScore
QnAService --> VoteLedger
QnAService --> ReputationService
QnAService --> Repository
QnAService --> "many" PostListener : notifies
ReputationService --> ReputationPolicy
SearchService --> QuestionSpec
SearchService --> RankingStrategy
PostListener <|.. InboxNotifier
PostListener <|.. BadgeAwarder
ReputationPolicy <|.. StackOverflowReputation
ReputationPolicy <|.. FlatReputation
RankingStrategy <|.. HighestScoreRanking
RankingStrategy <|.. NewestFirstRanking
RankingStrategy <|.. MostAnsweredRanking
QuestionSpec <|-- AndSpec
QuestionSpec <|-- TaggedWith
QuestionSpec <|-- KeywordIn
QuestionSpec <|-- MinScore
Design patterns applied¶
| Pattern | Where | Why it earns its place |
|---|---|---|
| Inheritance + polymorphism | Post → Question / Answer / Comment; accepted_vote_types |
The rule "comments cannot be downvoted" is one tuple on one subclass. No service ever asks what kind of post it holds. |
| Strategy | ReputationPolicy, RankingStrategy |
These are exactly the two rules an interviewer changes mid-round. FlatReputation proves the seam: swap it and every number on the site changes with no service edit. |
| Specification | QuestionSpec with &, |, ~ |
Search filters compose instead of accumulating parameters. TaggedWith("python") & (MinScore(1) | KeywordIn("profile")) is one object, and it can be printed or later translated to SQL. |
| Observer | PostListener → InboxNotifier, BadgeAwarder |
The service publishes a frozen PostEvent and knows nothing about inboxes or badges. Adding an email digest is a new listener. |
| Repository | Repository[T] |
One dict today, one table tomorrow. Services depend on the class, never on a dict literal. |
| Facade | QnAService |
Callers get ask_question / cast_vote / accept_answer and never touch the ledger, the policy or the event bus by hand. |
| Dependency injection | Clock, three IdGenerators, ReputationPolicy |
Tests run on a FakeClock with sequential ids, so every assertion is an exact number. |
| State (lightweight) | QuestionStatus with guarded transitions |
Four states and six transitions do not justify a class per state; enum plus guard clauses is the honest amount of machinery. |
What was deliberately not used: Command for vote-with-undo. It is the pattern people reach for, but the ledger already stores the previous ballot, and that row is a smaller, more durable undo record than a command log — retract reads it and negates it. Say when Command does pay: Design Cricinfo (live scoreboard) has to replay a whole innings after a correction, and there the log is the design. Naming both sides is the signal.
Key flows¶
Answer, notify, upvote, pay reputation, check badges — the chain the interviewer asks for.
sequenceDiagram
autonumber
actor R as Reader
participant S as QnAService
participant L as VoteLedger
participant P as ReputationPolicy
participant Rep as ReputationService
participant I as InboxNotifier
participant B as BadgeAwarder
R->>S: post_answer(q1, expert, body)
S->>S: question.accepts_new_posts()
S-)I: ANSWER_POSTED to the asker
R->>S: cast_vote(a1, reader, UP)
S->>S: reject self-vote, check accepted_vote_types
S->>L: cast(post, reader, UP)
alt no ballot for this pair
L->>L: shard lock, insert, score += 1
L-->>S: VoteOutcome(applied=UP, previous=None)
else same ballot already stored
L-->>S: DuplicateVoteError
end
S->>P: author_delta(ANSWER, UP)
P-->>S: +10
S->>Rep: award(expert, +10)
S-)I: POST_VOTED to the answer author
S-)B: POST_VOTED with score
B->>B: award Nice Answer once at score 10
Question lifecycle. ANSWERED is a status, not a boolean, because a closed question keeps its accepted answer and must come back to the right state when it is reopened.
stateDiagram-v2
[*] --> OPEN : ask_question
OPEN --> ANSWERED : accept_answer
ANSWERED --> ANSWERED : accept a different answer
OPEN --> CLOSED : close_question (needs reputation)
ANSWERED --> CLOSED : close_question
CLOSED --> OPEN : reopen with no accepted answer
CLOSED --> ANSWERED : reopen with an accepted answer
OPEN --> DELETED : delete_question
ANSWERED --> DELETED : delete_question
CLOSED --> DELETED : delete_question
DELETED --> [*]
Implementation¶
Write it in the order you would in the room: vocabulary, then the post hierarchy, then the rules, then the machinery that holds locks, and only then the facade.
The enums pin the vocabulary. VoteType.score_delta is the small move that stops +1/-1 from being scattered across the ledger:
class VoteType(StrEnum):
UP = "up"
DOWN = "down"
@property
def score_delta(self) -> int:
"""What one ballot of this type contributes to a post score."""
return 1 if self is VoteType.UP else -1
class PostType(StrEnum):
QUESTION = "question"
ANSWER = "answer"
COMMENT = "comment"
class QuestionStatus(StrEnum):
OPEN = "open" # accepting answers
ANSWERED = "answered" # has an accepted answer, still accepting more
CLOSED = "closed" # duplicate or off-topic: no new answers or comments
DELETED = "deleted" # hidden from search and from the front page
class BadgeTier(StrEnum):
BRONZE = "bronze"
SILVER = "silver"
GOLD = "gold"
class EventType(StrEnum):
ANSWER_POSTED = "answer_posted"
COMMENT_ADDED = "comment_added"
POST_VOTED = "post_voted"
ANSWER_ACCEPTED = "answer_accepted"
QUESTION_CLOSED = "question_closed"
Errors subclass the shared hierarchy, so an API layer can map ConflictError to HTTP 409 without knowing what a ballot is:
class PostNotFoundError(NotFoundError):
"""Unknown post, user or vote id."""
class SelfVoteError(ValidationError):
"""You cannot vote on your own post."""
class DuplicateVoteError(ConflictError):
"""The (voter, post) pair already holds a ballot of this type."""
class VoteNotAllowedError(ValidationError):
"""This post type does not accept this ballot (comments are upvote-only)."""
class QuestionStateError(InvalidStateError):
"""The question's status forbids the operation (answering a closed question)."""
class NotAuthorError(ValidationError):
"""Only the question's author may accept an answer."""
class InsufficientReputationError(ValidationError):
"""The actor lacks the reputation a privilege requires."""
The Post base is the heart of the model. Note two details worth saying out loud: the dataclasses are kw_only so subclasses can add required fields after the base's defaults, and Question.__post_init__ calls Post.__post_init__(self) explicitly because @dataclass(slots=True) rebuilds the class and breaks zero-argument super().
@dataclass(slots=True, kw_only=True)
class Post(ABC):
"""Everything a member writes. The base owns votable and commentable behaviour.
Subclasses declare *what kind* of post they are and *which ballots* they
accept; no service ever asks ``if isinstance(post, Comment)``.
"""
id: str
author_id: str
body: str
created_at: float
score: int = 0
comment_ids: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if not self.body.strip():
raise ValidationError("post body must be non-empty")
@property
@abstractmethod
def post_type(self) -> PostType:
"""Discriminator used by the reputation policy and the event stream."""
@property
def accepted_vote_types(self) -> tuple[VoteType, ...]:
"""Ballots this post accepts, overridden where the rules differ."""
return (VoteType.UP, VoteType.DOWN)
def assert_accepts(self, vote_type: VoteType) -> None:
if vote_type not in self.accepted_vote_types:
raise VoteNotAllowedError(f"a {self.post_type} cannot be {vote_type}voted")
def summary(self) -> str:
head = self.body.strip().splitlines()[0]
return head if len(head) <= 48 else head[:45] + "..."
@dataclass(slots=True, kw_only=True)
class Question(Post):
"""The root of a thread: title, tags, answers and one accepted answer."""
title: str
tags: frozenset[str] = frozenset()
status: QuestionStatus = QuestionStatus.OPEN
accepted_answer_id: str | None = None
answer_ids: list[str] = field(default_factory=list)
view_count: int = 0
post_type = PostType.QUESTION
def __post_init__(self) -> None:
# Explicit base call: @dataclass(slots=True) rebuilds the class, which breaks
# the zero-argument super() closure cell.
Post.__post_init__(self)
if not self.title.strip():
raise ValidationError("question title must be non-empty")
def accepts_new_posts(self) -> bool:
return self.status in (QuestionStatus.OPEN, QuestionStatus.ANSWERED)
@dataclass(slots=True, kw_only=True)
class Answer(Post):
question_id: str
accepted: bool = False
post_type = PostType.ANSWER
@dataclass(slots=True, kw_only=True)
class Comment(Post):
"""Attached to a question or an answer. Upvote-only, and never worth reputation."""
parent_id: str
post_type = PostType.COMMENT
accepted_vote_types = (VoteType.UP,)
Reputation is a Protocol quoting deltas per ballot, never per transition. That is what lets the service express a switch as "reverse the old one, apply the new one" without the policy knowing any history:
class ReputationPolicy(Protocol):
"""How ballots and accepts turn into reputation.
Deltas are quoted per ballot, never per transition: the service composes a
switch (up to down) as ``-delta(up) + delta(down)`` and a retraction as
``-delta(previous)``, so a policy never has to know about history.
"""
def author_delta(self, post_type: PostType, vote_type: VoteType) -> int:
"""Reputation the post's author gains from one ballot."""
...
def voter_delta(self, post_type: PostType, vote_type: VoteType) -> int:
"""Reputation the voter pays for casting one ballot."""
...
def accept_delta(self) -> tuple[int, int]:
"""``(answer author, accepter)`` reputation on the first accept."""
...
def close_threshold(self) -> int:
"""Reputation required to close or reopen a question."""
...
class StackOverflowReputation:
"""The published rules: +10 for a useful answer, -2 for a bad one, -1 to downvote."""
UPVOTE_ANSWER = 10
UPVOTE_QUESTION = 5
DOWNVOTE_AUTHOR = -2
DOWNVOTE_COST = -1
ACCEPT_ANSWER = 15
ACCEPT_GIVER = 2
CLOSE_PRIVILEGE = 3000
def author_delta(self, post_type: PostType, vote_type: VoteType) -> int:
if post_type is PostType.COMMENT:
return 0 # comment votes are cosmetic
if vote_type is VoteType.DOWN:
return self.DOWNVOTE_AUTHOR
return self.UPVOTE_ANSWER if post_type is PostType.ANSWER else self.UPVOTE_QUESTION
def voter_delta(self, post_type: PostType, vote_type: VoteType) -> int:
# Downvoting an answer costs you something; downvoting a question is free.
if vote_type is VoteType.DOWN and post_type is PostType.ANSWER:
return self.DOWNVOTE_COST
return 0
def accept_delta(self) -> tuple[int, int]:
return self.ACCEPT_ANSWER, self.ACCEPT_GIVER
def close_threshold(self) -> int:
return self.CLOSE_PRIVILEGE
class FlatReputation:
"""Every ballot is worth the same, votes are free, nobody needs a privilege.
The policy you hand a new community. It exists to prove the seam: swapping it
changes every number on the site and touches no service code.
"""
def __init__(self, points: int = 1) -> None:
self._points = points
def author_delta(self, post_type: PostType, vote_type: VoteType) -> int:
return self._points * vote_type.score_delta
def voter_delta(self, post_type: PostType, vote_type: VoteType) -> int:
return 0
def accept_delta(self) -> tuple[int, int]:
return self._points, 0
def close_threshold(self) -> int:
return 0
The Specification algebra is an ABC rather than a Protocol because &, | and ~ are shared behaviour every leaf should inherit for free:
class QuestionSpec(ABC):
"""A composable yes/no rule about one question.
An ``ABC`` rather than a ``Protocol`` because ``&``, ``|`` and ``~`` are
shared behaviour every leaf inherits for free. The filter is data: you can
print it, log it, or later translate it into SQL instead of evaluating it.
"""
@abstractmethod
def is_satisfied_by(self, question: Question) -> bool: ...
@abstractmethod
def describe(self) -> str: ...
def __and__(self, other: QuestionSpec) -> QuestionSpec:
return AndSpec(self, other)
def __or__(self, other: QuestionSpec) -> QuestionSpec:
return OrSpec(self, other)
def __invert__(self) -> QuestionSpec:
return NotSpec(self)
class AndSpec(QuestionSpec):
def __init__(self, left: QuestionSpec, right: QuestionSpec) -> None:
self._left, self._right = left, right
def is_satisfied_by(self, question: Question) -> bool:
return self._left.is_satisfied_by(question) and self._right.is_satisfied_by(question)
def describe(self) -> str:
return f"({self._left.describe()} AND {self._right.describe()})"
class OrSpec(QuestionSpec):
def __init__(self, left: QuestionSpec, right: QuestionSpec) -> None:
self._left, self._right = left, right
def is_satisfied_by(self, question: Question) -> bool:
return self._left.is_satisfied_by(question) or self._right.is_satisfied_by(question)
def describe(self) -> str:
return f"({self._left.describe()} OR {self._right.describe()})"
class NotSpec(QuestionSpec):
def __init__(self, inner: QuestionSpec) -> None:
self._inner = inner
def is_satisfied_by(self, question: Question) -> bool:
return not self._inner.is_satisfied_by(question)
def describe(self) -> str:
return f"NOT {self._inner.describe()}"
class TaggedWith(QuestionSpec):
def __init__(self, tag: str) -> None:
self._tag = tag.strip().lower()
def is_satisfied_by(self, question: Question) -> bool:
return self._tag in question.tags
def describe(self) -> str:
return f"tag={self._tag}"
class KeywordIn(QuestionSpec):
"""Case-insensitive substring match over title and body — the in-memory stand-in
for an inverted index. The Specification hides which one you use."""
def __init__(self, term: str) -> None:
self._term = term.strip().lower()
def is_satisfied_by(self, question: Question) -> bool:
return self._term in question.title.lower() or self._term in question.body.lower()
def describe(self) -> str:
return f"keyword={self._term!r}"
class MinScore(QuestionSpec):
def __init__(self, minimum: int) -> None:
self._minimum = minimum
def is_satisfied_by(self, question: Question) -> bool:
return question.score >= self._minimum
def describe(self) -> str:
return f"score>={self._minimum}"
class HasAcceptedAnswer(QuestionSpec):
def is_satisfied_by(self, question: Question) -> bool:
return question.accepted_answer_id is not None
def describe(self) -> str:
return "accepted=yes"
class AskedBy(QuestionSpec):
def __init__(self, author_id: str) -> None:
self._author_id = author_id
def is_satisfied_by(self, question: Question) -> bool:
return question.author_id == self._author_id
def describe(self) -> str:
return f"author={self._author_id}"
class StatusIs(QuestionSpec):
def __init__(self, status: QuestionStatus) -> None:
self._status = status
def is_satisfied_by(self, question: Question) -> bool:
return question.status is self._status
def describe(self) -> str:
return f"status={self._status}"
Now the piece the whole problem turns on. Ballots are sharded by post id, so every ballot for a post lands in exactly one shard and is only ever touched under that shard's lock — which means the row and post.score move together and can never drift:
class VoteLedger:
"""The (voter, post) uniqueness constraint, and the score it keeps in step.
Ballots are sharded by post id. Every ballot for a post lands in one shard
and is only ever touched while that shard's lock is held, so the row and
``post.score`` move together and two threads can never both insert the
"first" ballot for the same pair. Posts in different shards do not contend.
"""
SHARDS = 8
def __init__(self, clock: Clock, ids: IdGenerator) -> None:
self._clock = clock
self._ids = ids
self._shards: tuple[dict[tuple[str, str], Vote], ...] = tuple({} for _ in range(self.SHARDS))
self._locks = tuple(threading.Lock() for _ in range(self.SHARDS))
def _index(self, post_id: str) -> int:
return hash(post_id) % self.SHARDS
def cast(self, post: Post, voter_id: str, vote_type: VoteType) -> VoteOutcome:
"""Insert or switch a ballot. Casting the same type twice is a conflict."""
index = self._index(post.id)
key = (voter_id, post.id)
with self._locks[index]:
shard = self._shards[index]
previous = shard.get(key)
if previous is not None and previous.type is vote_type:
raise DuplicateVoteError(f"{voter_id} already {vote_type}voted {post.id}")
vote = Vote(
id=self._ids.next_id(),
post_id=post.id,
voter_id=voter_id,
type=vote_type,
created_at=self._clock.now(),
)
delta = vote_type.score_delta - (previous.type.score_delta if previous else 0)
shard[key] = vote
post.score += delta
return VoteOutcome(vote, previous.type if previous else None, delta, post.score)
def retract(self, post: Post, voter_id: str) -> VoteOutcome:
"""Undo a ballot. The stored row *is* the undo record; no command log needed."""
index = self._index(post.id)
key = (voter_id, post.id)
with self._locks[index]:
shard = self._shards[index]
previous = shard.pop(key, None)
if previous is None:
raise PostNotFoundError(f"{voter_id} has no ballot on {post.id}")
delta = -previous.type.score_delta
post.score += delta
return VoteOutcome(None, previous.type, delta, post.score)
def vote_of(self, post_id: str, voter_id: str) -> Vote | None:
index = self._index(post_id)
with self._locks[index]:
return self._shards[index].get((voter_id, post_id))
def ballots_for(self, post_id: str) -> list[Vote]:
index = self._index(post_id)
with self._locks[index]:
return [v for (_, pid), v in self._shards[index].items() if pid == post_id]
The reputation column gets its own lock and its own floor. The comment about lock order is the sentence to say in the interview:
class ReputationService:
"""Owns the reputation column and the floor under it.
Lock order rule: this lock is always taken *after* a ballot shard lock or a
question lock and never the other way round, so the two can never deadlock.
"""
MINIMUM = 1
def __init__(self, users: Repository[User], policy: ReputationPolicy) -> None:
self._users = users
self._policy = policy
self._lock = threading.Lock()
@property
def policy(self) -> ReputationPolicy:
return self._policy
def award(self, user_id: str, delta: int) -> int:
if delta == 0:
return self.reputation(user_id)
user = self._users.get(user_id)
with self._lock:
user.reputation = max(self.MINIMUM, user.reputation + delta)
return user.reputation
def reputation(self, user_id: str) -> int:
return self._users.get(user_id).reputation
def require(self, user_id: str, needed: int, privilege: str) -> None:
have = self.reputation(user_id)
if have < needed:
raise InsufficientReputationError(
f"{privilege} needs {needed} reputation, {user_id} has {have}"
)
The two observers are deliberately dull. BadgeAwarder keeps a set of (user, badge) so an event storm still awards each badge once:
class PostListener(Protocol):
"""Observer interface. Listeners are called outside every domain lock."""
def on_event(self, event: PostEvent) -> None: ...
class InboxNotifier:
"""The bell icon: one message list per recipient, newest last."""
def __init__(self) -> None:
self._lock = threading.Lock()
self._inbox: dict[str, list[str]] = {}
def on_event(self, event: PostEvent) -> None:
if event.actor_id == event.recipient_id:
return # you do not get notified about your own action
text = f"{event.type} on {event.post_id} by {event.actor_id}"
if event.detail:
text = f"{text}: {event.detail}"
with self._lock:
self._inbox.setdefault(event.recipient_id, []).append(text)
def messages(self, user_id: str) -> list[str]:
with self._lock:
return list(self._inbox.get(user_id, ()))
class UserLookup(Protocol):
"""The narrow slice of the facade a badge rule needs."""
def user(self, user_id: str) -> User: ...
class BadgeAwarder:
"""Awards a badge at most once per (user, badge), whatever the event storm."""
NICE_ANSWER_SCORE = 10
def __init__(self, users: UserLookup) -> None:
self._users = users
self._lock = threading.Lock()
self._granted: set[tuple[str, str]] = set()
def on_event(self, event: PostEvent) -> None:
badge: Badge | None = None
if (
event.type is EventType.POST_VOTED
and event.post_type is PostType.ANSWER
and event.value >= self.NICE_ANSWER_SCORE
):
badge = Badge("Nice Answer", BadgeTier.BRONZE, f"answer {event.post_id} reached +10")
elif event.type is EventType.ANSWER_ACCEPTED:
badge = Badge("Scholar", BadgeTier.BRONZE, "accepted an answer")
if badge is None:
return
owner = event.actor_id if event.type is EventType.ANSWER_ACCEPTED else event.recipient_id
with self._lock:
if (owner, badge.name) in self._granted:
return
self._granted.add((owner, badge.name))
self._users.user(owner).badges.append(badge)
The facade validates first, claims the ballot second, and moves reputation third — never the other way round, because a rejected ballot must not have paid anyone. accept_answer is the other interesting method: it is idempotent, it moves the points when the tick moves, and it pays the asker's +2 only once.
class QnAService:
"""The facade: posts, ballots, reputation and the observer fan-out in one place.
Each question owns an ``RLock`` created with the question, so accept, close
and reopen on the same thread serialise and different threads never see a
half-applied accept. Lazily creating those locks would itself be a race,
which is why they are created up front.
"""
def __init__(
self,
clock: Clock | None = None,
ids: IdGenerator | None = None,
policy: ReputationPolicy | None = None,
user_ids: IdGenerator | None = None,
vote_ids: IdGenerator | None = None,
) -> None:
self._clock = clock or SystemClock()
self._ids = ids or SequentialIdGenerator("p")
self._user_ids = user_ids or SequentialIdGenerator("u")
self._users: Repository[User] = Repository("user")
self._posts: Repository[Post] = Repository("post")
self._tags: dict[str, Tag] = {}
self._tag_counts: dict[str, int] = {}
self._registry_lock = threading.Lock()
self._question_locks: dict[str, threading.RLock] = {}
self._listeners: list[PostListener] = []
self.ledger = VoteLedger(self._clock, vote_ids or SequentialIdGenerator("v"))
self.reputation = ReputationService(self._users, policy or StackOverflowReputation())
# -- wiring ---------------------------------------------------------------
def subscribe(self, listener: PostListener) -> None:
self._listeners.append(listener)
def _publish(self, event: PostEvent) -> None:
for listener in self._listeners: # outside every lock: a slow listener blocks nobody
listener.on_event(event)
def _lock_for(self, question_id: str) -> threading.RLock:
with self._registry_lock:
try:
return self._question_locks[question_id]
except KeyError:
raise PostNotFoundError(f"unknown question {question_id!r}") from None
# -- reads ----------------------------------------------------------------
def user(self, user_id: str) -> User:
return self._users.get(user_id)
def question(self, question_id: str) -> Question:
return self._typed(question_id, Question)
def answer(self, answer_id: str) -> Answer:
return self._typed(answer_id, Answer)
def post(self, post_id: str) -> Post:
return self._posts.get(post_id)
def questions(self) -> list[Question]:
return [p for p in self._posts.all() if isinstance(p, Question)]
def tag_counts(self) -> dict[str, int]:
with self._registry_lock:
return dict(self._tag_counts)
def _typed[P: Post](self, post_id: str, kind: type[P]) -> P:
post = self._posts.get(post_id)
if not isinstance(post, kind):
raise PostNotFoundError(f"{post_id!r} is a {post.post_type}, not a {kind.__name__.lower()}")
return post
# -- writes ---------------------------------------------------------------
def register_user(self, display_name: str) -> User:
user = User(id=self._user_ids.next_id(), display_name=display_name)
return self._users.add(user.id, user)
def ask_question(self, author_id: str, title: str, body: str, tags: Iterable[str] = ()) -> Question:
self._users.get(author_id)
names = frozenset(Tag.of(name).name for name in tags)
question = Question(
id=self._ids.next_id(),
author_id=author_id,
body=body,
created_at=self._clock.now(),
title=title,
tags=names,
)
with self._registry_lock:
self._question_locks[question.id] = threading.RLock()
for name in sorted(names): # sorted: frozenset order is not stable across runs
self._tags.setdefault(name, Tag.of(name))
self._tag_counts[name] = self._tag_counts.get(name, 0) + 1
return self._posts.add(question.id, question)
def post_answer(self, question_id: str, author_id: str, body: str) -> Answer:
self._users.get(author_id)
question = self.question(question_id)
with self._lock_for(question_id):
if not question.accepts_new_posts():
raise QuestionStateError(f"question {question_id} is {question.status}")
answer = Answer(
id=self._ids.next_id(),
author_id=author_id,
body=body,
created_at=self._clock.now(),
question_id=question_id,
)
self._posts.add(answer.id, answer)
question.answer_ids.append(answer.id)
self._publish(
PostEvent(
type=EventType.ANSWER_POSTED,
at=self._clock.now(),
post_id=answer.id,
post_type=PostType.ANSWER,
actor_id=author_id,
recipient_id=question.author_id,
detail=answer.summary(),
)
)
return answer
def add_comment(self, parent_id: str, author_id: str, body: str) -> Comment:
self._users.get(author_id)
parent = self._posts.get(parent_id)
question_id = parent.id if isinstance(parent, Question) else self._question_of(parent)
with self._lock_for(question_id):
if not self.question(question_id).accepts_new_posts():
raise QuestionStateError(f"question {question_id} is closed to new comments")
comment = Comment(
id=self._ids.next_id(),
author_id=author_id,
body=body,
created_at=self._clock.now(),
parent_id=parent_id,
)
self._posts.add(comment.id, comment)
parent.comment_ids.append(comment.id)
self._publish(
PostEvent(
type=EventType.COMMENT_ADDED,
at=self._clock.now(),
post_id=comment.id,
post_type=PostType.COMMENT,
actor_id=author_id,
recipient_id=parent.author_id,
detail=comment.summary(),
)
)
return comment
def _question_of(self, post: Post) -> str:
if isinstance(post, Answer):
return post.question_id
if isinstance(post, Comment):
return self._question_of(self._posts.get(post.parent_id))
return post.id
def cast_vote(self, post_id: str, voter_id: str, vote_type: VoteType) -> VoteOutcome:
"""Validate, claim the ballot, then move reputation. Never the other way round."""
post = self._posts.get(post_id)
self._users.get(voter_id)
if post.author_id == voter_id:
raise SelfVoteError(f"{voter_id} cannot vote on their own {post.post_type}")
post.assert_accepts(vote_type)
outcome = self.ledger.cast(post, voter_id, vote_type)
self._settle_reputation(post, voter_id, outcome)
self._publish(
PostEvent(
type=EventType.POST_VOTED,
at=self._clock.now(),
post_id=post.id,
post_type=post.post_type,
actor_id=voter_id,
recipient_id=post.author_id,
value=outcome.score,
detail=f"{vote_type}vote",
)
)
return outcome
def retract_vote(self, post_id: str, voter_id: str) -> VoteOutcome:
post = self._posts.get(post_id)
outcome = self.ledger.retract(post, voter_id)
self._settle_reputation(post, voter_id, outcome)
return outcome
def _settle_reputation(self, post: Post, voter_id: str, outcome: VoteOutcome) -> None:
"""Reverse whatever the previous ballot paid, then apply the new one."""
policy = self.reputation.policy
if outcome.previous is not None:
self.reputation.award(post.author_id, -policy.author_delta(post.post_type, outcome.previous))
self.reputation.award(voter_id, -policy.voter_delta(post.post_type, outcome.previous))
if outcome.vote is not None:
applied = outcome.vote.type
self.reputation.award(post.author_id, policy.author_delta(post.post_type, applied))
self.reputation.award(voter_id, policy.voter_delta(post.post_type, applied))
def accept_answer(self, question_id: str, answer_id: str, actor_id: str) -> None:
"""Only the asker accepts, only one answer is accepted, and switching moves the points."""
question = self.question(question_id)
answer = self.answer(answer_id)
with self._lock_for(question_id):
if question.status in (QuestionStatus.CLOSED, QuestionStatus.DELETED):
raise QuestionStateError(f"question {question_id} is {question.status}")
if question.author_id != actor_id:
raise NotAuthorError(f"{actor_id} did not ask {question_id}")
if answer.question_id != question_id:
raise QuestionStateError(f"answer {answer_id} does not belong to {question_id}")
if question.accepted_answer_id == answer_id:
return # idempotent: a double click is not an error
answer_points, giver_points = self.reputation.policy.accept_delta()
if question.accepted_answer_id is None:
self.reputation.award(actor_id, giver_points)
else:
previous = self.answer(question.accepted_answer_id)
previous.accepted = False
self.reputation.award(previous.author_id, -self._accept_points(previous, actor_id, answer_points))
answer.accepted = True
question.accepted_answer_id = answer_id
question.status = QuestionStatus.ANSWERED
self.reputation.award(answer.author_id, self._accept_points(answer, actor_id, answer_points))
self._publish(
PostEvent(
type=EventType.ANSWER_ACCEPTED,
at=self._clock.now(),
post_id=answer_id,
post_type=PostType.ANSWER,
actor_id=actor_id,
recipient_id=answer.author_id,
value=answer_points,
)
)
@staticmethod
def _accept_points(answer: Answer, actor_id: str, points: int) -> int:
"""Accepting your own answer earns nothing — otherwise reputation is free."""
return 0 if answer.author_id == actor_id else points
def close_question(self, question_id: str, moderator_id: str, reason: str) -> None:
question = self.question(question_id)
self.reputation.require(moderator_id, self.reputation.policy.close_threshold(), "closing a question")
with self._lock_for(question_id):
if question.status is QuestionStatus.DELETED:
raise QuestionStateError(f"question {question_id} is deleted")
question.status = QuestionStatus.CLOSED
self._publish(
PostEvent(
type=EventType.QUESTION_CLOSED,
at=self._clock.now(),
post_id=question_id,
post_type=PostType.QUESTION,
actor_id=moderator_id,
recipient_id=question.author_id,
detail=reason,
)
)
def reopen_question(self, question_id: str, moderator_id: str) -> None:
question = self.question(question_id)
self.reputation.require(moderator_id, self.reputation.policy.close_threshold(), "reopening a question")
with self._lock_for(question_id):
if question.status is not QuestionStatus.CLOSED:
raise QuestionStateError(f"question {question_id} is {question.status}, not closed")
question.status = (
QuestionStatus.ANSWERED if question.accepted_answer_id else QuestionStatus.OPEN
)
def delete_question(self, question_id: str, actor_id: str) -> None:
question = self.question(question_id)
with self._lock_for(question_id):
if question.author_id != actor_id:
self.reputation.require(actor_id, self.reputation.policy.close_threshold(), "deleting")
question.status = QuestionStatus.DELETED
Search is four lines of real work because the Specification does the thinking:
class SearchService:
"""Specification in, ranked page out. It never grows an ``if`` per filter."""
def __init__(self, qna: QnAService, ranking: RankingStrategy | None = None) -> None:
self._qna = qna
self._ranking = ranking or HighestScoreRanking()
def search(
self,
spec: QuestionSpec,
ranking: RankingStrategy | None = None,
limit: int = 10,
) -> list[Question]:
visible = spec & NotSpec(StatusIs(QuestionStatus.DELETED))
matches = [q for q in self._qna.questions() if visible.is_satisfied_by(q)]
return (ranking or self._ranking).rank(matches)[:limit]
def by_tag(self, tag: str, limit: int = 10) -> list[Question]:
return self.search(TaggedWith(tag), limit=limit)
Running python -m lld.stack_overflow.demo walks the whole scenario:
p-1 'Why is my GIL-bound loop slow?' tags=['concurrency', 'python'] status=open
answer score=11, expert reputation=111
double vote rejected: u-3 already upvoted p-2
self vote rejected: u-2 cannot vote on their own answer
reader1 switches: score=9, expert=99
accepted: status=answered, expert=114
badges: expert=['Nice Answer'], asker=['Scholar']
search: [+1] Why is my GIL-bound loop slow? (concurrency, python) - answered
search: [+0] How do I profile Python? (python) - open
close rejected: closing a question needs 3000 reputation, u-2 has 114
p-4 closed -> closed; tag counts {'concurrency': 1, 'python': 2}
asker inbox: answer_posted on p-2 by u-2: Use processes for CPU work.
Trace one line of it: eleven readers upvote at +10 each, so the expert goes from 1 to 111. Reader 1 then switches to a downvote — the service reverses the +10 and applies −2, giving 99, while the score moves by −2 rather than −1. Accepting adds 15 and lands on 114.
Concurrency and edge cases¶
Which lock protects what. There are three, and the order between them is the answer the interviewer wants:
VoteLedger._locks[i]— one lock per shard, chosen byhash(post_id) % 8. It guards that shard's ballot dict and thescoreof any post whose ballots live there. Because the shard is picked by post id, every ballot for a post uses the same lock, so "check for an existing ballot, insert, adjust the score" is one atomic step. Two members voting on different posts almost never contend.QnAService._question_locks[qid]— oneRLockper question, created when the question is created. Lazily creating locks is itself a race, which is why they are not lazy. It guards attaching answers and comments and the whole accept/close/reopen transition, so a reader never observes a question that has an accepted answer id but a stale status.ReputationService._lock— the reputation column. It is always acquired after a shard lock or a question lock has been released or while one is held, never before, so the pair cannot deadlock.
Double voting. This is the race the design exists for. The check ("does this pair already have a ballot?") and the write (insert plus score += delta) happen inside one shard lock. The concurrency test fires 24 clicks from 8 members through 12 threads: exactly 8 succeed, 16 raise DuplicateVoteError, the score lands on 8 and the ledger holds 8 rows. An uncontended mutex costs about 17 ns, so the sharding is not a throughput trick — a busy site might see 1M votes/day, which is 1M / 10^5 s ≈ 10 writes/s average and roughly 30/s at a 3x peak, far below the 5k–20k writes/s a single relational primary sustains. Sharding exists so the ledger never has to hold a global lock across a reputation update.
Concurrent accept. Two browser tabs accepting different answers serialise on the question lock. The loser observes accepted_answer_id already set, revokes the previous answer's 15 points and grants them to the new one; the asker's +2 is paid only on the first accept, so refreshing cannot farm reputation.
Reputation is deliberately non-reversible. The floor at 1 means 1 − 2 clamps to 1, so undoing that downvote hands back 2 points that were never paid. Real sites accept this. Say so rather than pretending the arithmetic is a group — a reviewer who notices the clamp and calls it out scores better than one who silently gets it wrong.
Other edge cases handled: self-votes, downvoting a comment, voting on an unknown post, answering or commenting on a closed question, accepting an answer that belongs to a different question, a non-asker accepting, accepting your own answer (worth nothing), retracting a ballot that does not exist, reopening a question that was never closed, deleted questions dropping out of every search, and privileged actions failing with InsufficientReputationError before anything mutates.
Common mistake
Updating post.score in the service after the ledger returns. It reads naturally and it is wrong: between the insert and the increment another thread can insert its own ballot, and the score drifts from the ballot count for good. The score is a cached aggregate of the ledger, so the ledger must own it — one lock, one place, one invariant.
Extensibility and follow-ups¶
- A new reputation regime: write a class with four methods and pass it to
QnAService(policy=...).FlatReputationin the repo exists purely to prove nothing else moves. - A real search index:
KeywordInscans strings today. Put an inverted index behind the sameQuestionSpecinterface andSearchServicedoes not change; that hand-off is where this becomes Design a search engine (with Twitter real-time search). - Bounties:
Bountyis already a value object. Wire it as a scheduled job that transfers reputation whenexpires_atpasses, and add anawardedstatus. - Moderation: flags become a
Flagpost-adjacent entity plus aFlagReviewedevent; a moderation queue is one morePostListener. - Caching hot questions: the front page is read-heavy — social systems run around a 10:1 read/write ratio — so put a read-through cache in front of
SearchServicekeyed by the Specification'sdescribe()string, and invalidate it onPOST_VOTED. - Ranking feeds:
MostAnsweredRankingis 3 lines. A time-decayed hotness score is 5 more, and the service stays untouched.
Interview tip
When you get to voting, do not start with the code. Say the invariant first — "a post's score is always the sum of its ballots, and one member holds at most one ballot per post" — then show the one lock that makes both true at once. Interviewers grade whether you can name the invariant, not whether you can spell threading.Lock.
Tests¶
tests/test_stack_overflow.py has 12 cases. The one to walk through is the ballot race: eight members, three clicks each, twelve threads, and an exact assertion on all three quantities that could drift.
def test_concurrent_ballots_respect_the_uniqueness_constraint(clock: FakeClock) -> None:
"""Eight readers, three clicks each, all at once: eight ballots and a score of 8."""
qna = make_service(clock)
asker, expert = qna.register_user("asker"), qna.register_user("expert")
question = qna.ask_question(asker.id, "Why?", "Because.")
answer = qna.post_answer(question.id, expert.id, "Do this.")
readers = [qna.register_user(f"reader{i}") for i in range(8)]
def click(attempt: int) -> bool:
try:
qna.cast_vote(answer.id, readers[attempt % 8].id, VoteType.UP)
except DuplicateVoteError:
return False
return True
with ThreadPoolExecutor(max_workers=12) as pool:
results = list(pool.map(click, range(24)))
assert results.count(True) == 8 and results.count(False) == 16
assert answer.score == 8 and len(qna.ledger.ballots_for(answer.id)) == 8
assert qna.reputation.reputation(expert.id) == 81 # 1 + 8 x 10, no lost updates
The search test is the one that shows the Specification paying rent — five different filters, no new service code:
def test_search_composes_specifications_and_honours_the_ranking(clock: FakeClock) -> None:
qna = make_service(clock)
asker, reader = qna.register_user("asker"), qna.register_user("reader")
slow = qna.ask_question(asker.id, "Why is threading slow?", "GIL.", ["python", "threads"])
clock.advance(60)
fast = qna.ask_question(asker.id, "How to profile Python?", "cProfile.", ["python"])
clock.advance(60)
other = qna.ask_question(asker.id, "Rust borrow checker?", "Lifetimes.", ["rust"])
qna.cast_vote(fast.id, reader.id, VoteType.UP)
search = SearchService(qna)
python_only = TaggedWith("python")
assert [q.id for q in search.search(python_only, HighestScoreRanking())] == [fast.id, slow.id]
assert [q.id for q in search.search(python_only, NewestFirstRanking())] == [fast.id, slow.id]
assert [q.id for q in search.search(TaggedWith("python") & KeywordIn("threading"))] == [slow.id]
assert [q.id for q in search.search(TaggedWith("rust") | MinScore(1))] == [fast.id, other.id]
assert search.search(HasAcceptedAnswer()) == []
assert [q.id for q in search.by_tag("rust")] == [other.id]
qna.delete_question(other.id, asker.id)
assert search.search(AskedBy(asker.id), MostAnsweredRanking()) and other not in search.search(
AskedBy(asker.id)
)
assert [q.id for q in search.search(StatusIs(QuestionStatus.OPEN))] == [fast.id, slow.id]
The rest cover: the happy path from ask to accept with exact reputation numbers; switching a ballot both ways via parametrize (including the lossy floor); the validation family (self-vote, comment downvote, duplicate, unknown post); the full OPEN → ANSWERED → CLOSED → ANSWERED lifecycle with the privilege check; moving an accepted answer between two answerers; retracting a ballot; the two observers, asserting each badge is awarded exactly once and that you are never notified about your own action; a swapped FlatReputation policy; and accepting your own answer for zero points. Run them with uv run pytest code/lld/stack_overflow -q.
45-minute pacing¶
| Minutes | What to do | What to say or write |
|---|---|---|
| 0–5 | Clarify | Is a comment a post? What happens on a double click? Can the accepted answer move? Out of scope: markdown, moderation queues, the real index. |
| 5–10 | Entities | Nouns on the board: Post (+3 subclasses), User, Vote, Tag, Badge. Verbs become methods: ask, answer, vote, accept, close, search. |
| 10–18 | Class diagram | Draw the Post hierarchy first, then hang VoteLedger, ReputationPolicy and QuestionSpec off QnAService. Mark the three locks as you draw them. |
| 18–24 | Vote path | State the invariant, then write VoteLedger.cast: shard, lock, check the pair, insert or switch, adjust the score, return the outcome. |
| 24–32 | Reputation and accept | Write _settle_reputation (reverse then apply) and accept_answer (idempotent, points move, +2 paid once). Mention the floor being lossy. |
| 32–38 | Search | QuestionSpec with &, |, ~, three leaves, and a four-line SearchService. Say "an inverted index slots in here". |
| 38–43 | Concurrency and tests | The three locks, their order, and the 24-click race test with its three assertions. |
| 43–45 | Extensions | Bounties, moderation as another listener, caching the front page, and search at scale as the HLD hand-off. |
Related¶
- Specification — the filter algebra
SearchServiceruns on - Observer — the
PostEventfan-out to the inbox and the badge awarder - Strategy — reputation and ranking as swappable policies
- Repository — the persistence seam under every store
- Design LinkedIn (social network) — the same Observer and Specification shapes over a graph
- Design a search engine (with Twitter real-time search) — what happens when
KeywordInhas to be a real index - Primary source: Stack Exchange help centre, "What is reputation?" (https://stackoverflow.com/help/whats-reputation)