<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Works on My Production]]></title><description><![CDATA[Works on My Production]]></description><link>https://worksonmyproduction.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aab07cde187a1e1283e43fd/b08b2667-34e5-48e3-9b06-b7b8617888c7.png</url><title>Works on My Production</title><link>https://worksonmyproduction.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 20 Sep 2026 15:58:45 GMT</lastBuildDate><atom:link href="https://worksonmyproduction.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Event-Driven From Day One (Except the Part That Deliberately Wasn't)]]></title><description><![CDATA[Every system design interview eventually arrives at the same moment: someone draws a box, then draws a queue next to it, and says "and this part is event-driven" with the quiet confidence of someone w]]></description><link>https://worksonmyproduction.hashnode.dev/event-driven-from-day-one-except-the-part-that-deliberately-wasn-t</link><guid isPermaLink="true">https://worksonmyproduction.hashnode.dev/event-driven-from-day-one-except-the-part-that-deliberately-wasn-t</guid><category><![CDATA[System Design]]></category><category><![CDATA[kafka]]></category><category><![CDATA[event-driven-architecture]]></category><category><![CDATA[distributed systems]]></category><category><![CDATA[Backend Development]]></category><dc:creator><![CDATA[Gowtham Behara]]></dc:creator><pubDate>Fri, 18 Sep 2026 22:09:27 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/d7c506fe-61bd-4cba-9086-147a1b8d9036.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Every system design interview eventually arrives at the same moment: someone draws a box, then draws a queue next to it, and says "and this part is event-driven" with the quiet confidence of someone who has just won the interview. Kafka (or these days, its scrappier open-source cousin Redpanda) has become system design's equivalent of a firm handshake — you do it because you're supposed to, not always because the situation asked for it.</p>
<p>So here's the slightly unglamorous truth about how event-driven architecture actually entered this project: the part of the system that most obviously <em>sounds</em> like it should be event-driven — moderation, the thing that decides whether your review goes live — flatly isn't. It runs in a single Postgres transaction, synchronously, in-process, and it has stayed that way on purpose since early on. Kafka showed up later, for a completely different reason, and it took me writing this post to admit out loud that the reason was "I wanted the practice."</p>
<p>I think that's a more useful story than pretending I had the whole distributed-systems diagram in my head on day one. So let's do it in the order it actually happened.</p>
<h2>The part that stayed boring, deliberately</h2>
<p>When a candidate submits a rating, <code>RoundRatingsService.create()</code> inserts the rating and a <code>moderation_queue</code> row in the <em>same database transaction</em>. No queue, no broker, no "eventually" — either both rows exist or neither does. The original plan sketched moderation as a separate async worker reacting to events. That version never got built, and the reason is worth sitting with: nothing was asking for it. There was no load problem a synchronous write couldn't handle, and moderation decisions are exactly the kind of thing you don't want living in "probably arrives within a few seconds" land. A transactional write is a promise. A queue is a suggestion.</p>
<p>This is the least exciting sentence in the whole post and also, I'd argue, the most important system-design decision in this section: knowing where <em>not</em> to add a message broker is worth more than knowing how to configure one.</p>
<h2>Then I wanted a broker anyway</h2>
<p>Kafka/Redpanda showed up in a later phase for a reason that has nothing to do with moderation needing to be decoupled — it still doesn't. It showed up because I wanted real distributed-systems practice: a message broker two independent services could subscribe to, consumer groups, at-least-once delivery, the whole "now go build things that don't fall over when a message gets redelivered" problem set. That's an honest reason to add infrastructure. It's just not the reason system-design folklore usually cites, and I'd rather say that plainly than back into a more flattering story later.</p>
<p>Redpanda won over full Kafka for the boring, practical reason: it speaks the same wire protocol my Node client already expected, and running it locally means one container instead of a JVM plus a coordination layer. Nothing downstream of it cares which broker is underneath, which is the entire point of building against a protocol instead of a vendor.</p>
<p>Once the broker existed, every moderation-relevant write — a rating created, a status changed to approved or rejected — started publishing a versioned event, still safely <em>after</em> its own transaction had already committed. The synchronous write path never became dependent on the broker being up. Nobody's review submission should ever fail because a message queue is having a bad day.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/40b0fcd2-b1a8-4f9b-9c6f-985ef5e8ded7.png" alt="Sequence diagram: a rating submission flowing through Postgres, Redpanda, notification-service, and review-analyzer" style="display:block;margin:0 auto" />

<h2>The first two people who showed up to the party</h2>
<p>Two services subscribe to those events, and they demonstrate two completely different lessons about building on top of an at-least-once broker.</p>
<p><strong>notification-service</strong> exists to send one email: "your submission is pending review." The interesting part isn't the email, it's that Redpanda makes no promise about delivering that event exactly once — only <em>at least</em> once — which means the same event can show up twice, and a candidate should never get the same email twice because a network blip caused a redelivery. The fix is a <code>NotificationLog</code> table with a unique constraint on <code>(entityType, entityId, eventType)</code>, checked once as a fast-path read and enforced for real by catching the database's own duplicate-key error on insert. The lock isn't a clever distributed algorithm — it's just Postgres doing what Postgres has always done, which is often the correct answer to "how do I make this idempotent" before you reach for anything fancier.</p>
<p>Building this also surfaced a real schema gap that had nothing to do with Kafka: the service had a <code>candidateId</code> and needed an email address, and the only thing on file was an HMAC hash designed to be <em>unreversible</em> — useful for "does this email already have an account," useless for "send this address a message." That's what a reversible, separately-keyed encrypted copy of the address is for, added specifically because a background consumer, unlike a synchronous request, can't rely on already having the plaintext address sitting in scope.</p>
<p><strong>review-analyzer</strong> is the more interesting story, because it started as the setup for a joke about async infrastructure and ended up as a lesson about write authority. Its job is to run LLM-based triage on every submission and decide whether it looks concerning — but for a while, an <em>identical</em> version of that logic still lived inside <code>api</code> itself, running synchronously on the same write path. Porting the async version in and leaving the old one running would have looked like a careful, incremental migration. It would also have meant two independent code paths were each capable of auto-approving the same submission — a real double-approval bug waiting for a Tuesday to happen, not a style nitpick. The old in-process version had to be deleted outright, not just bypassed, the same day the new one shipped. <code>review-analyzer</code> computes and publishes a verdict; it never touches the moderation table directly. <code>api</code> remains the only thing on Earth allowed to actually approve or reject a submission — the event is an opinion, not a permission slip.</p>
<h2>The bug that would have failed silently, forever</h2>
<p>Here's my favorite kind of bug: the one that isn't a bug yet. A design review — not a production incident — noticed that the service publishing all these events connected to the broker exactly once, at boot. If the broker wasn't up yet when the app started (a routine local-dev race), or if a healthy connection later dropped for any reason, every subsequent publish call would just quietly return early forever. No crash, no error log anyone would notice, no retry. The app would keep serving requests, moderation would keep working, and it would look completely fine — right up until someone eventually asked "wait, why has nobody gotten a notification email in three days?"</p>
<p>The fix is almost insultingly simple: listen for the broker client's own <code>DISCONNECT</code> event to know the connection is actually dead (a try/catch around one send tells you nothing about the <em>next</em> one), and run a self-healing reconnect check every 30 seconds forever, whether the failure was "never connected" or "connected, then dropped." It's a few lines of code guarding against an entire category of "everything looks fine, nothing is happening" failure — the kind of gap that never shows up in a demo, because a demo doesn't run long enough for a connection to quietly die.</p>
<h2>Teaching a free local model to grade a paid one</h2>
<p>The newest chapter of this story is the one I like most, because it's a small, honest lesson about evaluating AI systems without either trusting them blindly or spending real money to check your own homework. Once the LLM-triage pipeline existed, a natural question showed up: is it actually <em>good</em>, or does it just look good in the three examples I happened to glance at? Building a real evaluation harness against the same paid model doing the triage would cost tokens every single run — which quietly discourages ever running it. So the harness runs entirely against a free local model instead, checked live end to end at 14/14 correct on a hand-labeled test set, with an average "does this reasoning actually make sense" score of 4.79 out of 5 from a second local model acting as judge.</p>
<p>That second number comes with an asterisk I think is worth keeping in the post rather than editing out: on one run, the judge's lowest score flagged a verdict as inventing a detail that "wasn't in the source." Reading the actual text by hand, it was in the source — the judge was wrong, not the model under test. A single small model's opinion is a prompt to go look closer, not a number to report on a dashboard and walk away from. That distinction — between "I ran an eval" and "I know what my eval can and can't actually tell me" — is, I'd argue, most of what separates a real evaluation practice from eval-shaped theater.</p>
<h2>Why this is the part I actually wanted to write about</h2>
<p>"Event-driven" is an easy phrase to say and a much harder architecture to actually justify, verify, and keep honest once at-least-once delivery, silent disconnects, and double-write races enter the picture. The interesting engineering here was never "we added Kafka" — it's the decision <em>not</em> to add it to the one place that most wanted it, and then, once it existed for a good reason, taking idempotency, connection health, and write authority as seriously as the happy path.</p>
<p>Next up in this series: the least glamorous part of the entire system — moderation queue SLAs, claim logic, and the plumbing nobody claps for that's actually what separates a trust-and-safety feature from a trust-and-safety <em>demo</em>.</p>
]]></content:encoded></item><item><title><![CDATA[Designing trust into the schema, not bolting it on later]]></title><description><![CDATA[If you build a platform where anonymous strangers rate named individuals, you have built a defamation machine with a nice UI. That's not a hypothetical — it's the first thing that occurred to me befor]]></description><link>https://worksonmyproduction.hashnode.dev/designing-trust-into-the-schema-not-bolting-it-on-later</link><guid isPermaLink="true">https://worksonmyproduction.hashnode.dev/designing-trust-into-the-schema-not-bolting-it-on-later</guid><category><![CDATA[systemdesign]]></category><category><![CDATA[database design]]></category><category><![CDATA[Security]]></category><category><![CDATA[Backend Development]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Gowtham Behara]]></dc:creator><pubDate>Thu, 17 Sep 2026 19:42:25 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/6baa2028-087f-421b-afa3-f288eee44ed6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If you build a platform where anonymous strangers rate named individuals, you have built a defamation machine with a nice UI. That's not a hypothetical — it's the first thing that occurred to me before I wrote the database schema for Interview Insights, and it shaped more of this system than any other single realization.</p>
<p>Here's the uncomfortable version of the product I <em>didn't</em> build: candidates log in, rate "John from Engineering" a 1/5, write "he was condescending and clearly didn't read my resume," and that goes live to the internet under John's actual name, searchable, permanent, and produced by a system with zero verification that John was even having a bad day versus being systematically unfair. That system would get someone sued — the platform, probably, and possibly the candidate too. It would also, deservedly, deserve it.</p>
<p>So this post is about the three things that actually stand between "review platform" and "liability engine," and why all three had to be architectural decisions, not UI features you could accidentally ship without.</p>
<h2>1. Anonymize identity, not accountability</h2>
<p>Interviewers and recruiters <em>are</em> stored as real entities in this system — that's not a contradiction, it's the whole trick. An <code>interviewers</code> row exists so that ratings can roll up correctly ("this interviewer's fluency score across 40 rounds") and so the same person doesn't get treated as forty different strangers across forty different candidate submissions. But the row has no <code>name</code> column. It has <code>internal_identifier_hash</code> (for de-duplication) and <code>display_label</code>, a generated string like "Interviewer A" or "Round 2 Interviewer."</p>
<p>The point isn't that a UI layer hides the name from you. The point is that the name was never captured in the first place — there is nothing to leak, because there is nothing stored to leak. A bug in a future API endpoint, a careless <code>SELECT *</code>, a debug log statement someone forgets to remove — none of those can expose a real name, because the schema makes it structurally impossible. That's a materially different, and much stronger, guarantee than "we redact this in the response serializer," which is one missed code review away from an incident.</p>
<h2>2. Nothing is public until a human — or a sufficiently confident model — says so</h2>
<p>Every rating and review table in this system ships with a <code>status</code> column defaulting to <code>pending</code>, and it's been there since the very first database migration, before a single line of moderation logic existed to act on it. That ordering matters: the column wasn't added in response to a fraud incident. It was there from day zero because the plan was never "launch open, add moderation later." Every public read path filters on <code>status = 'approved'</code>. There is no code path that serves an unmoderated submission to a stranger.</p>
<p>The write path and the moderation enqueue happen in the same database transaction:</p>
<pre><code class="language-ts">create(roundId: string, dto: CreateRoundRatingDto) {
  return this.prisma.$transaction(async (tx) =&gt; {
    const flagReason = await this.fraudChecksService.detectFlagReason(dto, tx);
    const rating = await tx.roundRating.create({ data: { ...dto, roundId } });
    await this.moderationService.enqueue('round_rating', rating.id, tx, flagReason);
    return rating; // one transaction: the write and the enqueue, atomic
  });
}
</code></pre>
<p>Why does this matter? Because the alternative failure modes are both bad: a rating that gets written but never enqueued would be content that can <em>never</em> become visible, since nothing would ever review it. A queue entry pointing at a rating that failed to insert would be a review task for nothing. Wrapping both in one transaction means neither failure mode can happen — a rating either fully exists with a pending review task, or neither exists at all.</p>
<p>A confidence-scored LLM pass (an Anthropic model, evaluated against the exact same criteria a human moderator uses) now handles the obviously-clean tail of this queue automatically — but notice the design constraint that survived the AI layer's introduction unchanged: the model doesn't get a bypass, it gets a <em>confidence threshold</em>. Below that threshold, or on anything flagged <code>concerning</code>, a human still reviews it, exactly as before. The system's fail-safe direction has stayed pointed the same way through every phase: when in doubt, a human decides, never the reverse.</p>
<h2>3. A raw average is a lie you tell with math</h2>
<p>Here's a scenario: a company has exactly one review, and that reviewer had a genuinely bad day and gave every score a 1. Do you show that company a 1.0-star rating? Obviously not — that's not "this company is bad," that's "n=1 and noise dominates." But where's the actual cutoff? Hide everything below n=5? Then a company with 4 reviews shows nothing, and the moment review #5 lands, you show a raw average that's <em>still</em> barely more meaningful than the one you were hiding a second ago. That's a misleading cliff, not a fix.</p>
<p>The actual approach is shrinkage — pull small samples toward the platform-wide average, and let them converge to the company's true average as real signal accumulates:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/dc975860-5f67-4210-a135-1fac8d6f597a.png" alt="Shrinkage-scoring flowchart: raw ratings blended with the platform-wide average, weighted by sample size" style="display:block;margin:0 auto" />

<p>With <code>k</code> (a tunable confidence constant, starting around 8–10) and a company with only 1 review, the formula pulls that score heavily toward the global average — one bad-day reviewer can't tank a company's displayed number by themselves. As real reviews accumulate, the weight shifts naturally toward the company's own true signal. And there's still one hard floor underneath all of it: below <code>n = 3</code>, the score isn't shown <em>at all</em> — the API returns <code>null</code>, and the frontend shows "not enough reviews yet" instead of a number. Sample size is always shown alongside the score, never hidden — the goal is honest uncertainty, not a false sense of precision.</p>
<h2>The lifecycle, end to end</h2>
<p>Put it all together, and a single rating's life looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/be71327e-6028-4691-86e5-15199b654e41.png" alt="Rating lifecycle flowchart: submission through fraud check, AI triage, human moderation, to shrinkage-scored aggregates" style="display:block;margin:0 auto" />

<p>Every box in that diagram exists because leaving it out creates a specific, describable harm — a name leak, a permanently-public unreviewed accusation, or a misleading score. None of them are decorative.</p>
<h2>The part I'm actually proud of: finding my own bug</h2>
<p>None of the above is worth much if the implementation has races in it, so here's an honest one. An internal audit later found that <code>ModerationService.review()</code> — the method behind every approve/reject/flag action, including the AI auto-approval path — had a check-then-act race: it read a queue entry, checked whether it was already reviewed, and <em>then</em> wrote the decision, as two separate round trips with no lock held between them. Two moderators clicking "approve" within milliseconds of each other (or a moderator racing the AI auto-approval consumer) could both pass the "not yet reviewed" check before either committed — and both proceed to flip the same entity's status, in the worst case sending a candidate an "approved" email and a "rejected" email for the same submission.</p>
<p>The fix is a small, specific pattern worth knowing regardless of what you're building: fold the check into the write's own <code>WHERE</code> clause, so the database's row lock does the serializing instead of application code racing itself.</p>
<pre><code class="language-ts">const { count } = await tx.moderationQueueEntry.updateMany({
  where: { id, reviewedAt: null },   // the check IS the write now
  data: { reviewedAt: new Date(), reviewedBy: dto.reviewedBy, flagReason },
});
if (count === 0) throw new ConflictException('Already reviewed.');
</code></pre>
<p>I'd rather tell you about the race condition I found and fixed than pretend the first version was perfect. A system that claims to be trustworthy and then hides its own bugs is a worse system than one that's honest about having found and closed one.</p>
<h2>Why this matters</h2>
<p>Every one of these decisions has a name behind it on both sides of the transaction — a candidate whose honest account of a bad interview shouldn't get buried under legal risk, and an interviewer who shouldn't have their name permanently attached to an unverified, un-reviewable accusation. Trust isn't a feature you add once the "real" product works. In a system like this, it <em>is</em> the product — the schema either protects both of those people by construction, or it doesn't, and no amount of nice UI afterward fixes a bad answer to that question.</p>
<p>Next up: why the write path stayed a plain, synchronous Postgres transaction even after a Kafka-style message broker showed up in this system — and the very specific, very honest reason a broker got added anyway.</p>
]]></content:encoded></item><item><title><![CDATA[Why "it was hard, 3/5 stars" isn't a review]]></title><description><![CDATA[Somebody once left a review of a technical screen that said, in full: "Difficult but fair. 3/5."
I have no idea what happened in that interview. Neither do you. Neither, six months later, does the per]]></description><link>https://worksonmyproduction.hashnode.dev/why-3-out-of-5-stars-isnt-a-review</link><guid isPermaLink="true">https://worksonmyproduction.hashnode.dev/why-3-out-of-5-stars-isnt-a-review</guid><category><![CDATA[System Design]]></category><category><![CDATA[database design]]></category><category><![CDATA[Backend Development]]></category><category><![CDATA[software architecture]]></category><category><![CDATA[PostgreSQL]]></category><dc:creator><![CDATA[Gowtham Behara]]></dc:creator><pubDate>Wed, 16 Sep 2026 21:59:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/32407106-0e13-4c9d-80b9-801263a375e6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Somebody once left a review of a technical screen that said, in full: "Difficult but fair. 3/5."</p>
<p>I have no idea what happened in that interview. Neither do you. Neither, six months later, does the person who wrote it. Was the interviewer rude? Was the <em>problem</em> hard, or was the <em>interviewer</em> hard to understand? Did "fair" mean "unbiased" or just "not a trick question"? A number from 1 to 5 collapsed an hour of another human being's working life into a piece of information roughly as useful as a fortune cookie.</p>
<p>This is the problem I actually set out to solve before I wrote a single line of backend code: an interview loop isn't one experience, it's a sequence of them — a phone screen, a system design round, a behavioral round, a recruiter who either ghosts you or doesn't — and a single aggregate rating throws away exactly the information you'd want if you were the next candidate walking in.</p>
<p>So the very first decision I made on this project wasn't a framework or a database. It was a data model. And the data model is, honestly, most of the product.</p>
<h2>Start with the shape of the thing you're actually modeling</h2>
<p>Here's the entity hierarchy the entire system is built around:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aab07cde187a1e1283e43fd/412113f5-7772-45c6-b0b9-e35b0724b9e8.png" alt="Entity relationship diagram: Company, InterviewProcess, Round, RoundRating, RecruiterInteraction, RecruiterRating, OverallReview" style="display:block;margin:0 auto" />

<p>Notice what this is <em>not</em>: it's not a <code>Review</code> table with a <code>stars</code> column and a <code>text</code> field. It's a tree. A <code>Company</code> has many <code>InterviewProcess</code>es (one per candidate's application loop). A process has many <code>Round</code>s in sequence, and separately, <code>RecruiterInteraction</code>s. Each round can carry exactly one rating from the candidate who sat it. There's also one <code>OverallReview</code> per process — a summary, but a summary that sits <em>alongside</em> the granular data, never a replacement for it.</p>
<p>Getting this hierarchy right on day one mattered more than almost anything else in the project, for a boring but important reason: five phases of feature work later — moderation, analytics, search, an AI triage pipeline — nothing has ever needed a breaking schema change to this core tree. Only additive changes. That's not luck. It's the payoff of spending real time on the data model before writing a single API endpoint.</p>
<h2>The rating fields are an argument, encoded as columns</h2>
<p>The most interesting design decision isn't the hierarchy, it's what's <em>inside</em> a <code>RoundRating</code>. Early on, a round rating had five fields: <code>difficulty</code>, <code>fairness</code>, <code>communication_fluency</code>, <code>attentiveness</code>, <code>bias_signal</code>. It read like a survey designed by committee, because early on, it kind of was.</p>
<p>A later UX pass killed two of those fields outright and renamed the rest, landing on the four that ship today:</p>
<ul>
<li><p><code>difficulty</code> — an axis about the <em>round</em>, not the interviewer. How hard was the problem.</p>
</li>
<li><p><code>fluency</code> — an axis about the <em>interviewer</em>. Could they actually communicate.</p>
</li>
<li><p><code>clarity</code> — was the problem statement itself clear, or did you spend ten minutes decoding what was even being asked.</p>
</li>
<li><p><code>focus</code> — was the interviewer paying attention, or checking Slack under the table.</p>
</li>
</ul>
<p>Why cut <code>fairness</code> and <code>bias_signal</code>? Because they don't hold up as <em>self-reported, single-question</em> ratings. "Was this interview fair" is a conclusion, not an observation — it's the thing you'd <em>derive</em> from a pattern across many fluency/clarity/focus scores over time, not something one candidate can honestly rate about their own hour-long experience without just projecting their overall outcome onto the question. Asking directly just captures "did I get an offer," dressed up as a bias metric. It's a subtler trap than it sounds, and cutting it was the right call.</p>
<p>That's the kind of decision that never shows up in a demo, and it's exactly the kind of decision I want a hiring manager reading this to notice: the schema isn't just "what fields does the form have," it's a stance on what's actually measurable.</p>
<h2>Type-specific data without a column explosion</h2>
<p>A coding round wants to record which algorithms and data structures came up. A case study round wants to record which frameworks and what industry context. A behavioral round wants a framework used (STAR, etc.) and focus areas. None of those belong as top-level columns on <code>rounds</code> — that's a table that would grow a new nullable column every time someone adds a ninth interview format, and eventually look like a spreadsheet that gave up.</p>
<p>Instead, <code>rounds.type_metadata</code> is a single JSONB column, and its <em>shape</em> is defined by a round-type registry in code — a lookup table mapping <code>round_type</code> to an explicit list of expected fields and their kind (free text vs. a controlled, admin-managed vocabulary). Adding a ninth round type later is "add an enum value and a schema entry," not "write a migration."</p>
<pre><code class="language-json">// a coding round
{ "problemAlgorithms": ["DFS", "BFS"], "problemDataStructures": ["Graph"] }

// a leadership round
{ "principlesAsked": ["Ownership", "Deliver Results"] }
</code></pre>
<h2>What Postgres enforces so the application never has to lie</h2>
<p>A few constraints are worth calling out because they're the difference between "the API validates this" and "this cannot physically happen":</p>
<ul>
<li><p>Every 1–5 rating column has a real <code>CHECK</code> constraint at the database level. Prisma's schema language can't express <code>CHECK</code> directly, so these were hand-appended to the generated migration SQL — the one deliberate exception to an otherwise fully auto-generated schema.</p>
</li>
<li><p><code>UNIQUE(round_id, candidate_id)</code> means a candidate cannot insert a second rating for the same round. Not "the frontend disables the button" — the database rejects the insert.</p>
</li>
<li><p>Every rating and review table has a <code>status</code> column, defaulting to <code>pending</code>, from the very first migration — before a moderation system existed to act on it. That's the subject of the next post in this series.</p>
</li>
</ul>
<p>None of this is architecture for architecture's sake. It's what lets me say, honestly, that a company's aggregate score is built from real atomic rows that were never allowed to be malformed, duplicated, or invented — which turns out to matter enormously once you start asking "can I trust this number," which is exactly the question the next post is about.</p>
<h2>Why this matters</h2>
<p>A candidate who bombs a system design round because the <em>interviewer</em> couldn't articulate the problem deserves a system that can say that — separately from whether the algorithm question itself was hard, and separately from whatever their overall outcome was. A generic star rating erases that distinction entirely, which means it erases exactly the information a future candidate would find useful, and exactly the accountability a company would find uncomfortable. Building the schema to preserve that distinction from day one was the actual product decision. Everything else is implementation.</p>
<p>Next up: what happens to one of these ratings between the moment a candidate hits submit and the moment it's public — and why "never expose a real interviewer's name" had to be a schema decision, not a UI filter.</p>
]]></content:encoded></item></channel></rss>