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:
| Screen | Role | Purpose |
|---|---|---|
| Catalog search | Member | Search/browse books, see availability |
| Book detail | Member | Author(s), copies, availability, checkout button |
| My loans | Member | Active + past loans, fines owed |
| Admin: catalog | Admin | Add/edit books, authors, copies |
| Admin: desk | Admin | Look 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
| Method | Path | Body | Auth |
|---|---|---|---|
| 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:
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 NULLbefore 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
ILIKEwith a trigram index (pg_trgm) ontitle— 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
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.