Stack Overflow

Questions, answers, votes, comments, badges, tags. Reputation as derived state; moderation as a workflow.

System Advanced
17 min read
ood case-study stack-overflow strategy-pattern command-pattern

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). SearchService is the seam; the implementation lives in the HLD round.
  • Home-page ranking and the “hot” feed. Same shape — FeedService is a seam, generation strategy is HLD.
  • Anti-spam, rate-limiting, captchas. Cross-cutting concerns; mention PolicyGate and 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 Undelete

Three patterns are doing the load-bearing work:

  • Strategy on VotingStrategy. The reputation delta for an upvote on a Question is +5 (and 0 for the voter); on an Answer it is +10; a downvote on either is -2 for the author and -1 for the voter. The asymmetry is real and is the point of having a per-post-kind strategy rather than a single applyVote switch.
  • Command on ModerationCommand. Flag, Close, Reopen, Delete, Undelete each implement execute(Post) and undo(Post). The moderation log is a list of executed commands; “undelete” is Delete.undo() and the audit trail falls out for free.
  • State on Post.statusOPEN → CLOSED → REOPEN(=OPEN) | DELETED, with DELETED → OPEN via Undelete. New answers are rejected on CLOSED and DELETED. Reads still work on CLOSED; DELETED posts 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). The User.reputation field is the projection’s cache, refreshed write-through. This is the single most important diagram-shaping decision; the next section makes the flow explicit.
  • Post is abstract, not “Question with isAnswer flag.” Question and Answer have different responsibilities — only Question carries tags and an accepted-answer pointer; only Answer carries accepted. Folding them into one class invites if (kind == ANSWER) everywhere.
  • Vote is not a cell in a (user, post) matrix. It is an event. A user’s “current vote on a post” is last(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, RankingService aggregates. 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)
OPEN

CLOSED 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:

  • ReputationProjection is a separate class, not a method on User. That separation is what makes “rebuild reputation from the event log after a bug” a one-liner instead of a migration.
  • Post is abstract; applyVote is 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 on ReputationDelta. Even in a slice, do not return raw int deltas; encapsulate so the projection has one shape to apply.
  • canVote() lives on User, not on VoteService. The privilege gate is a property of the actor. The service composes it; it does not own it.
  • castVote rejects self-votes. This is the kind of business rule a fresh candidate forgets and an experienced one names without prompting.

Trade-offs and extensions#

DecisionWhyCost if requirements change
Reputation as a projection over eventsDeterministic 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 concreteDifferent 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 PostKindOne 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 moderationFree undo and audit log.None — Command is exactly the right shape.
Tag as a flat entity, not hierarchicalTags 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 EventBusLLD scope; the bus is a seam.Replace with Kafka in HLD without touching domain code.
Single coarse lock per Post for vote applicationCorrectness 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 PostRevision aggregate per Post; the post points to its current revision; rollback is Post.setRevision(rev). The state machine extends to EDITED.
  • Bounties. A Bounty aggregate holds reputation in escrow against a question; payouts go through ReputationProjection like any other event. Doesn’t change the diagram.
  • Per-tag reputation. The projection groups by (userId, tag); User.reputation becomes 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; the User aggregate 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.applyVote is synchronized; the score is consistent. The projection is a ConcurrentHashMap.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: PolicyGate rejects suspicious vote events before they hit castVote; an offline anomaly detector subscribes to VoteEvent and revokes via RevocationCommand. The LLD shape supports both — the event log is replayable.
  • “How do badges get awarded?” — A BadgeRuleEngine subscribes to the event bus; each rule is a predicate over recent events (first-question: emit on first QuestionCreated; popular-question: emit when a question crosses 1000 views). Awards are themselves events; the projection picks them up.
  • “Where is full-text search?”SearchService is a seam. Implementation lives in HLD (Lucene / Elasticsearch + tag faceting + recency boost). The LLD-relevant decision is that SearchService.search returns List<QuestionSummary>, not List<Question> — the summary is what the index has, the full aggregate is fetched lazily.
  • “What changes if a moderator deletes a popular question?”DeleteCommand.execute flips status; vote events on a deleted post are still in the log but the projection emits a RetractDelta for affected reputation. The event log retains the truth; the cache is recomputed.
  • “Why is Question not a subclass of Answer or 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 abstract Post. Inheriting one from the other violates LSP the moment you accept an Answer’s answer.
  • 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 FeedService and SearchService as seams and hold the line.
  • Strategy PatternVotingStrategy is a textbook use.
  • Command Pattern — moderation actions are the canonical Command example with an undo story.
  • State PatternPost.status lifecycle is the OOD analogue of a finite-state machine.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.