Skip to content

9.7.22 — The Lending & Enrolment Family

"Design a library management system." · "Design a course registration system."

A library has four copies of one book and twelve people who want it. A university has thirty seats in one class and three hundred students who need it. Both problems hand you a small pile of interchangeable things, a much larger pile of people who want them, and a set of rules about who is allowed to take one. Both then ask the question that decides the design: what happens to the eleventh person?

The two problems separate on exactly three points, and everything else they share. This page teaches the shared skeleton once, then works each difference properly.

1. The three questions that separate them

LibraryCourse registration
How long is it heldWeeks, ends when returnedA term, ends on a date
Who decides the orderWhoever asks firstSometimes rank, not arrival
Shape of the loadSteady all yearEverything in ten minutes

How long it is held is the biggest one. A library loan ends when a human walks back through the door, so the return time is unknown and the waiting list has to survive that uncertainty. A course enrolment ends on a date printed in the calendar, so the system always knows when the seat comes back.

Who decides the order is the one people miss. A library queue is first come, first served and nobody argues. Registration is often not: final-year students may be served before first-years, and some universities run a lottery instead of a race. That single sentence changes whether the design is a queue or a batch.

The shape of the load decides how much of the design is about concurrency. Library borrowing is a handful of events a minute. Registration opens at nine o'clock and every student in the university presses the same button at the same second.

2. The title is not the copy, and the course is not the class

This is the same modelling split as the cinema seat in 9.7.9, and getting it wrong is what makes every later question unanswerable.

typescript
interface Book {                    // (1)
  isbn: Isbn;
  title: string;
  authors: string[];
}

interface BookCopy {                // (2)
  id: CopyId;
  isbn: Isbn;
  branchId: BranchId;               // (3)
  condition: Condition;
  state: CopyState;                 // (4)
}

(1) Book is the work. It has a title and an author, and it cannot be borrowed, because "The Hobbit" is not a physical object.

(2) BookCopy is the object on the shelf, and it is the only thing a person can carry home. Four copies of one book means four rows here and one row above.

(3) Every copy lives at a branch. This is why the split matters even before anything is borrowed: "do you have this book?" and "do you have this book here?" are different questions, and only the second one is useful to someone standing in a building.

(4) The copy's state is about the object itself — on the shelf, lent out, being repaired, lost. It is not about who has it, which is the next section's job.

Course registration has the identical split, with different words:

typescript
interface Course {                  // (1)
  code: CourseCode;                 // "CS-201"
  title: string;
  credits: number;
  prerequisites: CourseCode[];      // (2)
}

interface Section {                 // (3)
  id: SectionId;
  courseCode: CourseCode;
  termId: TermId;                   // (4)
  capacity: number;
  meetings: Meeting[];              // (5)
  instructorId: StaffId;
}

(1) Course is the subject in the catalogue. It is never full, because a subject has no seats.

(2) Prerequisites belong to the course, not to a section, because the rule "you must pass CS-101 first" is true no matter which sitting you attend.

(3) Section is the actual class: one term, one room, one timetable, one capacity. This is the thing that fills up.

(4) A term identifier is on the section rather than the course for the same reason ShowSeat carried a show identifier: the same course runs again next year and its capacity resets.

(5) A meeting is a day and a time range. This list is what makes timetable clashes detectable, and section 6 uses it.

Say the split out loud in the interview, because it is the load-bearing decision: the catalogue entry and the thing with capacity are two different tables. Candidates who put availableCopies: number on Book cannot answer where a copy is, cannot tell you who has it, and cannot explain what happens when one is damaged.

3. A loan is a record, never a flag

The tempting shortcut is a borrowedBy field on the copy. It works exactly until someone asks a question about the past.

typescript
interface Loan {
  id: LoanId;
  copyId: CopyId;                   // (1)
  memberId: MemberId;
  borrowedAt: Instant;
  dueAt: Instant;                   // (2)
  returnedAt: Instant | null;       // (3)
  renewalCount: number;             // (4)
}

(1) The loan points at the copy, not the book. Two people can hold "The Hobbit" at once; nobody can hold copy 4471 twice.

(2) The due date is stored on the loan rather than computed from the borrow date, because the rule that produced it can change. A member who borrowed under a three-week policy keeps their three weeks when the library switches to two, and that is only possible if the answer was written down at the time.

(3) null means still out. This one nullable field is what turns a flag into a history: the row survives the return, so "who had this copy in March" has an answer.

(4) Renewals are counted because the rule is almost always "you may renew twice, and not at all if someone is waiting."

The copy's state is now derived, and the derivation is the invariant: a copy is on loan exactly when a Loan row exists for it with returnedAt null. Storing CopyState.OnLoan as well is fine as a cached value, under the same three conditions this book applies everywhere — written in the same transaction, rebuildable by recomputation, and alerted on when the two disagree (9.7.18).

Enrolment is the same shape with a different ending. An Enrolment row carries a section, a student, a state (enrolled, waitlisted, dropped, completed) and the timestamps of each change. It is never deleted when a student drops, because a drop after the deadline appears on the transcript, and a deleted row cannot appear anywhere.

4. Who may take one: the rules that say no

Both systems reject far more requests than they accept, and the rejections are the interesting part. The mistake is to write them as one tangled if. Write them as a list of independent checks, each of which returns a reason.

typescript
type Denial =
  | { code: "limitReached"; limit: number }              // (1)
  | { code: "hasOverdue"; oldestDueAt: Instant }
  | { code: "unpaidFines"; amount: Money }
  | { code: "membershipExpired"; expiredAt: Instant }
  | { code: "alreadyHolds"; loanId: LoanId };            // (2)

type BorrowRule = (m: Member, book: Book, now: Instant) => Denial | null;  // (3)

const rules: BorrowRule[] = [                            // (4)
  loanLimitRule, overdueRule, fineRule, membershipRule, duplicateCopyRule,
];

function checkBorrow(m: Member, b: Book, now: Instant): Denial[] {
  return rules.map(r => r(m, b, now)).filter((d): d is Denial => d !== null);  // (5)
}

(1) Each denial carries the number the member needs in order to fix it. "You cannot borrow" is a bad error; "you are holding 5 of your 5 books" tells them what to do next.

(2) A member holding one copy of a book should not take a second copy of the same book. This is a rule people forget until a member checks out the whole shelf.

(3) Every rule has the same shape: given a member, a book and the time, either explain the refusal or return null for "no objection from me." That uniform shape is what lets the list be extended without touching the code that runs it — the plug point from 9.3.6.

(4) The list is the policy. A branch that allows ten books instead of five changes data, not code. A university that adds "no enrolment while you owe library fines" adds one entry.

(5) Collecting all the denials rather than stopping at the first is a deliberate choice. A student who is blocked by three separate things should be told all three at once, or they fix one, try again, and get blocked by the next — three round trips to learn what one screen could have said.

Registration's rule list is longer and two of the rules are genuinely harder: prerequisites need the student's completed courses, and a timetable clash needs the meeting times of everything they are already enrolled in. Section 6 works both.

5. The waiting list, and the moment it becomes a hold

Eleven people want the last copy. The design question is what the system promises them.

① a copy comes backcopy 4471returned② held for the first in lineheld for Ana — 48 hoursnot on the shelf, not lent③ Ana collectshold becomes a loan③ 48 hours passoffered to Benthe copy is unavailable to everyone else during the hold, and that is the cost of the promiseA queue with no hold is not a queue: the copy goes back on the shelf and the twelfth person, who happens to be standing there,takes it. A hold with no expiry is worse: one person who never comes back stops the queue permanently.the same three states appear in registrationA dropped seat is offered to the first waitlisted student for a fixed window, then passes on. The only difference is thatregistration usually auto-enrols instead of waiting for a collection, because there is nothing physical to fetch.
Figure 1 — A returned copy passing down the waiting list. The middle box is the part that must exist: a returned copy is held for one named person for a bounded time, and the bound is what stops one unresponsive member freezing the queue.

A reservation is a row with a position, and the position is the promise:

typescript
interface Reservation {
  id: ReservationId;
  isbn: Isbn;                       // (1)
  memberId: MemberId;
  branchId: BranchId;
  placedAt: Instant;
  state:
    | { kind: "waiting" }                                        // (2)
    | { kind: "held"; copyId: CopyId; expiresAt: Instant }       // (3)
    | { kind: "fulfilled"; loanId: LoanId }
    | { kind: "cancelled"; reason: string };
}

(1) The reservation is for the book, not a copy, because the member does not care which copy they get. This is the whole reason the split in section 2 was worth making: the queue is on the title and the fulfilment is on a copy.

(2) Waiting means "in the queue and no copy is yours yet." Position is computed by ordering the waiting reservations for this book and branch by placedAt. Storing an integer position instead is a classic self-inflicted wound: every cancellation would have to renumber everyone behind it.

(3) Held means a specific copy is now yours until expiresAt. The copy is unavailable to everybody else during this window, which is exactly the promise, and exactly the cost.

When a copy is returned, the return path does one extra thing: look for the oldest waiting reservation for that book at that branch, and if one exists, move the copy into a hold instead of onto the shelf. That single step is what makes the queue real rather than decorative.

Expiry works the same way as the cinema hold in 9.7.9 — by being ignored, not deleted. A hold whose expiresAt has passed is treated as gone by every reader, so a member arriving one second late is refused by the check rather than by a race with a cleanup job. A background job then passes the copy to the next person, and because it re-reads the state, running it twice does nothing the second time.

Two rules that stop the queue being gamed, both worth volunteering:

A member may not hold a copy and sit in the queue for the same book. Otherwise the moment they borrow they re-queue, and they are permanently at the front.

A hold that expires unclaimed counts against the member. Three no-shows and the ability to reserve is suspended for a month. Without this, reserving is free and people reserve everything.

6. The two rules registration has that lending does not

Prerequisites

The check is "has this student completed every course this one requires?", and the trap is that "completed" is not "enrolled."

typescript
function missingPrerequisites(
  student: StudentRecord, course: Course, term: TermId,
): CourseCode[] {
  const passed = new Set(                                        // (1)
    student.transcript
      .filter(r => r.grade.isPass && r.termId !== term)          // (2)
      .map(r => r.courseCode),
  );
  return course.prerequisites.filter(p => !passed.has(p));       // (3)
}

(1) Build the set of passed course codes once, so the check below is a lookup rather than a scan per prerequisite.

(2) Two conditions, and the second is the one people miss. A pass is required, so an enrolment in progress does not count — and a course being taken in the same term does not count either, because it has not finished yet. Without that filter a student registers for CS-101 and CS-201 in the same term and the system approves it.

(3) Return the missing codes rather than a boolean. The screen can then say "you still need CS-101", which is the difference between a usable system and a support ticket.

The honest complication: universities allow overrides. A department head can waive a prerequisite, and a final-year student can take a course "concurrently" with its prerequisite. So the real answer is a stored override per student per course, checked before the denial is raised, and recorded with who approved it. Saying this unprompted shows you have thought about the institution rather than only the rule.

Timetable clashes

Two sections clash when they meet on the same day at overlapping times.

typescript
interface Meeting { day: Weekday; start: MinuteOfDay; end: MinuteOfDay; }

function overlaps(a: Meeting, b: Meeting): boolean {
  return a.day === b.day && a.start < b.end && b.start < a.end;  // (1)
}

function clashesWith(candidate: Section, current: Section[]): Section[] {
  return current.filter(s =>                                     // (2)
    s.meetings.some(m => candidate.meetings.some(c => overlaps(m, c))),
  );
}

(1) The overlap test is the standard one for two ranges, and the strict < on both sides is deliberate: a class ending at 10:00 and another starting at 10:00 do not clash. Using <= here makes back-to-back classes impossible to register for, which is a bug students will report on the first day.

(2) Return the clashing sections, not true. The student needs to know what they clash with in order to choose.

The part that catches people out: exams. Two courses can have compatible weekly meetings and a final exam at the same hour, and a university that does not check this discovers it in December. Whether it is in scope is a good clarifying question to ask at minute three.

7. The two races, and where each is settled

Library: two members request the last copy at the same moment. Rare, and easily handled. The claim is a conditional write on the copy — mark it lent only if it is currently on the shelf — so exactly one of the two updates affects a row and the other is told the copy has gone. Nothing more elaborate is needed, because contention here is genuinely low.

Registration: three hundred students hit the same section in the same second. This is a different problem, and the answer that goes wrong is a read-then-write:

typescript
// this is the bug, shown so it is recognisable
const section = await load(sectionId);
if (section.enrolled < section.capacity) {           // (1)
  await enrol(studentId, sectionId);                 // (2)
  await bumpCount(sectionId);
}

(1) Every one of the three hundred requests reads the same count of 29, and every one of them concludes there is room.

(2) Every one of them enrols. The section ends up with 300 students in 30 seats, and the count says 30 because the increments were also lost.

The fix is to let the database decide, in one statement:

sql
UPDATE sections
   SET enrolled = enrolled + 1
 WHERE id = :sectionId
   AND enrolled < capacity;              -- (1)

(1) The condition and the increment happen inside one statement on one row, so the row lock serialises the three hundred attempts and exactly thirty of them affect a row. The rest affect zero rows, which is the signal to put the student on the waiting list. This is the same conditional-claim shape as the wallet's conditional debit in 9.7.10 and the seat claim in 9.7.9, and recognising it as the same move across three problems is worth more than memorising any one of them.

The second race is the one nobody mentions: a student clicking "enrol" twice. A unique index on (studentId, sectionId) makes the second attempt fail at the database rather than producing two enrolments and two seats consumed. Every one of these systems needs that index, and it is one line.

Then the honest ceiling. One row per section is a single point of serialisation, so a wildly popular section is limited by how fast one row can be updated — a few thousand a second, which is fine for a university and would not be for a concert. If the interviewer pushes on scale, the move is to split the capacity into buckets, claim from a bucket, and accept that a nearly full section needs a second pass over the buckets to find the last seat. Naming both the ceiling and the escape is a better answer than pretending the ceiling is not there.

8. Time, money and the jobs that run at night

Fines are computed, not stored — until they are charged. An overdue amount is a function of the due date, the return date and the rate, so it can be recomputed at any moment. The moment money is actually taken, that becomes a ledger entry and stops being derived, for the reasons the wallet page gives in full (9.7.10).

The overdue job must be idempotent and it must not double-charge. It runs nightly, finds loans past their due date, and records a fine for the day rather than a total. Recording a total means a second run in the same night doubles it; recording one row per overdue day per loan, with a unique index on (loanId, day), makes a second run affect nothing.

The grace period is a product decision that must be explicit. Almost every library has one, almost no candidate mentions it, and it changes the job's query. Ask.

Registration's equivalent is the drop deadline, and it has three phases rather than one: free drop with no record, drop with a "withdrawn" mark on the transcript, and no drop at all. This is a schedule attached to the term, not a constant, and modelling it as a list of dated phases means the rule can differ per term without a code change.

9. What the interviewer will push on

"Where does capacity live?" On the thing that actually fills up — the copy for a library, the section for a course — never on the catalogue entry. The tell is whether you can answer "which branch has it" and "who had this copy in March" without adding tables. The common wrong answer is availableCount on Book, which cannot answer either.

"Three hundred students, thirty seats, one second." One conditional UPDATE that both checks and increments, so the row lock does the serialising and exactly thirty statements affect a row; zero rows affected means waitlist. Then volunteer the two things that complete it: a unique index on (studentId, sectionId) for the double-click, and the honest ceiling that one row is one serialisation point, escapable by bucketing capacity if the numbers ever demand it. The wrong answer is a read, a check, then a write.

"Someone returns a book and eleven people are waiting." The return path checks the waiting list before the shelf. The copy moves into a hold for the first person with an expiry, which is a real promise with a real cost — the copy is unavailable to everyone else during the window. Expired holds are ignored rather than deleted, so no cleanup job can race a member who is collecting. Add the anti-gaming rules: no queueing for a book you already hold, and repeated no-shows suspend reserving.

"How do you stop a student registering for two classes at the same time?" Compare meeting times with a.start < b.end && b.start < a.end, and be ready for the follow-up about the boundary: strict comparisons on both sides, because a class ending at 10:00 must not clash with one starting at 10:00. Then mention exam clashes, which are a separate check that most systems forget.

"A student is taking the prerequisite this term." It does not count. The prerequisite check filters the transcript to passing grades from earlier terms, and both halves of that filter are needed. Then name the real-world escape: overrides exist, they are stored per student per course, and they record who approved them.

"Why not delete the enrolment when someone drops?" Because a drop after the deadline appears on the transcript, and a deleted row appears nowhere. The same logic gives loans a returnedAt instead of a delete. In both systems the row is the history, and the current state is what you compute from it.

The thing to volunteer that nobody asks for: the hold expiry counting against the member. Every candidate builds the waiting list; almost none notice that a free, consequence-free reservation is a resource everyone will over-claim, which turns a queue that promises fairness into one where the books sit unclaimed on the hold shelf for two days at a time. One counter and a suspension rule fixes it, and knowing that is the difference between designing the mechanism and having watched it run.

Recall

  • The catalogue entry and the thing with capacity are different tables. Book vs BookCopy, Course vs Section. Capacity lives on the second one, never the first.
  • A loan is a row with a nullable returnedAt, not a flag on the copy. The row is the history; the copy's state is derived from it.
  • The due date is stored, not computed, so a policy change does not retroactively move it.
  • Rules that say no are a list of independent checks, each returning a reason with the number needed to fix it. Collect all denials, not the first.
  • A reservation queues on the title and is fulfilled by a copy. Position is derived from placedAt, never stored as an integer.
  • A returned copy checks the waiting list before the shelf, and becomes a hold with an expiry. Expired holds are ignored, not deleted.
  • Anti-gaming: no queueing for what you already hold; repeated no-shows suspend reserving.
  • The registration race is settled by one conditional UPDATE that checks and increments together; zero rows affected means waitlist. Plus a unique index on (studentId, sectionId) for the double-click.
  • One row is one serialisation point. Bucket the capacity if the numbers ever demand it, and say so rather than pretending.
  • Prerequisites need passes from earlier terms. Overrides are stored, approved by someone, and recorded.
  • Overlap test: a.start < b.end && b.start < a.end, strict on both sides, or back-to-back classes become unregisterable.
  • Fines are computed until charged, then they are ledger entries. The nightly job records one row per overdue day with a unique index, so a second run does nothing.

Self-test: Where does capacity live and why? What turns a returned copy into a held copy? Which single statement settles the seat race? Why does a same-term prerequisite not count? Why is the overlap test strict on both sides? What stops the nightly fine job double-charging?

Quiz Bank

FoundationalModel a library so that these three questions are answerable: does this branch have the book, who is holding copy 4471 right now, and who had it in March?

All three answers come from one decision: the work and the physical object are different entities.

typescript
interface Book   { isbn: Isbn; title: string; authors: string[]; }
interface BookCopy {
  id: CopyId; isbn: Isbn; branchId: BranchId;
  condition: Condition;
}
interface Loan {
  id: LoanId; copyId: CopyId; memberId: MemberId;
  borrowedAt: Instant; dueAt: Instant;
  returnedAt: Instant | null; renewalCount: number;
}

"Does this branch have the book?" Count the copies with this isbn and this branchId that have no open loan. This question is unanswerable if availability is a number on Book, because a single count cannot be split by branch — and a library with three branches is the normal case, not an edge case.

"Who is holding copy 4471?" The loan row for that copy with returnedAt null. There is at most one, and that "at most one" is worth stating as an invariant you would enforce with a partial unique index on copyId where returnedAt is null. A flag on the copy could tell you that it is out; only the loan tells you who.

"Who had it in March?" Every loan row for that copy whose borrowed and returned dates overlap March. This is the question that kills the flag design outright, because a flag has no past. When the copy comes back damaged, or a member reports the previous borrower left something inside it, or an audit asks how often a copy circulates, the answer is in these rows and nowhere else.

Two details in the model that are easy to skip and matter.

dueAt is stored rather than derived. The library's loan period will change at some point. A member who borrowed under the old rule keeps the old date, and that is only possible because the answer was written down when the loan was made rather than recomputed on every read.

returnedAt is nullable rather than the row being deleted. The nullable field is the entire difference between a system with a memory and one without. Deleting is never cheaper here — the row is small and it is the only record that the event happened.

The derived state is allowed, with conditions. Keeping state: CopyState on the copy so the shelf screen does not join to loans is fine, provided it is written in the same transaction as the loan, it can be rebuilt by recomputing from loans, and something alerts when the two disagree. Those are the same three conditions this book puts on every cached aggregate.

AppliedRegistration opens at 09:00. Three hundred students hit a thirty-seat section in the same second, and many of them click twice. Show the code path and everything it must survive.

The wrong version first, because it is what people write:

typescript
const section = await load(sectionId);
if (section.enrolled < section.capacity) {
  await enrol(studentId, sectionId);
  await bumpCount(sectionId);
}

Every request reads 29. Every request concludes there is room. Three hundred students enrol into thirty seats, and the increments overwrite each other so the count still reads 30. The system is now confidently wrong, which is worse than being down.

The fix is to make the check and the change one statement on one row:

sql
UPDATE sections
   SET enrolled = enrolled + 1
 WHERE id = :sectionId
   AND enrolled < capacity;

The database takes a lock on that row, so the three hundred attempts happen one after another whether they like it or not. Exactly thirty of them find enrolled < capacity true and affect one row. The other two hundred and seventy affect zero rows, and zero rows affected is the signal, not an error: that student goes onto the waiting list.

The double-click needs a separate mechanism, because the statement above cannot tell two clicks from two students:

sql
CREATE UNIQUE INDEX ON enrolments (student_id, section_id);

The second click now fails at the database instead of consuming a second seat. This is one line and it is the difference between a section with 30 students and a section with 30 seats consumed by 24 students.

The two writes must be in one transaction. The enrolment row and the count increment either both happen or neither does. If they are separate, a crash between them leaves a seat consumed by nobody, and there is no way to tell that from a seat consumed by a student whose row failed to write.

Then the waiting list, which is the rest of the feature. A student who affected zero rows gets a waitlist row ordered by arrival. When someone drops, the drop path decrements the count and, in the same transaction, promotes the oldest waiting student. Promotion is automatic here rather than a held offer, because unlike a library book there is nothing to walk in and collect.

What I would say about the ceiling before being asked. One row per section means one serialisation point per section, which handles a few thousand attempts a second — comfortably enough for a university, and nowhere near enough for a ticket sale. If the numbers ever demanded more, capacity would be split into buckets, a student would claim from one bucket, and finding the last seat in a nearly full section would need a second pass across buckets. That trade is worth naming rather than leaving as an unstated limit, because "it does not scale" is a fair criticism only of a design that never admitted where it stops.

And one operational detail. At 09:00 the load is not spread evenly; it is a wall. Everything not required for correctness — sending confirmation mail, updating the student's timetable view, telling the department — belongs on the other side of an event rather than inside the transaction, so the burst is absorbed by a queue rather than by the row lock.

InterviewA copy is returned and eleven members are waiting for that book. Walk the whole path, including everything that can go wrong.

The return path has one extra step that most designs miss: it checks the queue before it checks the shelf.

In order: close the loan by setting returnedAt; compute and record any fine; then look for the oldest waiting reservation for that book at this branch. If one exists, the copy does not go back on the shelf. It moves into a hold for that member.

typescript
state: { kind: "held"; copyId: CopyId; expiresAt: Instant }

Why the hold has to exist at all. Without it, the copy returns to the shelf and the next person who walks past takes it, and the eleven people who waited three weeks watch the twelfth person carry it out. A queue that does not reserve anything is not a queue; it is a mailing list.

Why the hold has to expire. Someone will reserve a book and never come. If the hold is open-ended, one unresponsive member freezes the queue permanently, and the ten people behind them are stuck behind a copy sitting on a shelf. A fixed window — two days is the usual — bounds the damage.

How expiry works, and why it is not a delete. An expired hold is ignored rather than removed: every reader treats a hold whose expiresAt has passed as if it were not there. This matters because the alternative has a race. If a cleanup job deletes the hold, then a member arriving one second before the deadline can be racing that job, and whether they get their book depends on which one ran first. When expiry is a comparison performed by every reader, the outcome is decided by the clock rather than by scheduling luck. A background job still runs, but only to advance the queue — and because it re-reads the state, running it twice changes nothing the second time. Same mechanism as the seat hold in 9.7.9.

What happens when the hold expires. The copy is offered to the next waiting member and their window starts. If nobody is left waiting, it finally goes to the shelf. Note that this means a copy can pass through three or four holds before it is borrowed, and each pass costs two days — so a long queue moves much more slowly than its length suggests. That arithmetic is worth saying out loud, because it is the reason libraries make the window short.

Now the failure cases.

The member collects at the same moment the hold expires. The collection is a conditional write: turn the hold into a loan only if the hold is still valid at this instant. One of the two outcomes wins cleanly and the member is told which.

The copy is damaged on return. It does not enter a hold at all; it goes to a repair state, and the queue is untouched. The waiting member is not told a copy is ready and then told it is not.

The member at the front cancels while the hold is active. The copy passes immediately to the next person rather than waiting out the window.

Two copies are returned at once and one member is waiting. They get one hold, not two. The second copy goes to the second waiting member, or to the shelf.

Two anti-gaming rules I would add unprompted. A member may not be in the queue for a book they are currently holding, or borrowing becomes a way to stay permanently at the front. And a hold that expires unclaimed counts against them, with reservations suspended after three — because a reservation that costs nothing is a resource everyone over-claims, and the visible symptom is a hold shelf full of books nobody came for while eleven people wait.

StaffThe university wants registration by seniority rather than first-come, and a lottery for the most popular courses. How does the design change, and what gets easier?

The first thing to notice is that this removes the race rather than complicating it. A first-come system has to decide the winner at the instant of the click, which is why section 7 is about row locks and conditional updates. Seniority and lotteries decide the winner later, from a complete set of requests, so the click no longer allocates anything.

So the model splits into two phases, and the entities change shape.

Phase one: requests. A student submits a RegistrationRequest — a section, a priority ordering across their own requests, and a timestamp. Nothing is allocated. This write has no contention at all: it is an insert, one row per student per section, no shared row to lock. The 09:00 wall stops being a correctness problem and becomes an ordinary write burst.

Phase two: allocation. After the window closes, a batch job assigns seats. This runs once, alone, with no concurrency, which means the hard part is now an algorithm rather than a locking problem.

The allocation rule for seniority is a sort. Order the requests for a section by year of study, then by accumulated credits, then by a tie-break, and take the first capacity of them. Everyone else becomes waitlisted in the same order. The tie-break must be stated explicitly and must be stable — a stored random number assigned per student per term works, and it is far better than "whoever the sort happened to put first", because the second one produces different answers if the job is re-run.

The allocation rule for a lottery is the same sort with a random key. Assign each request a random number when the window closes, sort by it, take the first capacity. The important detail is that the random number is stored, not generated during the sort. A re-run must produce the same result, or a job that is retried after a crash silently reallocates the whole university.

What gets harder is the thing neither rule handles: a student's requests interact. A student who is given their fourth choice but not their first has a timetable clash they never would have chosen, and a student allocated four courses when they asked for five in priority order may need the fifth more. The honest answer is that this is an assignment problem, and the practical approach is a greedy pass in priority order per student — walk the students in seniority order, and for each one, walk their own preference list and give them the first thing that fits and does not clash. It is not optimal, it is explainable to a student who asks why they did not get a seat, and explainability is worth more here than optimality.

What gets easier, and it is a lot.

No hot row. Requests are inserts spread across the table; there is no section row being fought over.

No unfair timing. Under first-come, a student on a slow connection loses to one on a fast one, and there is nothing they can do about it. Under either of these rules, when you click inside the window does not matter at all. The system stops rewarding network speed, which is the actual fairness complaint students have about registration.

No partial state during the burst. Allocation happens once, offline, and can be validated before it is published — capacity respected, no clashes, no duplicate seats — with the option of not publishing at all if a check fails. A live first-come system has no such moment.

What must be designed carefully.

The window has to close hard. A request arriving during allocation must be rejected or held for the next round, never merged in, or the allocation is computed against a moving set.

The result must be published atomically. Students must not see half an allocation. Write the assignments, then flip one visible marker on the term.

The second round needs its own rules. Almost every university runs an add-drop period afterwards, and that period usually is first-come — so both mechanisms live in the same system, and the design has to support the batch phase and the live phase on the same tables. That is fine, because the live phase is exactly section 7's conditional update running against the seats the batch left free.

What I would monitor. The proportion of students who received their first choice, which is the number the university actually cares about; the number of sections that filled, which tells the timetabling office where to add capacity; the number of allocations rejected by the validation pass, which should be zero and is an incident when it is not; and the size of the add-drop churn afterwards, because a large one means the allocation rule is fighting what students really want.

Flashcards

FlashThe split that decides everything

The catalogue entry and the thing with capacity are separate: Book vs BookCopy, Course vs Section. Capacity, location and state live on the second. Putting availableCount on the first makes "which branch has it" and "who had it in March" unanswerable.

FlashLoan, not flag

A loan row with a nullable returnedAt is the history; a borrowedBy field on the copy is only the present. The due date is stored rather than computed, so a policy change does not move existing loans.

FlashThe seat race in one statement

UPDATE sections SET enrolled = enrolled + 1 WHERE id = :id AND enrolled < capacity. Zero rows affected means waitlist, not error. Plus a unique index on (studentId, sectionId) for the double-click.

FlashReturned copy, eleven waiting

The return path checks the queue before the shelf. The copy becomes a hold for the first person with an expiry. Expired holds are ignored rather than deleted, so no cleanup job can race a collecting member.

FlashThe prerequisite trap

"Completed" means a passing grade from an earlier term. Filtering only on "passed" lets a student take a course and its prerequisite together. Overrides exist in real universities and are stored with who approved them.

FlashWhy holds must cost something

A free reservation is over-claimed, and the hold shelf fills with books nobody collects while the queue waits. Count expired holds against the member and suspend reserving after three.

Next: 9.7.23 — a restaurant, where the same table is a seat, a queue and a bill at three different moments.