8 min read

New problem on top of app 1: real relationships (books have many authors, books have many physical copies), roles (admin vs. member), and business rules that have to be enforced, not just displayed. Notice this app needs no new infrastructure — the added complexity is entirely in the data model and the rules, which is itself the lesson: harder doesn't always mean "add a service."


1. Problem framing

A small library needs to track which books it owns, how many physical copies of each, who currently has which copy, and when it's due back. Staff need to check books in/out and manage the catalog; members need to search the catalog and see their own loans and any fines.

2. PRD

Goal: Let library staff manage a catalog of books and physical copies, and let members browse, check out, and return them, with overdue fines tracked automatically.

Non-goals:

  • Holds/reservations queue for checked-out books
  • Inter-library loans
  • Barcode scanner hardware integration (manual copy lookup by ID is fine)
  • Email/SMS due-date reminders

User stories:

  • As a member, I want to search the catalog by title/author, so I can find a book.
  • As a member, I want to see how many copies are available, so I know if I can check one out now.
  • As a member, I want to check out an available copy, so I can borrow it.
  • As a member, I want to see my active loans and any fines, so I know what I owe and what's due.
  • As an admin, I want to add books, authors, and copies to the catalog.
  • As an admin, I want to check a copy back in and see any fine computed automatically.

Success metrics:

  • Checkout of an available copy succeeds in a single request, no double-booking of the same copy — ever, under concurrent requests.
  • Overdue fines are computed correctly to the day at return time.

MVP cut line: catalog + copies + checkout/return + lazy fine calculation

  • role-gated admin catalog management. Holds and notifications are deferred.

3. Wireframes & UX flow

Screen inventory:

ScreenRolePurpose
Catalog searchMemberSearch/browse books, see availability
Book detailMemberAuthor(s), copies, availability, checkout button
My loansMemberActive + past loans, fines owed
Admin: catalogAdminAdd/edit books, authors, copies
Admin: deskAdminLook up a copy by id, check in/out

4. HLD

Still just Postgres. Stack rationale: the hard part of this app is correctness under concurrency (two members can't check out the last copy at once) and relational integrity — both are exactly what a relational database with transactions is for. No cache, no queue, nothing new.

5. Data model / ERD

BOOK_AUTHOR is a join table, not a author_id column on BOOK — books can have co-authors, and modeling it as many-to-many from day one avoids a painful migration the first time a real co-authored book gets added.

COPY is a separate table from BOOK, not an available_count integer — you need to know which specific copy is with which member (to track a lost book, to know exactly what's overdue), not just a count.

6. API design + flow mapping

MethodPathBodyAuth
GET/api/books?q=&genre=—any
GET/api/books/:id— (includes copies + availability)any
POST/api/loans{ copyId }member
POST/api/loans/:id/return—admin
GET/api/members/me/loans—member
POST/api/admin/books{ title, isbn, genre, authorIds[] }admin
POST/api/admin/copies{ bookId, barcode }admin

Flow: checkout — the one that has to be race-safe

SELECT ... FOR UPDATE inside the transaction is the whole trick: it locks the copy row for the duration of the transaction, so a second concurrent checkout request for the same copy blocks until the first commits — then sees status = 'checked_out' and correctly fails. Without the lock, two requests can both read available before either writes, and the same copy gets checked out twice.

Flow: return — fines computed lazily, not by a background job

7. LLD

Module breakdown:

code
app/api/books/route.ts              — search/list
app/api/books/[id]/route.ts         — detail with copies
app/api/loans/route.ts              — POST checkout
app/api/loans/[id]/return/route.ts  — POST return
app/api/admin/**                    — role-gated catalog management
lib/loans.ts                        — checkout/return business logic (the transaction above)

Edge cases worth naming explicitly:

  • Double checkout race — solved above with SELECT ... FOR UPDATE; this is the one edge case in this app that isn't optional to handle.
  • Returning a copy that's already returned — return endpoint must check returned_at IS NULL before writing, and return a 409 if it's already closed out, not silently double-charge a fine.
  • Deleting an author/book that has copies with active loans — block the delete (foreign key ON DELETE RESTRICT), don't cascade-delete loan history.
  • Max active loans per member (5) — enforced server-side inside the same transaction as checkout, not just as a UI disable, or a second tab bypasses it.
  • Negative fines — max(0, days_late * rate) guards against early/on-time returns producing a negative charge.

8. NFRs

  • Performance: catalog search uses Postgres ILIKE with a trigram index (pg_trgm) on title — fine up to tens of thousands of books; full-text search is a v2 concern (see retro).
  • Security / authz: role stored in the session, checked server-side on every /api/admin/* route; a member can only ever see their own loans (WHERE member_id = session.userId).
  • Observability: log every checkout/return with copy id + member id — this is the audit trail if a book goes "missing" and someone needs to know who had it last.

9. ADRs

code
ADR-1: Copy as its own table, not an availability count on Book
Context: need to know which physical copy a member has, for lost-book
         tracking and precise due dates.
Decision: separate COPY table, one row per physical item.
Consequence: more joins for a simple "is this book available" check, but
             it's the only model that supports per-copy state.

ADR-2: Many-to-many Book–Author via join table
Context: some books have co-authors.
Decision: BOOK_AUTHOR join table instead of a single author_id column.
Consequence: catalog queries need a join; avoids a breaking schema change
             the first time a co-authored book is added.

ADR-3: Compute fines lazily at return time, not via a nightly cron job
Context: could run a scheduled job to flag overdue loans and accrue fines
         daily.
Decision: compute the fine once, at the moment of return, from
         (return_date - due_date).
Alternatives: nightly job that accrues fine_cents daily.
Consequence: simpler — no scheduler/worker infrastructure needed yet
             (that's introduced later, in app 7, when the need is real).
             Tradeoff: a member can't see their fine ticking up before they
             return the book — acceptable for v1.

ADR-4: Row-level lock (FOR UPDATE) over an application-level mutex
Context: two members could try to check out the last copy at once.
Decision: use a database transaction with a row lock, not app-level
         locking (which doesn't work across multiple server instances).
Consequence: correctness holds even when the app scales to multiple
             instances, because the lock lives in the database, not memory.

10. Deploy plan

  • Environments: local, Preview (per PR), Production.
  • CI: typecheck, lint, and a test for the checkout race (concurrent requests against the same copy in a test transaction) on every push — this is exactly the kind of bug that only shows up under concurrency, so it needs an explicit test, not just manual QA.
  • Definition of done: admin can add a book + copies; a member can search, check out, and see it in "my loans"; admin can return it and see a fine if overdue; deployed and reachable.
  • Rollback: Vercel instant rollback; loan/copy schema changes go through additive migrations only.

11. Retro — "at 10x"

At 10x the catalog size, ILIKE search stops being good enough — it doesn't rank relevance and gets slow past tens of thousands of rows; I'd move to Postgres full-text search (tsvector) or, past that, a dedicated search service. At 10x the member base, the lazy fine-at-return-time model starts to feel thin — members would reasonably want to see accruing fines before returning, which does eventually justify the nightly job I deliberately deferred in ADR-3. That's the right order to learn it in: add the scheduler when a real requirement demands it, not preemptively.