Stack Overflow
Questions, answers, votes, comments, badges, tags. Reputation as derived state; moderation as a workflow.
Context#
Stack Overflow is a Q&A site for programmers: a user asks a question, other users post answers, both sides accumulate upvotes and downvotes, the asker accepts at most one answer, and reputation accrues to authors as a function of all of the above. Tags classify questions; badges acknowledge user milestones; comments hang off any post; moderators close, delete, undelete, and reopen. The prompt is dense enough that a candidate can spend the full 45 minutes drawing entities and still miss what scores — which is exactly why it is a useful Advanced round.
The interviewer’s hidden objectives, in roughly the order they will be tested:
- Can you enumerate the entities without missing the load-bearing ones — Question, Answer, Comment, Vote, Tag, Badge, User, Reputation — and notice that Question and Answer are siblings, not parent and child?
- Can you model reputation as derived state, not as a stored field that every action mutates by hand?
- Can you reach for the right pattern per axis — Strategy for vote-to-reputation deltas, Command for moderation actions, State for the moderation lifecycle?
- Can you scope out search, ranking, feed generation, anti-spam, and rate-limiting as system-design concerns, while still naming the seams cleanly?
- Can you defend the asymmetries the platform actually has (upvote on question is +5, on answer is +10; downvote on question costs the voter -1, on answer costs the voter -1 too — but the author loses -2) without inventing them?
Requirements (functional and non-functional)#
The scope below is what most interviewers expect for a 45-minute Advanced round. Anything outside it is flagged out-of-scope so the round finishes.
Functional — in scope.
- A user posts a Question with a title, body, and one to five Tags.
- Other users post Answers to a question. The asker may accept at most one answer.
- Any signed-in user with sufficient reputation can upvote or downvote any Question or Answer.
- Any user can post a Comment on any Question or Answer. Comments cannot be voted on for reputation (they have a flat helpful/unhelpful flag).
- Reputation is a derived integer per user: a function of vote events plus accepted-answer events plus awarded badges.
- Badges (bronze/silver/gold) are awarded by rules (
first-question,10-upvotes-on-an-answer,popular-question). The award engine is event-driven. - Moderation actions — flag, close, reopen, delete, undelete — are issued as moderator commands. Closed questions cannot receive new answers but remain readable.
Functional — out of scope (called out explicitly and held to).
- Search. Full-text search over questions/answers is a system-design problem (indexing, ranking, BM25/embeddings).
SearchServiceis the seam; the implementation lives in the HLD round. - Home-page ranking and the “hot” feed. Same shape —
FeedServiceis a seam, generation strategy is HLD. - Anti-spam, rate-limiting, captchas. Cross-cutting concerns; mention
PolicyGateand move on. - Edits, revision history, suggested edits. A clean follow-up extension (revision is its own aggregate); not the core round.
- Bounties, chat, jobs, network-wide profile. Out of scope.
Non-functional.
- The site holds on the order of 10⁷ questions and 10⁸ votes. For the LLD scope (a single in-memory slice), 10⁵-row data structures are sufficient; we name repository seams so a database fits cleanly later.
- Vote and post latency under 200 ms.
- Reputation must be consistent on read — a user who refreshes after upvoting an answer should see the new total. (This is the constraint that pushes reputation toward a write-through projection, not a batch recompute.)
- Concurrency: the same answer may receive simultaneous upvotes; the vote count and reputation projection must not lose updates.
Use case diagram#
┌────────────────┐ │ User │ └────────┬───────┘ │ ┌──────────┬──────┼──────┬──────────┬─────────────┐ ▼ ▼ ▼ ▼ ▼ ▼ [ask question] [answer] [vote] [comment] [accept ans] [flag post] │ │ │ │ │ │ └──────────┴──────┴──┬───┴──────────┴─────────────┘ ▼ ┌──────────────────────────────┐ │ Stack Overflow System │ └────────────┬─────────────────┘ ▲ │ ┌────────┴────────┐ │ Moderator │ … close / reopen / delete / undelete └─────────────────┘Two primary actors (User, Moderator). Moderator is a User with elevated reputation, modelled by role on the same User aggregate, not a subclass — privilege is a property, not an identity.
Class diagram#
┌──────────────────────┐ │ User │ ├──────────────────────┤ │ id, name, joinedAt │ │ reputation : long │ ◇── derived; updated by ReputationProjection │ roles : Set<Role> │ ├──────────────────────┤ │ canVote() / canClose() / canFlag() // gates by reputation └─────────┬────────────┘ │ authors ▼ ┌──────────────────────────────┐ │ Post │ «abstract» ├──────────────────────────────┤ │ id, author, body, createdAt │ │ status : PostStatus │ // OPEN, CLOSED, DELETED │ score : int │ // upvotes − downvotes (cache of vote sum) │ comments : List<Comment> │ ├──────────────────────────────┤ │ accept(VoteEvent) │ │ accept(ModerationCommand) │ └─────────┬────────────────────┘ │ ┌─────────────┴──────────────┐ ▼ ▼ ┌────────────────────┐ ┌────────────────────┐ │ Question │ │ Answer │ ├────────────────────┤ ├────────────────────┤ │ title │ │ questionId │ │ tags : Set<Tag> │ │ accepted : bool │ │ answers : List<Answer>│ └────────────────────┘ │ acceptedAnswerId? │ └────────────────────┘
┌────────────────┐ ┌────────────────┐ ┌──────────────────┐ │ Comment │ │ Tag │ │ Badge │ ├────────────────┤ ├────────────────┤ ├──────────────────┤ │ id, author │ │ name │ │ name, tier │ │ body, postId │ │ usageCount │ │ awardedAt │ └────────────────┘ └────────────────┘ └──────────────────┘
┌──────────────────────────┐ ┌───────────────────────────┐ │ VoteEvent │ │ VotingStrategy │◁── QuestionVoting, ├──────────────────────────┤ ├───────────────────────────┤ AnswerVoting │ voter, postId │ │ delta(VoteEvent, role) │ (different deltas per │ kind : UP | DOWN │ │ → ReputationDelta │ post kind) │ at │ └───────────────────────────┘ └──────────────────────────┘
┌─────────────────────────────┐ ┌─────────────────────────────┐ │ ModerationCommand │◁── │ ReputationProjection │ // event-sourced over ├─────────────────────────────┤ ├─────────────────────────────┤ // VoteEvent + AcceptEvent + │ execute(Post) : void │ │ apply(DomainEvent) : void │ // BadgeAwarded │ undo(Post) : void │ │ readReputation(userId) │ └─────────────┬───────────────┘ └─────────────────────────────┘ │ ┌───┬───┬─────┴─────┬──────┐ ▼ ▼ ▼ ▼ ▼ Flag Close Reopen Delete UndeleteThree patterns are doing the load-bearing work:
- Strategy on
VotingStrategy. The reputation delta for an upvote on a Question is+5(and0for the voter); on an Answer it is+10; a downvote on either is-2for the author and-1for the voter. The asymmetry is real and is the point of having a per-post-kind strategy rather than a singleapplyVoteswitch. - Command on
ModerationCommand.Flag,Close,Reopen,Delete,Undeleteeach implementexecute(Post)andundo(Post). The moderation log is a list of executed commands; “undelete” isDelete.undo()and the audit trail falls out for free. - State on
Post.status—OPEN → CLOSED → REOPEN(=OPEN) | DELETED, withDELETED → OPENviaUndelete. New answers are rejected onCLOSEDandDELETED. Reads still work onCLOSED;DELETEDposts are visible only to high-rep users and moderators.
What is not in the diagram and that is deliberate:
- Reputation is not a stored field that vote handlers increment. It is a projection over the event log (
VoteEvent,AcceptEvent,BadgeAwarded). TheUser.reputationfield is the projection’s cache, refreshed write-through. This is the single most important diagram-shaping decision; the next section makes the flow explicit. Postis abstract, not “Question withisAnswerflag.” Question and Answer have different responsibilities — only Question carries tags and an accepted-answer pointer; only Answer carriesaccepted. Folding them into one class invitesif (kind == ANSWER)everywhere.Voteis not a cell in a(user, post)matrix. It is an event. A user’s “current vote on a post” islast(VoteEvent.where(voter, post)); un-voting is just another event. This keeps the audit log clean and makes reputation deterministic on replay.- No
SearchService,FeedService,RankingServiceaggregates. They are seams. They appear as interfaces with one method each; the implementations are HLD.
Sequence diagram (key flows)#
The upvote-an-answer flow, in object messages:
Voter VoteController PostRepo Answer VotingStrategy EventBus ReputationProjection │ upvote(answerId) │ │ │ │ │ │ │─────────────────►│ │ │ │ │ │ │ │ load(id) │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ answer │ │ │ │ │ │ │◄─────────────│ │ │ │ │ │ │ canVote()? │ │ │ │ │ │ │ (User) │ │ │ │ │ │ │ accept(VoteEvent(UP)) │ │ │ │ │ │────────────────────────────►│ │ │ │ │ │ │ │ score++ │ │ │ │ │ │ │ delta(...) │ │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ ReputationDelta(+10 to author, 0 to voter) │ │ │ │ │◄─────────────│ │ │ │ │ │ │ publish(VoteEvent) │ │ │ │ │ │─────────────────────────────►│ │ │ │ │ │ │ apply(event) │ │ │ │ │ │─────────────►│ │ │ │ │ │ │ User.reputation += 10 │ │ 200 OK │ │ │ │ │ │ 200 OK │◄─────────────│ │ │ │ │ │◄─────────────────│ │ │ │ │ │The accept-an-answer flow is the analogue with the asker as actor, an AcceptEvent instead of a VoteEvent, and VotingStrategy replaced by a fixed delta (+15 to the answerer, +2 to the asker for accepting). The shape is identical: domain mutation, event published, projection applied.
The moderation flow uses the Command pattern:
Mod ModerationController ModerationCommand Post EventBus │ close(postId) │ │ │ │ │────────────────►│ │ │ │ │ │ new Close(...) │ │ │ │ │──────────────────────►│ │ │ │ │ execute(post) │ │ │ │ │──────────────────────►│ │ │ │ │ │ status:=CLOSED │ │ │ │ │─────────────────►│ │ │ │ │ publish(ModerationEvent) │ │ │ │──────────────────────────────────►│ │ │ log.append(cmd) │ │ │ │ │◄──────────────────────│ │ │ │ 200 OK │ │ │ │ │◄────────────────│ │ │ │Reopen and Undelete are the symmetric undo() half — the same command object that closed the post can reopen it; the moderation log holds the chain.
Activity diagram (for non-trivial state)#
The Post.status lifecycle is where the moderation rules live:
┌─────────┐ │ start │ └────┬────┘ ▼ ┌──────────┐ │ OPEN │◄────────────────┐ └────┬─────┘ │ │ │ ┌────────┼────────────┐ │ ▼ ▼ ▼ │ new answer close() delete() │ (allowed) │ │ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ CLOSED │ │ DELETED │ │ │ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ reopen() │ │ │ └─────────────┼──────────┤ │ │ │ │ │ undelete()│ │ └──────────┘ │ (new answer rejected on CLOSED / DELETED) ▼ OPENCLOSED is a soft state: reads continue, comments continue, voting continues (for archival rep), but new answers are rejected. DELETED removes visibility from low-rep users; the post is recoverable for the duration of the retention window. Reopen and Undelete are the same Command pattern with undo().
Java implementation#
A representative slice. The vote-and-projection path is the most interesting and the most testable, so it is the slice shown.
public enum VoteKind { UP, DOWN }public enum PostKind { QUESTION, ANSWER }public enum PostStatus { OPEN, CLOSED, DELETED }
public abstract class Post { protected final long id; protected final long authorId; protected final String body; protected final Instant createdAt; protected PostStatus status = PostStatus.OPEN; protected int score = 0; // cached sum of votes protected final List<Comment> comments = new ArrayList<>();
protected Post(long id, long authorId, String body, Instant createdAt) { this.id = id; this.authorId = authorId; this.body = body; this.createdAt = createdAt; }
public abstract PostKind kind();
public synchronized void applyVote(VoteEvent v) { if (status == PostStatus.DELETED) throw new IllegalStateException("Cannot vote on deleted post"); score += (v.kind() == VoteKind.UP ? 1 : -1); }
public long authorId() { return authorId; } public int score() { return score; } public PostStatus status() { return status; } public void setStatus(PostStatus s) { this.status = s; }}
public final class Question extends Post { private final String title; private final Set<Tag> tags; private final List<Long> answerIds = new ArrayList<>(); private Long acceptedAnswerId;
public Question(long id, long authorId, String title, String body, Set<Tag> tags, Instant at) { super(id, authorId, body, at); this.title = title; this.tags = Set.copyOf(tags); } public PostKind kind() { return PostKind.QUESTION; }
public void acceptAnswer(long answerId, long actorId) { if (actorId != authorId) throw new IllegalStateException("Only the asker may accept an answer"); if (acceptedAnswerId != null) throw new IllegalStateException("Already accepted"); this.acceptedAnswerId = answerId; }}
public final class Answer extends Post { private final long questionId; private boolean accepted; public Answer(long id, long authorId, long questionId, String body, Instant at) { super(id, authorId, body, at); this.questionId = questionId; } public PostKind kind() { return PostKind.ANSWER; } public long questionId() { return questionId; } public void markAccepted() { this.accepted = true; }}
/** Voting strategy — different reputation deltas per post kind. */public interface VotingStrategy { ReputationDelta delta(VoteEvent v, Post target);}
public final class QuestionVoting implements VotingStrategy { public ReputationDelta delta(VoteEvent v, Post q) { return switch (v.kind()) { case UP -> ReputationDelta.author(q.authorId(), +5); case DOWN -> ReputationDelta.of(q.authorId(), -2).and(v.voterId(), -1); }; }}
public final class AnswerVoting implements VotingStrategy { public ReputationDelta delta(VoteEvent v, Post a) { return switch (v.kind()) { case UP -> ReputationDelta.author(a.authorId(), +10); case DOWN -> ReputationDelta.of(a.authorId(), -2).and(v.voterId(), -1); }; }}
/** Reputation as derived state. The event log is the source of truth; User.reputation is a cache. */public final class ReputationProjection { private final Map<Long, Long> repByUser = new ConcurrentHashMap<>();
public void apply(ReputationDelta d) { for (var e : d.entries()) { repByUser.merge(e.userId(), (long) e.amount(), Long::sum); } } public long readReputation(long userId) { return repByUser.getOrDefault(userId, 0L); }}
/** Moderation as Command — execute / undo. */public interface ModerationCommand { void execute(Post post, User actor); void undo(Post post, User actor);}
public final class CloseCommand implements ModerationCommand { public void execute(Post p, User actor) { if (!actor.hasRole(Role.MODERATOR)) throw new SecurityException("Not allowed"); p.setStatus(PostStatus.CLOSED); } public void undo(Post p, User actor) { p.setStatus(PostStatus.OPEN); }}
public final class DeleteCommand implements ModerationCommand { public void execute(Post p, User actor) { if (!actor.hasRole(Role.MODERATOR)) throw new SecurityException("Not allowed"); p.setStatus(PostStatus.DELETED); } public void undo(Post p, User actor) { p.setStatus(PostStatus.OPEN); }}
/** The vote service ties strategy + projection + event bus. */public final class VoteService { private final Map<PostKind, VotingStrategy> strategies; private final ReputationProjection reputation; private final EventBus bus;
public VoteService(Map<PostKind, VotingStrategy> strategies, ReputationProjection r, EventBus bus) { this.strategies = strategies; this.reputation = r; this.bus = bus; }
public void castVote(User voter, Post target, VoteKind kind) { if (!voter.canVote()) throw new SecurityException("Insufficient reputation to vote"); if (voter.id() == target.authorId()) throw new IllegalArgumentException("Cannot vote on own post"); VoteEvent event = new VoteEvent(voter.id(), target.id(), kind, Instant.now()); target.applyVote(event); ReputationDelta delta = strategies.get(target.kind()).delta(event, target); reputation.apply(delta); bus.publish(event); }}Notes the interviewer will look for:
ReputationProjectionis a separate class, not a method onUser. That separation is what makes “rebuild reputation from the event log after a bug” a one-liner instead of a migration.Postis abstract;applyVoteis on the base class, but the reputation effect is in the strategy. The score (a display field) is universal; the reputation effect (a policy) is per-kind.Money-style invariants onReputationDelta. Even in a slice, do not return rawintdeltas; encapsulate so the projection has one shape to apply.canVote()lives onUser, not onVoteService. The privilege gate is a property of the actor. The service composes it; it does not own it.castVoterejects self-votes. This is the kind of business rule a fresh candidate forgets and an experienced one names without prompting.
Trade-offs and extensions#
| Decision | Why | Cost if requirements change |
|---|---|---|
| Reputation as a projection over events | Deterministic rebuild; supports retroactive policy changes (e.g. “downvote penalty is now -3”) via replay. | The projection cache must be kept in sync; a write-through pattern adds latency to the vote path. |
Post abstract, Question/Answer concrete | Different responsibilities (tags vs accepted, title vs question-link) | If a third post kind appears (Wiki, Discussion), it slots in; if their responsibilities overlap enough, consider Composite later. |
VotingStrategy keyed by PostKind | One place to change the +5 / +10 asymmetry. SRP. | If voting rules become per-tag or per-time-window, promote to VotingPolicy chain. |
| Command pattern on moderation | Free undo and audit log. | None — Command is exactly the right shape. |
Tag as a flat entity, not hierarchical | Tags don’t have real parent/child semantics on the real site. | If “synonyms” become first-class, add a TagSynonymRegistry strategy applied at write-time. |
Events on an in-process EventBus | LLD scope; the bus is a seam. | Replace with Kafka in HLD without touching domain code. |
Single coarse lock per Post for vote application | Correctness first; per-post contention is acceptable at LLD scope. | At celebrity-question scale, partition by post id or use atomic counters; that is HLD. |
Likely follow-up extensions and the shape of the answer:
- Edits and revision history. A
PostRevisionaggregate perPost; the post points to its current revision; rollback isPost.setRevision(rev). The state machine extends toEDITED. - Bounties. A
Bountyaggregate holds reputation in escrow against a question; payouts go throughReputationProjectionlike any other event. Doesn’t change the diagram. - Per-tag reputation. The projection groups by
(userId, tag);User.reputationbecomes the sum over tags. The strategy already takes the post; the post already has tags. - Cross-site network reputation. A second projection over multiple
(site, event)streams; theUseraggregate spans sites. Substantial new design; an interview’s-worth on its own. - Search.
SearchService.search(query, filters): List<Question>is the seam. Implementation is BM25 + tag boost + recency decay — HLD.
Mock interview follow-ups#
Questions interviewers reach for and the briefest correct answer:
- “Why is reputation a projection rather than a counter on
User?” — Because the rules change. A policy update (“downvotes cost the author -3, not -2”) with a counter requires a backfill script; with a projection, it is a replay. Audit, rebuild, rollback all fall out for free. - “Two users upvote the same answer at the same time.” —
Post.applyVoteissynchronized; the score is consistent. The projection is aConcurrentHashMap.merge; reputation is consistent. Per-post contention is fine because answers are not contended at the level of seconds. - “How do you prevent vote rings?” — Two seams:
PolicyGaterejects suspicious vote events before they hitcastVote; an offline anomaly detector subscribes toVoteEventand revokes viaRevocationCommand. The LLD shape supports both — the event log is replayable. - “How do badges get awarded?” — A
BadgeRuleEnginesubscribes to the event bus; each rule is a predicate over recent events (first-question: emit on firstQuestionCreated;popular-question: emit when a question crosses 1000 views). Awards are themselves events; the projection picks them up. - “Where is full-text search?” —
SearchServiceis a seam. Implementation lives in HLD (Lucene / Elasticsearch + tag faceting + recency boost). The LLD-relevant decision is thatSearchService.searchreturnsList<QuestionSummary>, notList<Question>— the summary is what the index has, the full aggregate is fetched lazily. - “What changes if a moderator deletes a popular question?” —
DeleteCommand.executeflips status; vote events on a deleted post are still in the log but the projection emits aRetractDeltafor affected reputation. The event log retains the truth; the cache is recomputed. - “Why is
Questionnot a subclass ofAnsweror vice versa?” — They are siblings, not generalisation. A Question has tags and a title; an Answer points at a question and can be accepted. The shared shape — author, body, votes, comments — is the abstractPost. Inheriting one from the other violates LSP the moment you accept an Answer’s answer.
Related#
- Facebook — the other “social platform” OOD round; shares the privilege-by-role pattern and observer-driven notifications.
- LinkedIn — siblings on the LLD/HLD seam; both define
FeedServiceandSearchServiceas seams and hold the line. - Strategy Pattern —
VotingStrategyis a textbook use. - Command Pattern — moderation actions are the canonical Command example with an
undostory. - State Pattern —
Post.statuslifecycle is the OOD analogue of a finite-state machine.