Skip to content

Design Stack Overflow

TL;DR

  • You build questions, answers and comments over one Post base, a VoteLedger that enforces one ballot per (voter, post), a ReputationPolicy that turns ballots into points, and a SearchService driven 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 Repository so 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, Answer and Comment. The base owns id, author, body, score and the comment list; each subclass declares its post_type and the ballots it accepts.
  • Question — title, a frozen set of tag names, a QuestionStatus, its answer ids and at most one accepted_answer_id. One question owns many answers (1 → *) and many comments.
  • Answer — the id of its question plus an accepted flag. 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; InboxNotifier and BadgeAwarder are 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:

code/lld/stack_overflow/models.py — enums
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:

code/lld/stack_overflow/models.py — errors
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().

code/lld/stack_overflow/models.py — the post hierarchy
@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:

code/lld/stack_overflow/strategies.py — reputation policies
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:

code/lld/stack_overflow/strategies.py — search filters
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:

code/lld/stack_overflow/stores.py — the vote ledger
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:

code/lld/stack_overflow/stores.py — reputation
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:

code/lld/stack_overflow/stores.py — observers
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.

code/lld/stack_overflow/services.py — the Q&A facade
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:

code/lld/stack_overflow/services.py — search
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:

  1. VoteLedger._locks[i] — one lock per shard, chosen by hash(post_id) % 8. It guards that shard's ballot dict and the score of 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.
  2. QnAService._question_locks[qid] — one RLock per 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.
  3. 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=...). FlatReputation in the repo exists purely to prove nothing else moves.
  • A real search index: KeywordIn scans strings today. Put an inverted index behind the same QuestionSpec interface and SearchService does not change; that hand-off is where this becomes Design a search engine (with Twitter real-time search).
  • Bounties: Bounty is already a value object. Wire it as a scheduled job that transfers reputation when expires_at passes, and add an awarded status.
  • Moderation: flags become a Flag post-adjacent entity plus a FlagReviewed event; a moderation queue is one more PostListener.
  • 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 SearchService keyed by the Specification's describe() string, and invalidate it on POST_VOTED.
  • Ranking feeds: MostAnsweredRanking is 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.

code/lld/stack_overflow/tests/test_stack_overflow.py — the ballot race
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:

code/lld/stack_overflow/tests/test_stack_overflow.py — composed filters
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.