Engineering Atlas › Low-Level Design

Code a Q&A site with votes and reputation

11 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

Requirements

Functional

Non-functional

Out of scope

Entities and relationships

User has an id and a reputation. Post is the shared base for Question and Answer: an id, an author, a body, a score and a list of Comment objects. A Question also has a title, its answer ids and an optional accepted answer id. An Answer points to its question.

Vote is the relationship between a user and a post with a direction. It is keyed by (post, voter), which makes "one vote per user per post" a property of the data structure rather than a rule someone must remember to check. ReputationPolicy maps events (upvote received, downvote received, downvote cast, answer accepted, accepted an answer) to point values and holds the privilege thresholds. QAService is the facade that validates a request, consults the policy, and updates posts, votes and reputations together.

Comments carry no votes or reputation here, so they are a value list on a post rather than a third kind of Post.

Class diagram

classDiagram
    class User {
        +id
        +reputation int
    }
    class Post {
        <<abstract>>
        +id int
        +author
        +score int
        +comments list~Comment~
    }
    class Question {
        +title
        +answerIds list~int~
        +acceptedId int
    }
    class Answer {
        +questionId int
    }
    class Comment {
        +id int
        +author
        +text
    }
    class Vote {
        +postId int
        +voter
        +direction
    }
    class ReputationPolicy {
        +voteEffect(direction) Effect
        +acceptEffect() Effect
        +threshold(privilege) int
    }
    class QAService {
        +ask(user, title) int
        +answer(user, questionId, body) int
        +comment(user, postId, text) int
        +vote(user, postId, direction)
        +accept(user, answerId)
        +unaccept(user, questionId)
    }
    Post <|-- Question
    Post <|-- Answer
    Post "1" *-- "*" Comment
    Question "1" o-- "*" Answer
    Vote --> Post
    Vote --> User
    QAService --> ReputationPolicy
    QAService --> Post
    QAService --> User

Key flows

The interesting flow is a vote change. Cat upvoted Bob's answer yesterday and now switches to a downvote. The service reads the stored direction, checks the privilege for the new direction, then reverses the old effect and applies the new one in one critical section.

sequenceDiagram
    participant C as Cat
    participant S as QAService
    participant V as VoteStore
    participant P as ReputationPolicy
    participant R as Reputation
    C->>S: vote(cat, answer 2, down)
    S->>V: get(answer 2, cat)
    V-->>S: up
    S->>P: threshold(downvote)
    P-->>S: 125
    S->>S: check cat >= 125 and cat is not author
    S->>R: undo up: bob -10, score -1
    S->>R: apply down: bob -2, cat -1, score -1
    S->>V: put(answer 2, cat, down)
    S-->>C: ok

Accepting works the same way: reverse the previous acceptance's effect, then apply the new one.

Design patterns used and why

Strategy (a policy object) for reputation and privileges. Point values have changed over the site's life: question upvotes went from +5 to +10 in 2019, and the real site charges the voter only for downvoting answers, not questions. When the numbers live in one ReputationPolicy, a rule change is a one-file diff plus a backfill job, and tests can run with a toy policy.

Inheritance for Post, used sparingly. Questions and answers really do share identity, authorship, score, voting and comments, and voting code should not care which one it got. The subclasses add only data (title, accepted id, question link). The "is-a" relation genuinely holds here.

Observer or domain events for side effects. A VoteChanged(post, voter, old, new) event is what badges, notifications, the "hot questions" ranker and the search indexer subscribe to. The practice applies reputation inline; at scale it becomes one more subscriber.

Facade in QAService: one method per user action, so the controller layer never coordinates posts, votes and reputations itself.

Concurrency and edge cases

Exactly one vote per user per post

Two clicks from the same user arrive at once (a double tap, or a retry after a timeout). With the vote keyed by (post, voter) and the read-compare-write done under one lock, the second request sees the direction already stored and is a no-op. In a database, a primary key on (post_id, user_id) plus INSERT ... ON CONFLICT (post_id, user_id) DO UPDATE gives the same guarantee, and the delta logic runs in the same transaction.

Hot rows

A viral answer receives hundreds of votes a minute, and every one updates the same score row and the same author's reputation row. Row locks serialise them; that is correct but makes latency grow with popularity. Keep the transaction tiny (one insert, two increments), always lock in the same order (post, then author, then voter) so two transactions cannot deadlock, and use score = score + delta instead of read-then-write. If still too hot, log votes and fold them into the score asynchronously.

Privilege checks use current reputation

Privileges gate new actions; a user who falls below 125 keeps the downvotes already cast. Withdrawing a vote never needs a privilege, otherwise a user who lost reputation could be stuck with a vote they regret.

Accepting

Only the asker can accept, only an answer to that question can be accepted, and accepting the already accepted answer is a no-op. Self-accepts are allowed but earn nothing, which closes the obvious farming loop of answering your own question and accepting it.

Caps and floors are policy

The real site floors reputation at 1 and caps vote reputation at 200 per day. Both break the "sum of effects" model (with a floor, undoing a downvote can return more than was taken), so they need the event log recomputed in time order with an injected clock.

Down the stack

Layer 1The contract

For every (post, voter) there is at most one standing vote. A post's score equals ups minus downs among standing votes. Every user's reputation equals 1 plus the sum of the policy effects of all standing votes and acceptances. Any sequence of vote changes that ends in the starting state leaves every number exactly where it began. A rejected action changes nothing.

Layer 2The mechanism

In memory: a map from post id to post, a map from user to reputation, and per post a map from voter to direction. A vote is O(1): one lookup, a delta of at most four integer updates, one store. The delta is effect(new) - effect(old) with effect(none) = 0, which is why undo and switch need no special cases. In a relational store this maps directly to the public Stack Exchange data dump's shape: a Votes table with a VoteTypeId (1 for accepted by originator, 2 for up, 3 for down), a denormalised Score on Posts, and Reputation on Users. Denormalised totals make the page read cheap; the votes table makes them verifiable.

Layer 3The machine

A vote in Postgres or SQL Server is a short transaction: insert or update a vote row of a few dozen bytes, update two rows with an increment, write the redo log and fsync on commit. On NVMe that commit is tens of microseconds; on networked storage it is about a millisecond, so one hot row tops out around a thousand serialised votes per second before batching helps. In memory, an uncontended Go sync.Mutex costs about 20 ns and the whole vote under it well under a microsecond. Stack Overflow's published 2016 architecture ran on a small fleet of SQL Server and web machines, which works because reads hit caches and writes like votes are tiny transactions.

Why it's built this way

People change their minds, so votes are stored as current state per (post, voter), not as counters. Reputation must be explainable when someone asks "why did I lose 2 points?", so it is a projection of standing votes. Rules change over the years, so the numbers sit in one policy object. Popular posts concentrate writes on one row, so the transaction is minimal and ordered. Reads dominate, so totals are denormalised and the log exists to verify them.

Extensibility

Leadership lens

Check yourself

  1. Why is a vote stored as a direction per (post, voter) instead of incrementing ups and downs counters?

    Show answer

    Counters cannot tell whether this user already voted, so they cannot enforce one vote per user, reverse a vote exactly, or rebuild reputation. The per-voter record makes undo a delta between two known states.

  2. Cat switches her vote on Bob's answer from up to down. What happens to Bob's and Cat's reputation and to the score?

    Show answer

    Bob loses the 10 from the upvote and 2 more for the downvote (-12), Cat pays 1, and the score drops by 2. Undoing it later restores all three exactly.

  3. Ann accepted Bob's answer, then switches to Dan's. Which numbers change?

    Show answer

    Bob loses 15, Dan gains 15, and Ann's +2 stays because she still has an accepted answer. If Dan's answer were Ann's own, Ann would lose the 2 and nobody would gain.

  4. A user at 125 downvotes and drops to 124. Can they withdraw that downvote? Should they be able to?

    Show answer

    Yes. Withdrawing needs no privilege; otherwise users who lose reputation would be stuck with votes they cannot change.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →