Online Voting LLD: The Design Where You Destroy the Join on Purpose
A low-level design walkthrough of an online voting system: separating eligibility from the ballot so one vote per voter never links who voted to what, with an append-only, recountable tally.
"Design an online voting system." It looks like a CRUD app — a list of candidates, a button, a counter that goes up. Then the interviewer asks the question that turns it inside out: "Can you prove I voted, but not prove who I voted for?" And suddenly the whole thing is about a relationship you have to deliberately break: the system must know you voted (so you can't vote twice) and must record your choice (so it can be counted) — but must make it impossible to connect the two. Every database instinct you have says join those tables; this is the one design where joining them is the bug.
That single inverted requirement — prevent the link you'd normally build — is what separates a t_oy from a system here. Everything else (one vote per person, an honest count) follows once you see it.
Let's start nowhere near a computer
A village hall on election day. Two things sit at opposite ends of the room, run by people who never compare notes. At the door, a clerk holds the electoral register — a list of names. You show ID, she finds your name, ticks it, and hands you one blank ballot. Try to come back for a second ballot and your tick turns you away. The clerk knows that you voted; she has no idea what you voted.
You walk to the other end, mark your ballot in a curtained booth, and drop it — folded, anonymous — into a sealed box. The box knows what every vote is; it has no idea who cast any of them. The slip carries no name, by design. At close, the box is opened and the slips are counted in the open, where anyone can watch.
The register knows WHO. The box knows WHAT. The walk across the room, through a curtain, is the deliberate severing of who-from-what. No single person, no single record, holds both halves — and that's not an accident of the building, it's the entire security property.
Where the destroy-the-join rule runs
- Real elections, union ballots, shareholder votes — the register-and-box separation is centuries old; the online version must reproduce it, not "improve" it by linking.
- Anonymous feedback, whistleblower systems, double-blind review — anywhere "we know you participated, not what you said" is the requirement.
- The coupon system's per-user redemption limit is the mirror image: there you want the link (one coupon per user, tracked). Same two facts — identity and action — opposite decision about the join. Designing both teaches you that the join is a choice, not a default.
Step 1 — Functional requirements (sentences first)
What the system must do, as plain sentences — the functional requirements.
- Only registered, eligible voters may vote, and each may vote at most once.
- A ballot records a choice but carries nothing that identifies the voter.
- The system can prove a given voter has voted (to stop a second attempt) without revealing their choice.
- The tally is the count of ballots; it can be recomputed at any time and always agrees.
- Voting is open only between the poll's open and close times.
Step 2 — Non-functional requirements
At class level the non-functional requirements are different words for the same fight — how well, not just what — and for a voting system they are the design:
- Integrity / correctness. One eligible voter casts exactly one counted vote; the tally equals the ballots cast, no more, no less. No double-vote, no phantom slip, no dropped one.
- Thread-safety. Concurrent voters race on the same poll, and the danger is the classic check-then-record gap: two requests for the same voter both read "hasn't voted" and both record. The single-vote rule survives concurrency or it isn't a rule.
- Anonymity / auditability. A ballot can't be traced back to a voter, yet the system can still prove no one voted twice — which is only possible if the "has-voted" set and the ballot tally are kept apart and never joined.
- Extensibility. New election shapes — ranked choice, multi-select, a different eligibility source — plug in without rewriting the cast path.
Listing them is the easy half; the design only earns them if it fulfills them. Here's the contract — each requirement and the mechanism that keeps it:
| Requirement | How this design fulfills it |
|---|---|
| Integrity / correctness | the tally is derived from append-only ballots, never a stored counter that can drift — Step 5 |
| Thread-safety | one atomic tick on the roll (markVotedIfEligible) is the single-vote guard, not a check-then-set — Step 4 |
| Anonymity / auditability | two ledgers — a voter-id Set and a candidate tally — written separately and never joined — Step 3 |
| Extensibility | the poll, eligibility source, and ballot shape are seams the cast path doesn't hard-code — Step 3 |
Every trade-off below is chosen to keep one of these.
Step 3 — Nouns: two ledgers that must never be joined
- VoterRoll — the register: the set of eligible voter ids and whether each has voted. Knows WHO, never WHAT.
- BallotBox — the box: an append-only list of anonymous ballots, each
(receiptId, candidateId). Knows WHAT, never WHO. - Receipt — the deliberately weak link: a random token handed back to the voter. It proves a ballot exists; it cannot be traced to the voter (the roll never stores it against the name) and reveals nothing about the choice.
- ElectionService — the hall: enforces eligibility, the single-vote rule, and the open/close window — and routes the two facts to the two ledgers so they're never written together.
The temptation is a single Vote { voterId, candidateId } row. That one table, however well-guarded, is the join — and "we promise not to query it that way" is not a design, it's a hope.
Which structure — and why. WHO is a Set of voter ids that have voted — a set because membership-test-and-insert is the exact operation the single-vote rule needs, and add returning false on a duplicate is the dedupe and the race-guard in one call (thread-safety). WHAT is a separate candidate → count tally computed from an append-only list of ballots — separate from the roll on purpose, because the two structures sharing no key is precisely what makes the join un-writable (anonymity). The counts are plain integers, never a stored running total, so a recount re-derives them and they can't silently drift (integrity). And every choice routes through the ElectionService seam rather than the ledgers directly, so a new ballot shape changes one path, not both ledgers (extensibility).
Step 4 — Casting a vote: tick the register, drop the slip
The cast operation touches both ledgers, in an order chosen so the failure modes are safe.
public Receipt cast(String voterId, String candidateId) {
if (!clock.get().isBefore(poll.closesAt())) throw new ClosedException();
if (!poll.candidates().contains(candidateId)) throw new UnknownCandidateException();
if (!voterRoll.isEligible(voterId)) throw new NotEligibleException(voterId);
// 1) WHO: tick the register — this is the atomic guard against a second vote.
if (!voterRoll.markVotedIfEligible(voterId)) {
throw new AlreadyVotedException(voterId); // tick was already there
}
// 2) WHAT: drop an anonymous slip; the receipt is the only thread back, and it's thin.
Receipt receipt = Receipt.random();
ballotBox.add(new Ballot(receipt.id(), candidateId)); // NO voterId here. Ever.
return receipt;
}The order matters: tick first, then drop the slip. The tick is the check-then-act guard (the same welded race the rate limiter and inventory fought) — markVotedIfEligible must atomically test eligibility-and-unvoted and set voted, or two simultaneous requests both pass the check and you've issued two ballots. The crucial discipline is in the comment: voterId never enters the Ballot. Not encrypted, not hashed — absent. A hash of the voter id on the slip is still a join: anyone with the roll can hash each name and match. The only safe amount of voter identity on a ballot is none.
"We'll just encrypt the voter id on the ballot" fails the round. Encryption is reversible by whoever holds the key, so the join still exists — you've only moved it behind a key the operator controls. Anonymity that depends on an operator choosing not to decrypt is not anonymity. The slip must be born without identity; that's why we sever at write time, not read time.
Step 5 — The tally: derived, never stored
public Map<String, Long> tally() {
Map<String, Long> counts = new HashMap<>();
for (Ballot b : ballotBox.ballots()) { // count the slips, every time
counts.merge(b.candidateId(), 1L, Long::sum);
}
return counts; // a recount === the same numbers
}There is no running counter incremented as votes come in. The tally is computed from the slips on demand, which makes a recount free and meaningful — run it twice, get the same answer (derive, don't store, the rule the bar graph and splitwise also live by). A stored counter can drift from the ballots it claims to summarize; a derived tally is the ballots, so it cannot lie. The ballot box is append-only for the same reason — a slip, once dropped, is never edited or removed, so the count is reproducible forever.
Step 6 — Trade-offs (each one keeping an NFR)
The last column is the discipline: every choice keeps one of the promises from Step 2 — that's what designing to the non-functional requirements looks like.
| Decision | The obvious alternative | Why ours wins | Keeps |
|---|---|---|---|
| two ledgers, no join | one Vote{voterId, candidateId} row | the join can't be queried because it was never written | anonymity |
voterId absent from ballot | encrypted/hashed voter id on ballot | hash/cipher is still a link; absence is the only real anonymity | anonymity |
| atomic tick on the roll | check-then-set in two steps | blocks the double-vote race under concurrency | thread-safety |
| tally derived from slips | a counter incremented per vote | recount is free and authoritative; a counter can silently drift | integrity |
| append-only ballots | editable/removable ballots | the count is reproducible; audit means re-running, not trusting | auditability |
The complete implementation
package dev.fiveyear.voting;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
/** The contest: who's on the ballot and when the booth is open. */
public record Poll(String id, Set<String> candidates, Instant opensAt, Instant closesAt) {
public Poll {
candidates = Set.copyOf(candidates);
}
}
/** A slip in the box: a receipt id and a choice. NO voter identity, by design. */
public record Ballot(String receiptId, String candidateId) {}
/** The deliberately thin thread back: proves a ballot exists, traces to no one. */
public record Receipt(String id) {
public static Receipt random() {
return new Receipt(UUID.randomUUID().toString());
}
}package dev.fiveyear.voting;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** The register: WHO is eligible, WHO has voted. Never sees a choice. */
public final class VoterRoll {
private final Set<String> eligible;
private final Set<String> voted = new HashSet<>();
public VoterRoll(Set<String> eligible) {
this.eligible = new HashSet<>(eligible);
}
public synchronized boolean isEligible(String voterId) {
return eligible.contains(voterId);
}
/** Atomically: eligible and not-yet-voted? then tick. The single-vote guard. */
public synchronized boolean markVotedIfEligible(String voterId) {
if (!eligible.contains(voterId)) {
return false;
}
return voted.add(voterId); // false if the tick was already there
}
public synchronized boolean hasVoted(String voterId) {
return voted.contains(voterId);
}
}
/** The sealed box: append-only anonymous slips. Never sees a voter. */
public final class BallotBox {
private final List<Ballot> ballots = new ArrayList<>();
public synchronized void add(Ballot ballot) {
ballots.add(ballot);
}
/** A snapshot copy — callers tally it, they can't mutate the box. */
public synchronized List<Ballot> ballots() {
return new ArrayList<>(ballots);
}
}package dev.fiveyear.voting;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.function.Supplier;
public final class ElectionService {
public static class ClosedException extends RuntimeException {}
public static class UnknownCandidateException extends RuntimeException {}
public static class NotEligibleException extends RuntimeException {
public NotEligibleException(String voterId) {
super(voterId);
}
}
public static class AlreadyVotedException extends RuntimeException {
public AlreadyVotedException(String voterId) {
super(voterId);
}
}
private final Poll poll;
private final VoterRoll voterRoll;
private final BallotBox ballotBox;
private final Supplier<Instant> clock;
public ElectionService(
Poll poll, VoterRoll voterRoll, BallotBox ballotBox, Supplier<Instant> clock) {
this.poll = poll;
this.voterRoll = voterRoll;
this.ballotBox = ballotBox;
this.clock = clock;
}
public Receipt cast(String voterId, String candidateId) {
Instant now = clock.get();
if (now.isBefore(poll.opensAt()) || !now.isBefore(poll.closesAt())) {
throw new ClosedException();
}
if (!poll.candidates().contains(candidateId)) {
throw new UnknownCandidateException();
}
// Eligibility is static (the roll is fixed), so checking it here is safe…
if (!voterRoll.isEligible(voterId)) {
throw new NotEligibleException(voterId);
}
// …but the tick is the ATOMIC single-vote guard: exactly one caller wins.
if (!voterRoll.markVotedIfEligible(voterId)) {
throw new AlreadyVotedException(voterId);
}
// WHAT: an anonymous slip — no voterId crosses this line.
Receipt receipt = Receipt.random();
ballotBox.add(new Ballot(receipt.id(), candidateId));
return receipt;
}
/** Derived on demand: a recount is free and always agrees. */
public Map<String, Long> tally() {
Map<String, Long> counts = new HashMap<>();
for (String c : poll.candidates()) {
counts.put(c, 0L);
}
for (Ballot b : ballotBox.ballots()) {
counts.merge(b.candidateId(), 1L, Long::sum);
}
return counts;
}
}At the village hall:
Poll poll = new Poll("p1", Set.of("A", "B", "C"),
Instant.parse("2026-06-13T08:00:00Z"), Instant.parse("2026-06-13T20:00:00Z"));
Instant noon = Instant.parse("2026-06-13T12:00:00Z");
ElectionService election = new ElectionService(
poll, new VoterRoll(Set.of("asha", "dev", "mira")), new BallotBox(), () -> noon);
Receipt r1 = election.cast("asha", "A"); // ok — register ticked, anonymous slip dropped
election.cast("dev", "B"); // ok
election.cast("mira", "A"); // ok
election.tally(); // {A=2, B=1, C=0}
election.tally(); // {A=2, B=1, C=0} — recount agrees, every time
election.cast("asha", "C"); // throws AlreadyVotedException — tick already there
// r1 proves a ballot exists; nothing in the system maps r1 → "asha", and asha's row
// in the roll says only "voted", never "voted for A".The interview corner
Clarify before you code: What's the anonymity guarantee — is "the operator must not be able to link voter to choice" a hard requirement (it changes everything)? One choice or ranked/multi-select? Is a verifiable public audit (anyone can recount) in scope? Eligibility — fixed roll, or live registration?
The follow-up ladder:
- "Prove to me my vote was counted, without revealing it." The receipt is a commitment: publish the list of all receipt ids with their (anonymous) choices; the voter finds their receipt in the list. They verify inclusion; observers verify the tally; nobody links receipt to name. This is the seed of end-to-end verifiable voting — name "receipt = commitment" and you're speaking the literature's language.
- "Stop the operator from stuffing fake ballots." Ballots must only be issuable against a register tick, and the register's ticks must be auditable in aggregate ("3,000 ticked, 3,000 slips"). A count mismatch between ticks and slips is the tamper alarm — the two ledgers check each other precisely because they're separate.
- "Scale to a million concurrent voters." The roll shards by voter id (the tick is a per-key atomic op — a unique-constraint insert in a real DB); the box is an append-only log, the friendliest possible write. The tally becomes a map-reduce over the log. The separation that gives anonymity also gives clean horizontal scaling — point that out.
- "A voter votes twice in the same millisecond from two devices." The atomic tick is the whole answer —
voted.add/ a unique insert succeeds exactly once; the loser getsAlreadyVotedException. This is the inventory two-phase guard wearing a sash. - "Coercion — someone watches you vote and demands proof of your choice." The receipt is designed to be useless for this: it proves participation, not content, so you literally cannot prove to a coercer how you voted. That's a feature, and explaining why receipts deliberately reveal nothing is the deepest point in the whole question.
Mistakes that fail the round: a single Vote{voterId, candidateId} row (the join you swore not to build); hashing/encrypting the voter id onto the ballot (still a join); a running counter instead of a derived tally; a non-atomic check-then-set on the roll (double votes under load); ignoring the open/close window.
Where to go from here
Pocket version: the register knows who, the box knows what, the walk across the room severs the two, one atomic tick stops the second vote, and the tally is recounted from append-only slips so it can never drift.
- Add the public receipt list from the ladder and let a "voter" verify inclusion — it's the moment verifiable voting stops being abstract.
- Add a tick-count vs slip-count audit and then try to "stuff" a ballot — watch the two ledgers catch you.
- Next, Phase 3 closes with the web crawler — from severing a join to chasing links across the whole web, where "are we done?" is the trick question.
Two clerks who never compare notes, a curtain in the middle, and a receipt that proves everything except the one thing you want kept secret — that's online voting, and the join you didn't build is the design.