Low-Level Design: Digital Library System – Catalog, Borrowing, Reservations, and DRM (2025)

Core Entities

Book:
  book_id, isbn, title
  author_ids: array of author_ids
  publisher_id
  genre_tags: array of strings
  description, language, year

DigitalCopy:
  copy_id, book_id
  format: EPUB | PDF | MOBI
  file_key (S3 object key)
  drm_type: ADOBE | LOAN_TOKEN | NONE
  total_licenses     -- max concurrent loans
  available_licenses -- decremented on borrow

BorrowRecord:
  record_id, copy_id, patron_id
  status: ACTIVE | RETURNED | OVERDUE
  borrowed_at, due_at, returned_at
  download_token     -- signed JWT for file access

Reservation:
  reservation_id, copy_id, patron_id
  position_in_queue
  created_at, notified_at, expires_at

License Management

Unlike physical books, digital copies can support multiple simultaneous loans up to a licensed limit. The atomic checkout pattern prevents overselling:

def borrow_book(copy_id, patron_id, loan_days=14):
    with db.transaction():
        # Lock the row to prevent concurrent borrows
        copy = db.query(
            "SELECT * FROM digital_copies WHERE copy_id=? FOR UPDATE",
            copy_id
        )[0]

        if copy['available_licenses'] <= 0:
            # Add to reservation queue
            add_to_queue(copy_id, patron_id)
            return None, "Added to waitlist"

        db.execute(
            "UPDATE digital_copies SET available_licenses = available_licenses - 1 WHERE copy_id=?",
            copy_id
        )
        due_at = now() + timedelta(days=loan_days)
        token = generate_loan_token(copy_id, patron_id, due_at)
        record = create_borrow_record(copy_id, patron_id, due_at, token)
        return record, "Success"

def return_book(record_id):
    with db.transaction():
        record = get_borrow_record(record_id)
        update_borrow_record(record_id, status='RETURNED', returned_at=now())
        db.execute(
            "UPDATE digital_copies SET available_licenses = available_licenses + 1 WHERE copy_id=?",
            record['copy_id']
        )
    notify_next_in_queue(record['copy_id'])

SELECT FOR UPDATE is the key: it acquires a row-level lock so only one transaction can decrement available_licenses at a time. Without it, two simultaneous borrows could both read available_licenses=1 and both succeed, creating an overcommitment.

Loan Token DRM

For libraries that cannot afford Adobe DRM infrastructure, a loan token approach using signed JWTs provides reasonable protection:

def generate_loan_token(copy_id, patron_id, due_at):
    payload = {
        "copy_id": copy_id,
        "patron_id": patron_id,
        "exp": due_at.timestamp(),   # JWT standard expiry claim
        "iat": now().timestamp(),
        "jti": str(uuid4())          # unique token ID for revocation
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="HS256")

def validate_loan_token(token):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
        # Check revocation list (for early returns)
        if is_revoked(payload['jti']):
            return None, "Token revoked"
        return payload, "Valid"
    except jwt.ExpiredSignatureError:
        return None, "Loan expired"

The token is embedded in the ebook file or passed to the reader app at download time. When the patron opens the book, the reader app validates the token against the library server. Once exp passes, the library server rejects the token and the reader locks the content.

On early return: add the jti to a revocation list (Redis set) and increment available_licenses. The token is cryptographically invalid after revocation even if the expiry has not passed.

Reservation Queue

When all licenses are checked out, patrons join a queue. On return, the first patron in line gets a notification and a time-limited claim window:

def add_to_queue(copy_id, patron_id):
    position = db.query(
        "SELECT COALESCE(MAX(position_in_queue), 0) + 1 FROM reservations "
        "WHERE copy_id=? AND expires_at IS NULL", copy_id
    )[0][0]
    db.execute(
        "INSERT INTO reservations (copy_id, patron_id, position_in_queue, created_at) "
        "VALUES (?, ?, ?, ?)", copy_id, patron_id, position, now()
    )

def notify_next_in_queue(copy_id):
    next_res = db.query(
        "SELECT * FROM reservations WHERE copy_id=? AND notified_at IS NULL "
        "ORDER BY position_in_queue LIMIT 1", copy_id
    )
    if not next_res:
        return
    res = next_res[0]
    expires_at = now() + timedelta(hours=24)
    db.execute(
        "UPDATE reservations SET notified_at=?, expires_at=? WHERE reservation_id=?",
        now(), expires_at, res['reservation_id']
    )
    send_notification(res['patron_id'], copy_id, expires_at)

# Cron job: expire unclaimed reservations and advance queue
def expire_reservations():
    expired = db.query(
        "SELECT * FROM reservations WHERE expires_at < ? AND status='NOTIFIED'", now()
    )
    for res in expired:
        db.execute("UPDATE reservations SET status='EXPIRED' WHERE reservation_id=?",
                   res['reservation_id'])
        notify_next_in_queue(res['copy_id'])

Search and Catalog

Full-text search via Elasticsearch. Index schema:

{
  "mappings": {
    "properties": {
      "title":       {"type": "text", "analyzer": "english"},
      "authors":     {"type": "text", "analyzer": "english"},
      "description": {"type": "text", "analyzer": "english"},
      "genre_tags":  {"type": "keyword"},
      "language":    {"type": "keyword"},
      "year":        {"type": "integer"},
      "available_licenses": {"type": "integer"}
    }
  }
}

Query with facets and availability boost:

def search_catalog(query, genre=None, language=None, available_only=False):
    must = [{"multi_match": {
        "query": query,
        "fields": ["title^3", "authors^2", "description"],
        "type": "best_fields"
    }}]
    filters = []
    if genre:
        filters.append({"term": {"genre_tags": genre}})
    if language:
        filters.append({"term": {"language": language}})
    if available_only:
        filters.append({"range": {"available_licenses": {"gt": 0}}})

    body = {
        "query": {
            "function_score": {
                "query": {"bool": {"must": must, "filter": filters}},
                "functions": [
                    # Boost available books
                    {"filter": {"range": {"available_licenses": {"gt": 0}}},
                     "weight": 1.5},
                    # Recency boost - newer books score higher
                    {"gauss": {"year": {"origin": "now", "scale": "5y", "decay": 0.5}}}
                ],
                "score_mode": "multiply"
            }
        },
        "aggs": {
            "genres":    {"terms": {"field": "genre_tags"}},
            "languages": {"terms": {"field": "language"}},
            "years":     {"histogram": {"field": "year", "interval": 10}}
        }
    }
    return es.search(index="books", body=body)

The function_score query blends TF-IDF relevance with availability and recency signals. Aggregations power the facet sidebar (genre counts, language breakdown, year histogram) without separate queries.

Scale and Operational Notes

  • License sync: keep Elasticsearch available_licenses in sync via a change-data-capture stream (Debezium on Postgres). Eventual consistency is acceptable for search; the authoritative source is the relational DB with locks.
  • File delivery: generate pre-signed S3 URLs valid for the loan duration. The URL is embedded in the download token response, not stored in the DB. Revocation works by rejecting the loan token before the signed URL is used.
  • Overdue enforcement: a daily cron marks records OVERDUE and can auto-return (incrementing licenses). Some libraries allow renewals – add a renew endpoint that updates due_at and reissues the token.
  • Multi-format: one Book can have multiple DigitalCopy rows (EPUB, PDF, MOBI). Each format has its own license pool – common in publisher contracts.

See also: Atlassian Interview Guide

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Shopify Interview Guide

Scroll to Top