Design a Library Management System: The Complete Guide
Building a production-grade LMS from scratch: catalog management, borrowing workflows, multi-branch operations, and fine calculations
Table of Contents
- Introduction — The Library Management Landscape
- Functional & Non-Functional Requirements
- Capacity Estimation & Back-of-Envelope
- Data Model & Storage Schema
- High-Level Architecture
- API Design
- Book Search & Catalog System
- Borrowing & Return Flow
- Hold/Reservation System
- Fine Calculation Engine
- Multi-Branch Support
- Inventory Management
- Member Management
- Reporting & Analytics
- Notification System
- Security & Access Control
- Cost Estimation
- Testing Strategy
- Interview Q&A
1. Introduction — The Library Management Landscape
A Library Management System (LMS) is a comprehensive software solution that manages the complete lifecycle of library operations — from cataloging books and tracking inventory to managing member accounts, processing loans, calculating fines, and generating analytics. While the concept seems straightforward on the surface, building a production-grade LMS that handles millions of records, thousands of concurrent users, and multi-branch operations is a deeply complex engineering challenge that touches nearly every domain of system design.
Modern library systems must handle an extraordinary range of physical and digital assets: books, e-books, audiobooks, periodicals, DVDs, academic journals, manuscripts, microfiches, and inter-library loan materials. Each asset type has different metadata schemas, lending rules, and availability constraints. A university library system might manage 5 million catalog entries across 12 branches, while a public library network could serve 2 million cardholders with 500,000 physical items spread across 40 locations.
The historical evolution of library systems mirrors the broader evolution of enterprise software. Early systems in the 1970s ran on mainframes with batch processing for catalog cards. The 1990s brought web-based OPACs (Online Public Access Catalogs) and the MARC (Machine-Readable Cataloging) standard. Today's systems are cloud-native microservices with real-time inventory tracking, machine-learning-powered recommendations, RFID-based automated checkout, and mobile-first interfaces.
In this guide, we will design a library management system from the ground up. We will cover every major subsystem — the book catalog, borrowing and return workflows, hold and reservation management, fine calculation, multi-branch operations, inventory management, member administration, reporting and analytics, notification pipelines, and security. Each section includes production-quality C# code, database schemas, Mermaid architecture diagrams, and HTML tables for quick reference. Whether you are preparing for a system design interview or building an actual library platform, this guide provides the depth you need.
The real complexity emerges when you combine these seemingly simple operations. A book that is checked out, placed on hold by three patrons, transferred to another branch, found damaged upon return, and then needs its hold queue re-evaluated — this single scenario touches five different subsystems and requires careful orchestration. Add multi-branch support with real-time inventory visibility, and you have a system that rivals the complexity of many e-commerce platforms.
We will approach this design systematically, starting with requirements gathering and capacity estimation, then progressively building out each subsystem. Every design decision will be justified with trade-off analysis, and every code sample will be production-ready rather than pseudocode. Let us begin by understanding what a modern library management system must do.
2. Functional & Non-Functional Requirements
Functional Requirements
The functional requirements of a library management system span multiple user roles and operational workflows. We need to support librarians, patrons, administrators, and system integrators, each with distinct capabilities and access levels.
Book Catalog Management
- Add, update, and remove books from the catalog with full MARC-compatible metadata
- Support multiple editions, formats (hardcover, paperback, e-book, audiobook), and translations of the same work
- Classify books using Dewey Decimal, LC (Library of Congress), or custom classification systems
- Track author biographies, publisher information, ISBNs (ISBN-10 and ISBN-13), and subject headings
- Maintain a unified catalog that merges records for the same physical or intellectual work
Member Management
- Register new members with identity verification and configurable membership tiers (student, faculty, general public, senior, institutional)
- Manage membership renewals, suspensions, and expirations with automated notifications
- Enforce per-member borrowing limits based on membership tier and account standing
- Track borrowing history and reading patterns for analytics and recommendations
Borrowing & Returns
- Check out books to patrons with configurable loan periods based on item type and patron tier
- Process returns with real-time inventory updates and condition assessment
- Support self-checkout kiosks, staff-assisted checkout, and RFID-gated automated systems
- Implement due date extensions (renewals) with limits and conflict detection against holds
Hold/Reservation System
- Allow patrons to place holds on checked-out items with FIFO queue management
- Notify patrons when reserved items become available with configurable pickup deadlines
- Support freeze and unfreeze of holds for patron convenience
- Implement automatic hold fulfillment when items are returned
Fine Management
- Calculate overdue fines with configurable daily rates per item type
- Track fine accumulation, payment processing, and waiver management
- Block borrowing privileges when fine thresholds are exceeded
- Support grace periods, maximum fine caps, and institutional fine amnesty policies
Non-Functional Requirements
| Requirement | Target | Rationale |
|---|---|---|
| Availability | 99.95% uptime | Public-facing catalog and self-checkout must be always available |
| Latency (P99) | < 200ms for search | Catalog search must feel instantaneous for patrons |
| Throughput | 5,000 checkouts/hour peak | Handling rush hours across all branches simultaneously |
| Data Consistency | Strong consistency for borrowing | A book cannot be checked out to two patrons simultaneously |
| Scalability | 10M+ catalog entries | Growing digital and physical collection over decades |
| Search Quality | 95%+ relevance for top-10 results | Patrons must find what they are looking for efficiently |
| Security | Role-based access, PCI DSS for payments | Patron data privacy and payment compliance |
| Offline Support | Local cache for self-checkout kiosks | Network outages should not halt physical operations |
| Multi-Language | UI in 10+ languages | Public libraries serve diverse communities |
| Mobile Responsive | Full functionality on phones | Majority of patron interactions happen on mobile |
3. Capacity Estimation & Back-of-Envelope
Capacity planning is essential for a library management system because the workload profile is highly skewed — peak hours (lunch breaks, evenings, weekends) see 5-10x the traffic of off-hours, and the start of academic semencies creates massive spikes for university libraries. We must size our infrastructure for peak loads while maintaining cost efficiency during quiet periods.
Assumptions for a City-Wide Public Library Network
- Branches: 30 library locations
- Registered patrons: 2,000,000 cardholders
- Catalog entries: 3,000,000 unique titles, 8,000,000 physical items (multiple copies)
- Daily transactions: 50,000 checkouts, 45,000 returns, 30,000 catalog searches
- Peak hourly throughput: 10,000 checkouts/hour, 50,000 searches/hour
- Average book metadata: 2 KB per catalog entry
Storage Calculations
| Data Type | Record Size | Count | Total Storage |
|---|---|---|---|
| Catalog entries | 2 KB | 3,000,000 | 6 GB |
| Physical item records | 0.5 KB | 8,000,000 | 4 GB |
| Patron profiles | 1 KB | 2,000,000 | 2 GB |
| Loan transactions | 0.3 KB | 50,000/day × 365 × 5 years = 91M | 27 GB |
| Fine records | 0.2 KB | 20,000/day × 365 × 5 years = 36M | 7 GB |
| Hold records | 0.3 KB | 10,000/day × 365 × 2 years = 7.3M | 2 GB |
| Search logs | 0.5 KB | 30,000/day × 365 × 1 year = 11M | 5.5 GB |
| Total | ~54 GB |
With 3x replication and indexes, total database storage comes to approximately 200 GB — well within a single PostgreSQL instance, though we would shard for availability and read performance.
Bandwidth Calculations
- Catalog searches: 50,000 queries/hour × 5 KB response = 250 MB/hour = 0.7 MB/s
- Checkout transactions: 10,000/hour × 1 KB request = 10 MB/hour = 0.003 MB/s
- Return transactions: 8,000/hour × 1 KB = 8 MB/hour
- Thumbnail images: 50,000 image loads/hour × 50 KB = 2.5 GB/hour = 0.7 MB/s
- Real-time inventory sync: 30 branches × 2 KB/second = 60 KB/s = 0.06 MB/s
- Total peak bandwidth: ~1.5 MB/s (well within a 1 Gbps link)
QPS Estimates
| Operation | Average QPS | Peak QPS | Read/Write Ratio |
|---|---|---|---|
| Catalog search | 0.8 | 14 | 100% Read |
| Book detail view | 2.0 | 28 | 100% Read |
| Checkout | 0.6 | 3 | 100% Write |
| Return | 0.5 | 2.5 | 100% Write |
| Hold place | 0.3 | 1.5 | 100% Write |
| Renewal | 0.4 | 2 | 100% Write |
| Inventory check | 1.0 | 10 | 95% Read |
| Fine payment | 0.2 | 1 | 100% Write |
These numbers reveal a read-heavy system — roughly 85% of operations are reads. This observation drives our caching strategy, where catalog data and inventory status are aggressively cached while write operations bypass the cache entirely to maintain consistency.
4. Data Model & Storage Schema
The data model is the foundation of the library management system. We need to model books, copies, patrons, loans, holds, fines, branches, and their relationships with clarity and performance in mind. The schema must support efficient queries for catalog search, real-time availability checks, patron loan history, and hold queue management.
public class Book
{
public Guid Id { get; set; }
public string Isbn13 { get; set; }
public string Isbn10 { get; set; }
public string Title { get; set; }
public string Subtitle { get; set; }
public string Author { get; set; }
public string[] Contributors { get; set; } // illustrators, translators, editors
public string Publisher { get; set; }
public DateTime PublicationDate { get; set; }
public string Edition { get; set; }
public int PageCount { get; set; }
public string Language { get; set; }
public string[] SubjectHeadings { get; set; }
public string ClassificationSystem { get; set; } // DDC, LCC, Custom
public string ClassificationNumber { get; set; }
public string Description { get; set; }
public string CoverImageUrl { get; set; }
public BookFormat Format { get; set; } // Hardcover, Paperback, Ebook, Audiobook
public BookStatus Status { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class BookCopy
{
public Guid Id { get; set; }
public Guid BookId { get; set; }
public Guid BranchId { get; set; }
public string Barcode { get; set; } // physical barcode on the item
public CopyCondition Condition { get; set; } // New, Good, Fair, Damaged, Lost
public CopyStatus Status { get; set; } // Available, CheckedOut, OnHold, InTransit, Withdrawn
public DateTime AcquiredDate { get; set; }
public decimal ReplacementCost { get; set; }
public int CheckoutCount { get; set; } // lifetime checkout count
public DateTime? LastCheckedOutAt { get; set; }
public DateTime? LastReturnedAt { get; set; }
}
public class Patron
{
public Guid Id { get; set; }
public string LibraryCardNumber { get; set; } // unique, human-readable
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Phone { get; set; }
public string Address { get; set; }
public PatronTier Tier { get; set; } // Student, Faculty, General, Senior, Institutional
public Guid HomeBranchId { get; set; }
public DateTime MembershipExpiry { get; set; }
public bool IsActive { get; set; }
public bool IsBlocked { get; set; }
public decimal OutstandingFines { get; set; }
public int CurrentLoanCount { get; set; }
public int MaxLoans { get; set; }
public DateTime CreatedAt { get; set; }
}
public class Loan
{
public Guid Id { get; set; }
public Guid PatronId { get; set; }
public Guid BookCopyId { get; set; }
public Guid BranchId { get; set; } // checkout branch
public DateTime CheckoutDate { get; set; }
public DateTime DueDate { get; set; }
public DateTime? ReturnDate { get; set; }
public int RenewalCount { get; set; }
public int MaxRenewals { get; set; }
public LoanStatus Status { get; set; } // Active, Returned, Overdue, Lost
public decimal? FineAmount { get; set; }
public string CheckedOutByStaffId { get; set; }
public string ReturnedToStaffId { get; set; }
}
public class Hold
{
public Guid Id { get; set; }
public Guid PatronId { get; set; }
public Guid BookId { get; set; } // hold on the work, not a specific copy
public Guid PreferredBranchId { get; set; }
public int QueuePosition { get; set; }
public HoldStatus Status { get; set; } // Pending, ReadyForPickup, Fulfilled, Cancelled, Expired
public DateTime PlacedAt { get; set; }
public DateTime? ReadyAt { get; set; }
public DateTime? ExpiresAt { get; set; }
public DateTime? PickupDeadline { get; set; }
public bool IsFrozen { get; set; }
}
public class Fine
{
public Guid Id { get; set; }
public Guid PatronId { get; set; }
public Guid LoanId { get; set; }
public FineReason Reason { get; set; } // Overdue, Lost, Damaged
public decimal Amount { get; set; }
public decimal AmountPaid { get; set; }
public FineStatus Status { get; set; } // Outstanding, Partial, Paid, Waived
public DateTime IncurredAt { get; set; }
public DateTime? PaidAt { get; set; }
public string WaivedByStaffId { get; set; }
public string WaiverReason { get; set; }
}
public class Branch
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Address { get; set; }
public string PhoneNumber { get; set; }
public string Email { get; set; }
public BranchHours OperatingHours { get; set; }
public int Capacity { get; set; } // max items the branch can hold
public bool IsActive { get; set; }
public GeoLocation Location { get; set; }
}
Entity Relationship Diagram
Indexing Strategy
| Table | Index | Type | Reason |
|---|---|---|---|
| books | title, author (full-text) | GIN (tsvector) | Catalog search performance |
| books | isbn13 | Unique B-tree | ISBN lookup |
| books | subject_headings | GIN (array) | Subject-based browsing |
| book_copies | book_id, status | B-tree composite | Availability check per title |
| book_copies | barcode | Unique B-tree | Physical scan at checkout |
| book_copies | branch_id, status | B-tree composite | Branch inventory views |
| loans | patron_id, status | B-tree composite | Active loans per patron |
| loans | due_date | B-tree (partial: WHERE status = 'Active') | Overdue detection job |
| holds | book_id, status, queue_position | B-tree composite | Hold queue processing |
| patrons | library_card_number | Unique B-tree | Login and card scan |
| patrons | Unique B-tree | Email-based login | |
| fines | patron_id, status | B-tree composite | Outstanding fines check |
5. High-Level Architecture
The architecture follows a service-oriented approach where each major domain operates as an independent service with its own data store, communicating through a message bus for asynchronous operations and REST/gRPC APIs for synchronous queries. This decomposition allows each service to scale independently — the search service can be optimized for read throughput while the loan service focuses on transactional consistency.
Service Responsibilities
| Service | Responsibility | Database | Consistency |
|---|---|---|---|
| Catalog Service | CRUD for book metadata, editions, classifications | PostgreSQL + Elasticsearch | Strong |
| Loan Service | Checkout, return, renewal processing | PostgreSQL | Strong (ACID) |
| Patron Service | Registration, profiles, eligibility checks | PostgreSQL | Strong |
| Hold Service | Queue management, hold notifications, pickup | PostgreSQL | Strong |
| Fine Service | Fine calculation, payments, waivers | PostgreSQL | Strong |
| Search Service | Full-text search, autocomplete, faceted filters | Elasticsearch | Eventual |
| Notification Service | Email, SMS, push, in-app notifications | PostgreSQL | At-least-once |
| Inventory Service | Stock tracking, transfers, condition assessment | PostgreSQL + Redis | Strong + Eventual |
| Reporting Service | Analytics, dashboards, scheduled reports | PostgreSQL (read replica) | Eventual |
| Branch Service | Location management, hours, capacity | PostgreSQL | Strong |
Caching Architecture
Redis serves as the caching layer with the following strategy: catalog search results are cached for 5 minutes (invalidated on catalog updates), book availability is cached for 10 seconds (near real-time for patron experience), patron profile data is cached for 1 minute, and branch operating hours are cached for 1 hour. Cache invalidation follows a write-through pattern where write operations publish invalidation events to the message bus, which the cache layer subscribes to for immediate eviction.
public class InventoryCacheService
{
private readonly IDatabase _redis;
private readonly TimeSpan _availabilityTtl = TimeSpan.FromSeconds(10);
private readonly TimeSpan _catalogTtl = TimeSpan.FromMinutes(5);
public async Task<BookAvailability> GetAvailabilityAsync(Guid bookId, Guid? branchId = null)
{
var cacheKey = branchId.HasValue
? $"availability:{bookId}:{branchId}"
: $"availability:{bookId}:all";
var cached = await _redis.StringGetAsync(cacheKey);
if (cached.HasValue)
return JsonSerializer.Deserialize<BookAvailability>(cached);
var availability = await _database.CalculateAvailabilityAsync(bookId, branchId);
await _redis.StringSetAsync(cacheKey,
JsonSerializer.Serialize(availability), _availabilityTtl);
return availability;
}
public async Task InvalidateAvailabilityAsync(Guid bookId)
{
var pattern = $"availability:{bookId}:*";
var server = _redis.Multiplexer.GetServer(_redis.Multiplexer.GetEndPoints().First());
var keys = server.Keys(pattern: pattern).ToArray();
if (keys.Length > 0)
await _redis.KeyDeleteAsync(keys);
}
}
6. API Design
A well-designed API for a library management system must serve multiple client types — public catalog terminals, staff dashboards, self-checkout kiosks, and mobile applications — each with different performance requirements and authorization levels. We use RESTful conventions with JSON payloads, consistent error formats, and pagination for all list endpoints.
Catalog APIs
// Search the catalog
GET /api/v1/catalog/search?q={query}&page={page}&pageSize={size}&branch={branchId}
GET /api/v1/catalog/books/{bookId}
GET /api/v1/catalog/books/{bookId}/availability
GET /api/v1/catalog/subjects/{subjectId}/books
GET /api/v1/catalog/authors/{authorId}/works
// Staff: Manage catalog
POST /api/v1/catalog/books
PUT /api/v1/catalog/books/{bookId}
DELETE /api/v1/catalog/books/{bookId}
POST /api/v1/catalog/books/{bookId}/copies
DELETE /api/v1/catalog/copies/{copyId}
Loan APIs
// Checkout a book
POST /api/v1/loans/checkout
{
"patronId": "550e8400-e29b-41d4-a716-446655440000",
"copyId": "660e8400-e29b-41d4-a716-446655440001",
"branchId": "770e8400-e29b-41d4-a716-446655440002"
}
// Return a book
POST /api/v1/loans/return
{
"copyId": "660e8400-e29b-41d4-a716-446655440001",
"condition": "Good",
"branchId": "770e8400-e29b-41d4-a716-446655440002"
}
// Renew a loan
POST /api/v1/loans/{loanId}/renew
// Get active loans for a patron
GET /api/v1/patrons/{patronId}/loans?status=Active
// Get loan history
GET /api/v1/patrons/{patronId}/loans?status=Returned&page={page}
Hold APIs
// Place a hold
POST /api/v1/holds
{
"patronId": "550e8400-e29b-41d4-a716-446655440000",
"bookId": "880e8400-e29b-41d4-a716-446655440003",
"preferredBranchId": "770e8400-e29b-41d4-a716-446655440002"
}
// Cancel a hold
DELETE /api/v1/holds/{holdId}
// Freeze/unfreeze a hold
POST /api/v1/holds/{holdId}/freeze
POST /api/v1/holds/{holdId}/unfreeze
// View hold queue for a book
GET /api/v1/catalog/books/{bookId}/holds
Fine & Patron APIs
// Get patron fines
GET /api/v1/patrons/{patronId}/fines?status=Outstanding
// Pay a fine
POST /api/v1/patrons/{patronId}/fines/{fineId}/pay
{
"amount": 5.50,
"paymentMethod": "CreditCard",
"transactionId": "txn_abc123"
}
// Waive a fine (staff only)
POST /api/v1/patrons/{patronId}/fines/{fineId}/waive
{
"reason": "System error caused incorrect fine",
"staffId": "staff_001"
}
// Register a patron
POST /api/v1/patrons
{
"firstName": "Jane",
"lastName": "Doe",
"email": "jane.doe@email.com",
"tier": "General",
"homeBranchId": "770e8400-e29b-41d4-a716-446655440002"
}
Response Format
// Standard success response
{
"status": "success",
"data": { ... },
"pagination": {
"page": 1,
"pageSize": 20,
"totalResults": 1543,
"totalPages": 78
}
}
// Standard error response
{
"status": "error",
"error": {
"code": "PATRON_FINE_THRESHOLD_EXCEEDED",
"message": "Cannot checkout: outstanding fines ($12.50) exceed the $10.00 limit.",
"details": {
"outstandingFines": 12.50,
"threshold": 10.00
}
}
}
API Rate Limits
| Client Type | Rate Limit | Burst | Window |
|---|---|---|---|
| Public catalog (anonymous) | 30 req/min | 50 | Sliding window |
| Authenticated patron | 120 req/min | 200 | Sliding window |
| Staff dashboard | 600 req/min | 1000 | Sliding window |
| Self-checkout kiosk | 120 req/min | 200 | Sliding window |
| Internal services (gRPC) | Unlimited | N/A | N/A |
7. Book Search & Catalog System
The search system is the most frequently accessed component of any library management system. Patrons search for books by title, author, ISBN, subject, keyword, and combination queries. The system must handle misspellings, partial matches, synonyms, and provide relevant results ranked by popularity, relevance, and availability. A patron searching for "dune" should find Frank Herbert's classic even if they type "doon" or just "herbert sci-fi."
Search Architecture
We use Elasticsearch as the primary search engine, with PostgreSQL serving as the source of truth. A CDC (Change Data Capture) pipeline using Debezium publishes catalog changes from PostgreSQL to Kafka, which the Search Service consumes to update the Elasticsearch index. This ensures the search index is eventually consistent with the catalog database, typically within 2-5 seconds of a catalog change.
Elasticsearch Index Mapping
public class SearchIndexBuilder
{
public static object CreateBookIndexMapping() => new
{
settings = new
{
number_of_shards = 5,
number_of_replicas = 2,
analysis = new
{
analyzer = new
{
custom_library = new
{
type = "custom",
tokenizer = "standard",
filter = new[] { "lowercase", "english_stemmer", "synonym_filter" }
}
},
filter = new
{
synonym_filter = new
{
type = "synonym",
synonyms = new[]
{
"sci-fi, science fiction",
"non-fiction, nonfiction",
"self-help, self improvement",
"bio, biography, autobiography"
}
},
english_stemmer = new
{
type = "stemmer",
language = "english"
}
}
}
},
mappings = new
{
properties = new
{
title = new { type = "text", analyzer = "custom_library",
fields = new { keyword = new { type = "keyword" },
autocomplete = new { type = "text", analyzer = "autocomplete" }}},
author = new { type = "text", analyzer = "custom_library",
fields = new { keyword = new { type = "keyword" }}},
isbn13 = new { type = "keyword" },
description = new { type = "text", analyzer = "custom_library" },
subjects = new { type = "keyword" },
publisher = new { type = "keyword" },
language = new { type = "keyword" },
format = new { type = "keyword" },
publicationDate = new { type = "date" },
pageCount = new { type = "integer" },
availableCopies = new { type = "integer" },
totalCopies = new { type = "integer" },
averageRating = new { type = "float" },
checkoutPopularity = new { type = "integer" },
suggest = new { type = "completion" }
}
}
};
}
Search Algorithm
The search ranking combines multiple signals to produce the most relevant results. Text relevance uses BM25 scoring on title (boost 3.0x), author (boost 2.5x), and description (boost 1.0x). Availability boosts results where copies are currently available at the patron's preferred branch. Popularity is based on the total checkout count over the past 12 months, normalized to a 0-1 scale. Recency favors newer editions of the same work.
public class SearchService
{
private readonly IElasticClient _elastic;
public async Task<SearchResult> SearchAsync(SearchRequest request)
{
var response = await _elastic.SearchAsync<BookDocument>(s => s
.Index("books")
.From((request.Page - 1) * request.PageSize)
.Size(request.PageSize)
.Query(q => q
.Bool(b => b
.Should(
q => q.MultiMatch(mm => mm
.Fields(f => f
.Field("title", 3.0)
.Field("author", 2.5)
.Field("description", 1.0)
.Field("subjects", 2.0))
.Query(request.Query)
.Type(TextQueryType.BestFields)
.Fuzziness(Fuzziness.Auto)),
q => q.Term(t => t.Field("isbn13").Value(request.Query)))
.Filter(
request.BranchId.HasValue
? q => q.Range(r => r.NumberRange(nr => nr
.Field("availableCopies").Gt(0)))
: null,
!string.IsNullOrEmpty(request.Language)
? q => q.Term(t => t.Field("language").Value(request.Language))
: null))
.Highlight(h => h
.PreTags("<mark>")
.PostTags("</mark>")
.Fields(
f => f.Field("title"),
f => f.Field("description")))
.Aggregations(a => a
.Terms("subjects", t => t.Field("subjects").Size(10))
.Terms("formats", t => t.Field("format"))
.Terms("languages", t => t.Field("language").Size(10))));
return new SearchResult
{
Books = response.Documents,
TotalCount = response.Total,
Facets = ExtractFacets(response.Aggregations)
};
}
}
Autocomplete and Suggestions
For the search-as-you-type experience, we use Elasticsearch's completion suggester with a separate index for high-performance prefix lookups. The suggester stores title, author, and subject data with associated metadata for displaying suggestions with context (format, availability). This index is lightweight, containing only the fields needed for suggestions rather than the full book document.
public async Task<List<SearchSuggestion>> GetSuggestionsAsync(string prefix, int limit = 8)
{
var response = await _elastic.SearchAsync<SuggestDocument>(s => s
.Index("book_suggestions")
.Suggest sug => sug
.Completion("title_suggest", c => c
.Prefix(prefix)
.Field("suggest")
.Size(limit)
.SkipDuplicates(true))
.Source(src => src.Excludes(e => e.Fields("suggest"))));
return response.Suggest["title_suggest"]
.SelectMany(s => s.Options)
.Select(o => new SearchSuggestion
{
Text = o.Text,
Score = o.Score,
Source = o.Source
}).ToList();
}
Cross-Branch Availability View
When a patron searches for a book, the results should show real-time availability across all branches or at a specific branch. This requires joining the search results with the inventory service. We handle this by storing availableCopies and totalCopies in the search index, updated via the CDC pipeline every time a checkout or return occurs. For the detailed per-branch breakdown, the client makes a follow-up call to the Inventory Service.
| Search Feature | Implementation | Latency |
|---|---|---|
| Full-text search | Elasticsearch BM25 + field boosting | < 50ms |
| Fuzzy matching | Elasticsearch fuzziness with edit distance | < 80ms |
| Autocomplete | Completion suggester | < 20ms |
| Availability overlay | Indexed field updated via CDC | < 5ms |
| Faceted filters | Aggregation queries | < 30ms |
| Similar books | More Like This query on subjects | < 60ms |
8. Borrowing & Return Flow
The borrowing and return workflow is the core transactional operation of the library system. A single checkout must atomically verify that the patron is eligible (active membership, no excessive fines, under borrowing limit), the requested copy is available, and then create the loan record, update inventory status, and schedule notifications. If any step fails, the entire operation must roll back cleanly.
Checkout Flow
Checkout Implementation
public class CheckoutService
{
private readonly LibraryDbContext _db;
private readonly IFineService _fineService;
private readonly INotificationService _notificationService;
private readonly IInventoryCache _cache;
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest request)
{
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
// 1. Verify patron eligibility
var patron = await _db.Patrons
.FirstOrDefaultAsync(p => p.Id == request.PatronId);
if (patron == null || !patron.IsActive || patron.IsBlocked)
return CheckoutResult.Failure("PATRON_INACTIVE", "Patron account is inactive or blocked.");
if (patron.MembershipExpiry < DateTime.UtcNow)
return CheckoutResult.Failure("MEMBERSHIP_EXPIRED", "Membership has expired. Please renew.");
// 2. Check outstanding fines
var outstandingFines = await _fineService.GetOutstandingTotalAsync(patron.Id);
if (outstandingFines > GetFineThreshold(patron.Tier))
return CheckoutResult.Failure("FINES_EXCEEDED",
$"Outstanding fines (${outstandingFines:F2}) exceed the limit.");
// 3. Check loan count
if (patron.CurrentLoanCount >= patron.MaxLoans)
return CheckoutResult.Failure("LOAN_LIMIT_REACHED",
$"Maximum of {patron.MaxLoans} loans reached.");
// 4. Verify copy availability
var copy = await _db.BookCopies
.FirstOrDefaultAsync(c => c.Id == request.CopyId);
if (copy == null)
return CheckoutResult.Failure("COPY_NOT_FOUND", "Book copy not found.");
if (copy.Status != CopyStatus.Available)
return CheckoutResult.Failure("COPY_UNAVAILABLE", "This copy is not available for checkout.");
// 5. Calculate due date based on item type and patron tier
var loanPeriod = GetLoanPeriod(copy, patron);
var dueDate = DateTime.UtcNow.AddDays(loanPeriod);
// 6. Create loan record
var loan = new Loan
{
Id = Guid.NewGuid(),
PatronId = patron.Id,
BookCopyId = copy.Id,
BranchId = request.BranchId,
CheckoutDate = DateTime.UtcNow,
DueDate = dueDate,
RenewalCount = 0,
MaxRenewals = GetMaxRenewals(patron.Tier),
Status = LoanStatus.Active,
CheckedOutByStaffId = request.StaffId
};
_db.Loans.Add(loan);
// 7. Update copy status
copy.Status = CopyStatus.CheckedOut;
copy.LastCheckedOutAt = DateTime.UtcNow;
copy.CheckoutCount++;
// 8. Update patron loan count
patron.CurrentLoanCount++;
await _db.SaveChangesAsync();
await transaction.CommitAsync();
// 9. Invalidate cache
await _cache.InvalidateAvailabilityAsync(copy.BookId);
// 10. Send checkout confirmation (async, fire-and-forget)
_ = _notificationService.SendCheckoutConfirmationAsync(patron, loan, dueDate);
return CheckoutResult.Success(new CheckoutResponse
{
LoanId = loan.Id,
DueDate = dueDate,
BookTitle = copy.Book.Title,
RenewalsAllowed = loan.MaxRenewals
});
}
catch (Exception ex)
{
await transaction.RollbackAsync();
throw;
}
}
private int GetLoanPeriod(BookCopy copy, Patron patron)
{
return (copy.Book.Format, patron.Tier) switch
{
(BookFormat.Ebook, _) => 14,
(BookFormat.Audiobook, _) => 14,
(BookFormat.Hardcover, PatronTier.Student) => 21,
(BookFormat.Hardcover, PatronTier.Faculty) => 60,
(BookFormat.Hardcover, PatronTier.General) => 21,
(BookFormat.Hardcover, PatronTier.Senior) => 28,
_ => 21
};
}
}
Return Flow
The return flow reverses the checkout process while triggering additional logic for fine calculation, condition assessment, hold fulfillment, and inventory updates. When a returned item has pending holds, the system must automatically transition the oldest hold to "ReadyForPickup" status and notify the holding patron.
public class ReturnService
{
public async Task<ReturnResult> ProcessReturnAsync(ReturnRequest request)
{
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
try
{
var copy = await _db.BookCopies
.Include(c => c.Book)
.FirstOrDefaultAsync(c => c.Id == request.CopyId);
var loan = await _db.Loans
.Where(l => l.BookCopyId == request.CopyId && l.Status == LoanStatus.Active)
.OrderByDescending(l => l.CheckoutDate)
.FirstOrDefaultAsync();
if (loan == null)
return ReturnResult.Failure("NO_ACTIVE_LOAN", "No active loan found for this copy.");
// 1. Update loan record
loan.ReturnDate = DateTime.UtcNow;
loan.ReturnedToStaffId = request.StaffId;
loan.Status = loan.DueDate < DateTime.UtcNow
? LoanStatus.Overdue
: LoanStatus.Returned;
// 2. Calculate fine if overdue
if (loan.ReturnDate > loan.DueDate)
{
var overdueDays = (loan.ReturnDate.Value - loan.DueDate).Days;
var fineAmount = CalculateOverdueFine(loan, overdueDays);
if (fineAmount > 0)
{
await CreateFineRecordAsync(loan, fineAmount);
}
}
// 3. Update copy status and condition
copy.Status = CopyStatus.Available;
copy.LastReturnedAt = DateTime.UtcNow;
copy.Condition = request.Condition;
if (request.Condition == CopyCondition.Damaged)
{
await CreateDamageFineAsync(copy, loan.PatronId);
}
if (request.Condition == CopyCondition.Lost)
{
copy.Status = CopyStatus.Withdrawn;
await CreateReplacementFineAsync(copy, loan.PatronId);
}
// 4. Decrement patron loan count
var patron = await _db.Patrons.FindAsync(loan.PatronId);
patron.CurrentLoanCount = Math.Max(0, patron.CurrentLoanCount - 1);
await _db.SaveChangesAsync();
// 5. Check for pending holds (async)
_ = ProcessPendingHoldsAsync(copy.BookId, copy.BranchId);
// 6. Invalidate availability cache
await _cache.InvalidateAvailabilityAsync(copy.BookId);
await transaction.CommitAsync();
return ReturnResult.Success(new ReturnResponse
{
BookTitle = copy.Book.Title,
WasOverdue = loan.Status == LoanStatus.Overdue,
FineAmount = loan.FineAmount,
Condition = request.Condition.ToString()
});
}
catch (Exception)
{
await transaction.RollbackAsync();
throw;
}
}
private decimal CalculateOverdueFine(Loan loan, int overdueDays)
{
var dailyRate = loan.BookCopy.Book.Format switch
{
BookFormat.Ebook => 0m,
BookFormat.Audiobook => 0m,
BookFormat.DVD => 1.00m,
_ => 0.25m
};
var maxFine = 10.00m;
return Math.Min(overdueDays * dailyRate, maxFine);
}
}
Renewal Logic
Renewals extend the due date of an active loan, but only if certain conditions are met: the patron has not exceeded the maximum renewal count, the book has no pending holds from other patrons, and the patron's account is in good standing. Each renewal pushes the due date forward by the same loan period used at checkout.
public async Task<RenewalResult> RenewAsync(Guid loanId, Guid patronId)
{
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var loan = await _db.Loans
.Include(l => l.BookCopy).ThenInclude(c => c.Book)
.FirstOrDefaultAsync(l => l.Id == loanId && l.PatronId == patronId);
if (loan == null || loan.Status != LoanStatus.Active)
return RenewalResult.Failure("INVALID_LOAN", "Loan not found or not active.");
if (loan.RenewalCount >= loan.MaxRenewals)
return RenewalResult.Failure("MAX_RENEWALS", $"Maximum {loan.MaxRenewals} renewals reached.");
// Check if anyone else has placed a hold on this book
var pendingHolds = await _db.Holds
.CountAsync(h => h.BookId == loan.BookCopy.BookId
&& h.Status == HoldStatus.Pending);
if (pendingHolds > 0)
return RenewalResult.Failure("HOLD_EXISTS",
"Cannot renew: other patrons are waiting for this book.");
var loanPeriod = (loan.BookCopy.Book.Format, loan.Patron.Tier) switch
{
(BookFormat.Ebook, _) => 14,
(BookFormat.Audiobook, _) => 14,
_ => 21
};
loan.DueDate = DateTime.UtcNow.AddDays(loanPeriod);
loan.RenewalCount++;
await _db.SaveChangesAsync();
await transaction.CommitAsync();
return RenewalResult.Success(loan.DueDate, loan.MaxRenewals - loan.RenewalCount);
}
9. Hold/Reservation System
The hold system allows patrons to reserve checked-out books and join a queue. When a copy becomes available — through a return, a cancellation, or a newly acquired copy — the system must automatically fulfill the oldest eligible hold. The hold queue must handle concurrent requests fairly, support freeze/unfreeze for patron convenience, and enforce pickup deadlines to prevent holds from being held indefinitely.
Hold State Machine
public class HoldService
{
private readonly LibraryDbContext _db;
private readonly INotificationService _notifications;
public async Task<HoldResult> PlaceHoldAsync(HoldRequest request)
{
// Verify patron can place holds
var patron = await _db.Patrons.FindAsync(request.PatronId);
if (patron == null || !patron.IsActive)
return HoldResult.Failure("PATRON_INACTIVE", "Active membership required.");
// Check for existing active hold on the same book
var existingHold = await _db.Holds
.AnyAsync(h => h.PatronId == request.PatronId
&& h.BookId == request.BookId
&& h.Status != HoldStatus.Cancelled
&& h.Status != HoldStatus.Expired);
if (existingHold)
return HoldResult.Failure("DUPLICATE_HOLD", "You already have a hold on this book.");
// Calculate queue position
var maxPosition = await _db.Holds
.Where(h => h.BookId == request.BookId
&& (h.Status == HoldStatus.Pending || h.Status == HoldStatus.Frozen))
.MaxAsync(h => (int?)h.QueuePosition) ?? 0;
var hold = new Hold
{
Id = Guid.NewGuid(),
PatronId = request.PatronId,
BookId = request.BookId,
PreferredBranchId = request.PreferredBranchId,
QueuePosition = maxPosition + 1,
Status = HoldStatus.Pending,
PlacedAt = DateTime.UtcNow,
IsFrozen = false
};
_db.Holds.Add(hold);
await _db.SaveChangesAsync();
return HoldResult.Success(hold.QueuePosition);
}
public async Task ProcessHoldFulfillmentAsync(Guid bookId, Guid availableBranchId)
{
// Find the oldest pending, unfrozen hold for this book
var nextHold = await _db.Holds
.Include(h => h.Patron)
.Where(h => h.BookId == bookId
&& h.Status == HoldStatus.Pending
&& !h.IsFrozen)
.OrderBy(h => h.PlacedAt)
.FirstOrDefaultAsync();
if (nextHold == null) return;
// Determine if the copy is at the preferred branch or elsewhere
var availableCopy = await _db.BookCopies
.FirstOrDefaultAsync(c => c.BookId == bookId
&& c.Status == CopyStatus.Available
&& c.BranchId == availableBranchId);
if (availableCopy == null) return;
// Reserve the copy for the patron
availableCopy.Status = CopyStatus.OnHold;
nextHold.Status = HoldStatus.ReadyForPickup;
nextHold.ReadyAt = DateTime.UtcNow;
nextHold.PickupDeadline = DateTime.UtcNow.AddDays(7); // 7-day pickup window
await _db.SaveChangesAsync();
// Notify the patron
_ = _notifications.SendHoldReadyAsync(nextHold.Patron, nextHold, availableCopy);
}
public async Task<HoldResult> FreezeHoldAsync(Guid holdId, Guid patronId)
{
var hold = await _db.Holds
.FirstOrDefaultAsync(h => h.Id == holdId
&& h.PatronId == patronId
&& h.Status == HoldStatus.Pending);
if (hold == null)
return HoldResult.Failure("HOLD_NOT_FOUND", "Hold not found or cannot be frozen.");
hold.IsFrozen = true;
await _db.SaveChangesAsync();
return HoldResult.Success(hold.QueuePosition);
}
}
Hold Expiration Job
A background job runs every 15 minutes to expire holds that were ready for pickup but not collected within the pickup deadline. When a hold expires, the system automatically moves to the next patron in the queue and sends a notification to both the expired patron and the next-in-line patron.
public class HoldExpirationJob
{
public async Task ExecuteAsync()
{
var expiredHolds = await _db.Holds
.Include(h => h.Patron)
.Where(h => h.Status == HoldStatus.ReadyForPickup
&& h.PickupDeadline < DateTime.UtcNow)
.ToListAsync();
foreach (var hold in expiredHolds)
{
using var transaction = await _db.Database.BeginTransactionAsync();
hold.Status = HoldStatus.Expired;
// Release the reserved copy
var copy = await _db.BookCopies
.FirstOrDefaultAsync(c => c.BookId == hold.BookId
&& c.Status == CopyStatus.OnHold);
if (copy != null)
copy.Status = CopyStatus.Available;
// Fulfill next hold in queue
var nextHold = await _db.Holds
.Where(h => h.BookId == hold.BookId
&& h.Status == HoldStatus.Pending
&& !h.IsFrozen
&& h.Id != hold.Id)
.OrderBy(h => h.PlacedAt)
.FirstOrDefaultAsync();
if (nextHold != null)
{
nextHold.Status = HoldStatus.ReadyForPickup;
nextHold.ReadyAt = DateTime.UtcNow;
nextHold.PickupDeadline = DateTime.UtcNow.AddDays(7);
if (copy != null) copy.Status = CopyStatus.OnHold;
}
await _db.SaveChangesAsync();
await transaction.CommitAsync();
// Send notifications
await _notifications.SendHoldExpiredAsync(hold.Patron, hold);
if (nextHold != null)
await _notifications.SendHoldReadyAsync(nextHold.Patron, nextHold, copy);
}
}
}
10. Fine Calculation Engine
The fine calculation engine handles three types of fines: overdue fines (daily charge for each day past the due date), lost item fines (replacement cost plus processing fee), and damage fines (assessment-based charges). The engine must support configurable rates per branch, per item type, and per patron tier. It must also handle edge cases like items returned during grace periods, fines that should be waived due to library closures, and maximum fine caps.
Fine Rules Configuration
| Item Type | Daily Rate | Grace Period | Max Fine Cap |
|---|---|---|---|
| Regular Book | $0.25 | 0 days | $10.00 |
| New Release (first 3 months) | $0.50 | 0 days | $15.00 |
| DVD/Blu-ray | $1.00 | 0 days | $20.00 |
| Reference Material | $5.00 | 0 days | $50.00 |
| Inter-Library Loan | $1.00 | 1 day | $25.00 |
| E-book/Audiobook | $0.00 | N/A | $0.00 |
| Periodical | $0.25 | 0 days | $10.00 |
public class FineCalculator
{
private readonly FinePolicyConfiguration _policies;
public FineResult CalculateOverdueFine(Loan loan, DateTime? returnDate = null)
{
var effectiveReturn = returnDate ?? DateTime.UtcNow;
// No fine for ebooks and audiobooks
if (loan.BookCopy.Book.Format == BookFormat.Ebook ||
loan.BookCopy.Book.Format == BookFormat.Audiobook)
{
return new FineResult { Amount = 0, DaysOverdue = 0 };
}
var overdueDays = (effectiveReturn - loan.DueDate).Days;
// Apply grace period
var policy = _policies.GetPolicy(loan.BookCopy.Book.Format);
var effectiveOverdueDays = Math.Max(0, overdueDays - policy.GracePeriodDays);
if (effectiveOverdueDays <= 0)
{
return new FineResult { Amount = 0, DaysOverdue = 0 };
}
// Calculate raw fine
var rawFine = effectiveOverdueDays * policy.DailyRate;
// Apply maximum cap
var finalFine = Math.Min(rawFine, policy.MaxFineCap);
// Round to nearest cent
finalFine = Math.Round(finalFine, 2);
// Check if library was closed on any of the overdue days (holidays, emergencies)
var closedDays = await _branchCalendar.GetClosedDaysAsync(
loan.BranchId, loan.DueDate, effectiveReturn);
var adjustedDays = effectiveOverdueDays - closedDays.Count;
var adjustedFine = Math.Min(adjustedDays * policy.DailyRate, policy.MaxFineCap);
return new FineResult
{
Amount = Math.Round(Math.Max(0, adjustedFine), 2),
DaysOverdue = effectiveOverdueDays,
ClosedDaysDeducted = closedDays.Count,
DailyRate = policy.DailyRate,
MaxCap = policy.MaxFineCap
};
}
public FineResult CalculateLostItemFine(BookCopy copy)
{
var replacementCost = copy.ReplacementCost;
var processingFee = 5.00m;
return new FineResult
{
Amount = replacementCost + processingFee,
Reason = FineReason.Lost,
Breakdown = $"Replacement: ${replacementCost:F2} + Processing: ${processingFee:F2}"
};
}
public FineResult CalculateDamageFine(BookCopy copy, DamageAssessment assessment)
{
var rate = assessment.Severity switch
{
DamageSeverity.Minor => 5.00m,
DamageSeverity.Moderate => 15.00m,
DamageSeverity.Severe => copy.ReplacementCost * 0.5m,
DamageSeverity.Destroyed => copy.ReplacementCost,
_ => 0m
};
return new FineResult
{
Amount = Math.Round(rate, 2),
Reason = FineReason.Damaged,
Description = assessment.Notes
};
}
}
Fine Payment Processing
public class FinePaymentService
{
public async Task<PaymentResult> ProcessPaymentAsync(FinePaymentRequest request)
{
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var fine = await _db.Fines.FindAsync(request.FineId);
if (fine == null || fine.Status == FineStatus.Waived)
return PaymentResult.Failure("INVALID_FINE");
var remainingAmount = fine.Amount - fine.AmountPaid;
if (request.Amount > remainingAmount)
return PaymentResult.Failure("OVERPAYMENT",
$"Maximum payment is ${remainingAmount:F2}.");
// Process payment through payment gateway
var paymentResult = await _paymentGateway.ChargeAsync(
request.Amount, request.PaymentMethod, request.PaymentToken);
if (!paymentResult.Success)
return PaymentResult.Failure("PAYMENT_FAILED", paymentResult.ErrorMessage);
// Update fine record
fine.AmountPaid += request.Amount;
fine.Status = fine.AmountPaid >= fine.Amount
? FineStatus.Paid
: FineStatus.Partial;
fine.PaidAt = DateTime.UtcNow;
// Record payment transaction
var payment = new FinePayment
{
Id = Guid.NewGuid(),
FineId = fine.Id,
Amount = request.Amount,
PaymentMethod = request.PaymentMethod,
TransactionId = paymentResult.TransactionId,
ProcessedAt = DateTime.UtcNow
};
_db.FinePayments.Add(payment);
// Check if patron is now below the fine threshold
var patron = await _db.Patrons.FindAsync(fine.PatronId);
patron.OutstandingFines = await _db.Fines
.Where(f => f.PatronId == patron.Id && f.Status != FineStatus.Paid && f.Status != FineStatus.Waived)
.SumAsync(f => f.Amount - f.AmountPaid);
if (patron.IsBlocked && patron.OutstandingFines < GetFineThreshold(patron.Tier))
{
patron.IsBlocked = false;
}
await _db.SaveChangesAsync();
await transaction.CommitAsync();
return PaymentResult.Success(payment.TransactionId, fine.Amount - fine.AmountPaid);
}
}
11. Multi-Branch Support
A multi-branch library system introduces significant complexity beyond a single-location system. Books can be transferred between branches, patrons can check out from any branch regardless of their home branch, holds can be fulfilled from any branch in the network, and inventory visibility must be real-time across all locations. Each branch operates semi-autonomously with local policies while sharing the centralized catalog and member database.
Inter-Branch Transfer Flow
public class TransferService
{
private readonly LibraryDbContext _db;
public async Task<TransferResult> InitiateTransferAsync(TransferRequest request)
{
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
// Verify source has the copy available
var sourceCopy = await _db.BookCopies
.FirstOrDefaultAsync(c => c.Id == request.CopyId
&& c.BranchId == request.SourceBranchId
&& c.Status == CopyStatus.Available);
if (sourceCopy == null)
return TransferResult.Failure("SOURCE_UNAVAILABLE", "Copy not available at source branch.");
// Check target branch capacity
var targetBranch = await _db.Branches.FindAsync(request.TargetBranchId);
var currentInventory = await _db.BookCopies
.CountAsync(c => c.BranchId == request.TargetBranchId
&& c.Status != CopyStatus.Withdrawn);
if (currentInventory >= targetBranch.Capacity)
return TransferResult.Failure("TARGET_FULL", "Target branch is at full capacity.");
// Create transfer record
var transfer = new Transfer
{
Id = Guid.NewGuid(),
BookCopyId = request.CopyId,
SourceBranchId = request.SourceBranchId,
TargetBranchId = request.TargetBranchId,
Status = TransferStatus.InTransit,
InitiatedAt = DateTime.UtcNow,
InitiatedByStaffId = request.StaffId,
Notes = request.Notes
};
sourceCopy.Status = CopyStatus.InTransit;
sourceCopy.BranchId = Guid.Empty; // temporarily unassigned
_db.Transfers.Add(transfer);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
// Notify target branch
await _notifications.SendTransferNotificationAsync(
targetBranch, transfer, sourceCopy.Book);
return TransferResult.Success(transfer.Id);
}
public async Task<TransferResult> CompleteTransferAsync(Guid transferId, Guid receivingStaffId)
{
var transfer = await _db.Transfers
.Include(t => t.BookCopy).ThenInclude(c => c.Book)
.FirstOrDefaultAsync(t => t.Id == transferId && t.Status == TransferStatus.InTransit);
if (transfer == null)
return TransferResult.Failure("TRANSFER_NOT_FOUND");
using var transaction = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable);
var copy = transfer.BookCopy;
copy.Status = CopyStatus.Available;
copy.BranchId = transfer.TargetBranchId;
transfer.Status = TransferStatus.Completed;
transfer.CompletedAt = DateTime.UtcNow;
transfer.ReceivedByStaffId = receivingStaffId;
await _db.SaveChangesAsync();
// Auto-fulfill holds at target branch
await _holdService.ProcessHoldFulfillmentAsync(copy.BookId, transfer.TargetBranchId);
await _cache.InvalidateAvailabilityAsync(copy.BookId);
await transaction.CommitAsync();
return TransferResult.Success();
}
}
Branch Operating Hours
| Branch Type | Weekdays | Saturday | Sunday | Holiday Policy |
|---|---|---|---|---|
| Main Library | 8:00 AM – 9:00 PM | 9:00 AM – 6:00 PM | 12:00 PM – 5:00 PM | Closed major holidays |
| Community Branch | 10:00 AM – 7:00 PM | 10:00 AM – 5:00 PM | Closed | Closed major holidays |
| University Library | 7:00 AM – 11:00 PM | 8:00 AM – 8:00 PM | 10:00 AM – 6:00 PM | Reduced hours during breaks |
| Mobile Library | Varies by route | Varies by route | Closed | Weather-dependent |
12. Inventory Management
Inventory management covers the entire lifecycle of a physical book from acquisition to withdrawal. This includes ordering new copies, receiving and cataloging them, tracking their condition over time, performing periodic inventory audits, and eventually withdrawing items that are too damaged or outdated to circulate. The system must maintain accurate real-time counts while supporting bulk operations for inventory audits.
Acquisition Workflow
public class AcquisitionService
{
public async Task<AcquisitionResult> ProcessNewAcquisitionAsync(AcquisitionRequest request)
{
// Check if the book already exists in the catalog
var existingBook = await _db.Books
.FirstOrDefaultAsync(b => b.Isbn13 == request.Isbn13);
if (existingBook == null)
{
existingBook = await CreateBookFromAcquisitionAsync(request);
}
var copies = new List<BookCopy>();
foreach (var item in request.Items)
{
var copy = new BookCopy
{
Id = Guid.NewGuid(),
BookId = existingBook.Id,
BranchId = item.BranchId,
Barcode = await GenerateBarcodeAsync(item.BranchId),
Condition = CopyCondition.New,
Status = CopyStatus.Available,
AcquiredDate = DateTime.UtcNow,
ReplacementCost = item.Cost,
CheckoutCount = 0
};
copies.Add(copy);
}
_db.BookCopies.AddRange(copies);
await _db.SaveChangesAsync();
// Update search index
await _searchService.IndexBookAsync(existingBook);
// Update inventory cache
foreach (var copy in copies)
{
await _cache.InvalidateAvailabilityAsync(copy.BookId);
}
return AcquisitionResult.Success(copies.Count, existingBook.Id);
}
}
Inventory Audit Process
Libraries conduct periodic inventory audits to verify that physical items match the database records. The audit process involves scanning every item in the collection and comparing against expected counts. Discrepancies are flagged for investigation. We implement a rolling audit approach where each branch audits a section of its collection quarterly, rather than attempting a full audit annually.
public class InventoryAuditService
{
public async Task<AuditResult> StartAuditAsync(Guid branchId, string section)
{
var audit = new InventoryAudit
{
Id = Guid.NewGuid(),
BranchId = branchId,
Section = section,
Status = AuditStatus.InProgress,
StartedAt = DateTime.UtcNow,
StartedByStaffId = staffId
};
// Get expected inventory for this section
var expectedItems = await _db.BookCopies
.Where(c => c.BranchId == branchId
&& c.Status != CopyStatus.Withdrawn
&& MatchesSection(c, section))
.Select(c => new ExpectedItem
{
CopyId = c.Id,
Barcode = c.Barcode,
Title = c.Book.Title,
ExpectedStatus = c.Status
})
.ToListAsync();
audit.ExpectedCount = expectedItems.Count;
audit.ScannedCount = 0;
audit.DiscrepancyCount = 0;
_db.InventoryAudits.Add(audit);
await _db.SaveChangesAsync();
return AuditResult.Started(audit.Id, expectedItems.Count);
}
public async Task<ScanResult> RecordScanAsync(Guid auditId, string barcode)
{
var audit = await _db.InventoryAudits.FindAsync(auditId);
var copy = await _db.BookCopies
.FirstOrDefaultAsync(c => c.Barcode == barcode && c.BranchId == audit.BranchId);
audit.ScannedCount++;
if (copy == null)
{
audit.DiscrepancyCount++;
await RecordDiscrepancyAsync(auditId, null, DiscrepancyType.UnexpectedItem,
$"Unexpected item found: {barcode}");
return ScanResult.Unexpected(barcode);
}
if (copy.Status == CopyStatus.Withdrawn)
{
audit.DiscrepancyCount++;
await RecordDiscrepancyAsync(auditId, copy.Id, DiscrepancyType.ShouldBeWithdrawn,
"Item marked as withdrawn but still on shelf");
return ScanResult.Discrepancy(copy.Id, "Should be withdrawn");
}
return ScanResult.Valid(copy.Id, copy.Book.Title);
}
public async Task<AuditSummary> CompleteAuditAsync(Guid auditId)
{
var audit = await _db.InventoryAudits.FindAsync(auditId);
// Find missing items (in DB but not scanned)
var scannedCopyIds = await _db.AuditScans
.Where(s => s.AuditId == auditId && s.CopyId.HasValue)
.Select(s => s.CopyId.Value)
.ToListAsync();
var missingItems = await _db.BookCopies
.Where(c => c.BranchId == audit.BranchId
&& c.Status != CopyStatus.Withdrawn
&& !scannedCopyIds.Contains(c.Id))
.ToListAsync();
foreach (var missing in missingItems)
{
audit.DiscrepancyCount++;
await RecordDiscrepancyAsync(auditId, missing.Id, DiscrepancyType.Missing,
"Item in database but not found on shelf");
}
audit.Status = AuditStatus.Completed;
audit.CompletedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return new AuditSummary
{
ExpectedCount = audit.ExpectedCount,
ScannedCount = audit.ScannedCount,
MissingCount = missingItems.Count,
DiscrepancyCount = audit.DiscrepancyCount,
AccuracyRate = (double)(audit.ScannedCount - audit.DiscrepancyCount) / audit.ExpectedCount * 100
};
}
}
Inventory Status Summary
| Status | Description | Can Checkout? | Can Hold? |
|---|---|---|---|
| Available | On shelf, ready for checkout | Yes | No (why hold?) |
| CheckedOut | Currently borrowed by a patron | No | Yes |
| OnHold | Reserved for a specific patron | No | Queue available |
| InTransit | Moving between branches | No | At source or target |
| Withdrawn | Removed from circulation | No | No |
13. Member Management
Member management handles the complete lifecycle of library patrons from registration through account maintenance to eventual deactivation. The system supports multiple membership tiers with different borrowing privileges, fine thresholds, and access levels. Patron data must be handled with strict privacy controls compliant with applicable data protection regulations.
Membership Tiers and Privileges
| Tier | Max Loans | Loan Period | Max Renewals | Fine Threshold | Hold Limit |
|---|---|---|---|---|---|
| Student | 5 | 21 days | 2 | $5.00 | 3 |
| Faculty | 25 | 60 days | 5 | $25.00 | 10 |
| General Public | 10 | 21 days | 2 | $10.00 | 5 |
| Senior (65+) | 10 | 28 days | 3 | $10.00 | 5 |
| Institutional | 50 | 30 days | 3 | $50.00 | 20 |
public class PatronService
{
public async Task<PatronResult> RegisterAsync(RegisterPatronRequest request)
{
// Validate uniqueness
if (await _db.Patrons.AnyAsync(p => p.Email == request.Email))
return PatronResult.Failure("EMAIL_EXISTS", "Email already registered.");
var cardNumber = await GenerateLibraryCardNumberAsync(request.HomeBranchId);
var patron = new Patron
{
Id = Guid.NewGuid(),
LibraryCardNumber = cardNumber,
FirstName = request.FirstName,
LastName = request.LastName,
Email = request.Email,
Phone = request.Phone,
Address = request.Address,
Tier = request.Tier,
HomeBranchId = request.HomeBranchId,
MembershipExpiry = DateTime.UtcNow.AddYears(1),
IsActive = true,
IsBlocked = false,
OutstandingFines = 0,
CurrentLoanCount = 0,
MaxLoans = GetMaxLoans(request.Tier),
CreatedAt = DateTime.UtcNow
};
_db.Patrons.Add(patron);
await _db.SaveChangesAsync();
await _notifications.SendWelcomeAsync(patron);
return PatronResult.Success(patron.Id, cardNumber);
}
public async Task CheckAndBlockOverduePatronsAsync()
{
var patronsToBlock = await _db.Patrons
.Where(p => p.IsActive && !p.IsBlocked)
.Select(p => new
{
Patron = p,
Outstanding = _db.Fines
.Where(f => f.PatronId == p.Id
&& (f.Status == FineStatus.Outstanding || f.Status == FineStatus.Partial))
.Sum(f => f.Amount - f.AmountPaid)
})
.Where(x => x.Outstanding > GetFineThreshold(x.Patron.Tier))
.ToListAsync();
foreach (var entry in patronsToBlock)
{
entry.Patron.IsBlocked = true;
entry.Patron.OutstandingFines = entry.Outstanding;
}
await _db.SaveChangesAsync();
}
}
Patron Privacy Controls
Library patron data is sensitive. Borrowing history reveals reading preferences, political interests, and personal habits. Our system implements the following privacy controls: borrowing history older than 12 months is automatically anonymized (loan records are retained for analytics but patron identification is removed), patrons can opt out of recommendation features, patron data is encrypted at rest using AES-256, and access to patron records is logged for audit purposes.
public class PrivacyService
{
public async Task AnonymizeExpiredRecordsAsync()
{
var cutoffDate = DateTime.UtcNow.AddMonths(-12);
// Anonymize old loan records
var oldLoans = await _db.Loans
.Where(l => l.ReturnDate != null && l.ReturnDate < cutoffDate)
.Where(l => l.PatronId != null) // not yet anonymized
.ToListAsync();
foreach (var loan in oldLoans)
{
loan.PatronId = Guid.Empty; // anonymize
loan.CheckedOutByStaffId = null;
loan.ReturnedToStaffId = null;
}
await _db.SaveChangesAsync();
}
public async Task<PatronDataExport> ExportPatronDataAsync(Guid patronId)
{
// GDPR/CCPA right to data portability
var patron = await _db.Patrons.FindAsync(patronId);
var loans = await _db.Loans.Where(l => l.PatronId == patronId).ToListAsync();
var holds = await _db.Holds.Where(h => h.PatronId == patronId).ToListAsync();
var fines = await _db.Fines.Where(f => f.PatronId == patronId).ToListAsync();
return new PatronDataExport
{
Profile = patron,
BorrowingHistory = loans,
HoldHistory = holds,
FineHistory = fines,
ExportedAt = DateTime.UtcNow
};
}
public async Task DeletePatronDataAsync(Guid patronId)
{
// Right to be forgotten - retain anonymized transaction records
var patron = await _db.Patrons.FindAsync(patronId);
patron.IsActive = false;
patron.FirstName = "DELETED";
patron.LastName = "USER";
patron.Email = $"deleted_{patron.Id}@anonymized.local";
patron.Phone = null;
patron.Address = null;
// Anonymize all associated records
await _db.Loans
.Where(l => l.PatronId == patronId)
.ForEachAsync(l => l.PatronId = Guid.Empty);
await _db.SaveChangesAsync();
}
}
14. Reporting & Analytics
The reporting subsystem provides actionable insights for library administrators, collection managers, and branch directors. Reports range from daily operational dashboards (items checked out, returns processed, new registrations) to long-term collection analysis (most popular genres, underutilized items, reading trend shifts). The system supports both real-time dashboards for operational monitoring and scheduled batch reports for strategic planning.
Key Metrics Dashboard
| Metric | Real-Time (Redis) | Daily Batch (PostgreSQL) | Update Frequency |
|---|---|---|---|
| Checkouts Today | Redis INCR counter | COUNT(Loans) | Real-time / EOD |
| Returns Today | Redis INCR counter | COUNT(Loans WHERE Returned) | Real-time / EOD |
| Overdue Items | Redis Sorted Set | COUNT(Loans WHERE DueDate < NOW) | Real-time / Hourly |
| Active Patrons (30d) | N/A | COUNT(DISTINCT PatronId) | Daily |
| Popular Books (7d) | Redis Sorted Set | GROUP BY BookId, ORDER BY count | Real-time / Daily |
| Branch Utilization | Redis Hash per branch | AVG(checked_out / total) | Real-time / Hourly |
| Fine Collection (MTD) | Redis INCR | SUM(AmountPaid) | Real-time / EOD |
| New Registrations | Redis INCR | COUNT(Patrons WHERE CreatedAt) | Real-time / Daily |
public class ReportingService
{
public async Task<DashboardMetrics> GetRealTimeMetricsAsync(Guid? branchId = null)
{
var branchFilter = branchId.HasValue ? $":{branchId}" : "";
var metrics = new DashboardMetrics
{
CheckoutsToday = int.Parse(await _redis.StringGetAsync($"stats:checkouts{branchFilter}:today") ?? "0"),
ReturnsToday = int.Parse(await _redis.StringGetAsync($"stats:returns{branchFilter}:today") ?? "0"),
OverdueCount = int.Parse(await _redis.StringGetAsync($"stats:overdue{branchFilter}:count") ?? "0"),
ActiveHolds = int.Parse(await _redis.StringGetAsync($"stats:holds{branchFilter}:active") ?? "0"),
NewRegistrationsToday = int.Parse(await _redis.StringGetAsync($"stats:registrations{branchFilter}:today") ?? "0"),
FineCollectedToday = decimal.Parse(await _redis.StringGetAsync($"stats:fines{branchFilter}:today") ?? "0")
};
return metrics;
}
public async Task<PopularityReport> GetPopularityReportAsync(DateTime from, DateTime to, int topN = 50)
{
var report = new PopularityReport();
report.TopBooks = await _db.Loans
.Where(l => l.CheckoutDate >= from && l.CheckoutDate <= to)
.GroupBy(l => l.BookCopy.Book)
.Select(g => new BookPopularity
{
BookId = g.Key.Id,
Title = g.Key.Title,
Author = g.Key.Author,
CheckoutCount = g.Count(),
UniquePatrons = g.Select(l => l.PatronId).Distinct().Count(),
AverageLoanDuration = g.Average(l =>
(l.ReturnDate ?? DateTime.UtcNow - l.CheckoutDate).TotalDays)
})
.OrderByDescending(b => b.CheckoutCount)
.Take(topN)
.ToListAsync();
report.TopSubjects = await _db.Loans
.Where(l => l.CheckoutDate >= from && l.CheckoutDate <= to)
.SelectMany(l => l.BookCopy.Book.SubjectHeadings)
.GroupBy(s => s)
.Select(g => new SubjectPopularity
{
Subject = g.Key,
CheckoutCount = g.Count()
})
.OrderByDescending(s => s.CheckoutCount)
.Take(20)
.ToListAsync();
report.BranchComparisons = await GetBranchComparisonAsync(from, to);
return report;
}
public async Task GenerateScheduledReportsAsync()
{
// Weekly collection health report
var weeklyReport = new CollectionHealthReport
{
PeriodStart = DateTime.UtcNow.AddDays(-7),
PeriodEnd = DateTime.UtcNow,
TotalCirculation = await GetCirculationCountAsync(),
ActivePatrons = await GetActivePatronCountAsync(30),
CollectionTurnover = await CalculateCollectionTurnoverAsync(),
AverageDaysBetweenCheckouts = await CalculateAvgDaysBetweenCheckoutsAsync(),
LostItemsCount = await GetLostItemsCountAsync(),
DamagedItemsCount = await GetDamagedItemsCountAsync(),
NewAcquisitions = await GetNewAcquisitionsCountAsync(),
WithdrawnItems = await GetWithdrawnCountAsync()
};
await _emailService.SendReportAsync(
"library-admin@ayodhyya.com",
"Weekly Collection Health Report",
weeklyReport);
}
}
15. Notification System
The notification system keeps patrons informed about due dates, hold availability, fine updates, and library events. It must support multiple delivery channels — email, SMS, push notifications, and in-app messages — with configurable preferences per patron. The system uses an asynchronous message-driven architecture to ensure notifications are eventually delivered even during system load spikes.
Notification Types and Timelines
| Notification | Channel | When | Template |
|---|---|---|---|
| Checkout Confirmation | Email + Push | Immediately | Receipt with due date |
| Due Date Reminder (3 days) | Email + Push | 3 days before due | List of items due soon |
| Due Date Reminder (1 day) | Email + Push + SMS | 1 day before due | Urgent reminder |
| Overdue Notice | Email + SMS | 1 day after due | Fine accrual warning |
| Overdue Final Notice | Email + SMS + Mail | 14 days after due | Account suspension warning |
| Hold Ready for Pickup | Email + Push + SMS | Immediately | Pickup instructions |
| Hold Expiring (2 days) | Email + Push | 2 days before deadline | Pickup reminder |
| Fine Issued | Immediately | Fine details and payment link | |
| Membership Renewal | 30, 7, 1 days before expiry | Renewal instructions | |
| New Book Alert | Email (opt-in) | Weekly digest | New acquisitions in preferred genres |
public class NotificationService
{
private readonly IMessageBus _messageBus;
private readonly ITemplateEngine _templates;
public async Task SendDueDateReminderAsync(Loan loan)
{
var patron = loan.Patron;
var daysUntilDue = (loan.DueDate - DateTime.UtcNow).Days;
var channels = daysUntilDue switch
{
1 => new[] { NotificationChannel.Email, NotificationChannel.Push, NotificationChannel.SMS },
3 => new[] { NotificationChannel.Email, NotificationChannel.Push },
_ => new[] { NotificationChannel.Email }
};
foreach (var channel in channels)
{
if (!patron.Preferences.IsChannelEnabled(channel)) continue;
var notification = new NotificationMessage
{
Id = Guid.NewGuid(),
PatronId = patron.Id,
Type = NotificationType.DueDateReminder,
Channel = channel,
Subject = $"Reminder: '{loan.BookCopy.Book.Title}' due in {daysUntilDue} day(s)",
Body = await _templates.RenderAsync("due_date_reminder", new
{
PatronName = patron.FirstName,
BookTitle = loan.BookCopy.Book.Title,
DueDate = loan.DueDate.ToString("MMMM dd, yyyy"),
DaysRemaining = daysUntilDue,
FinePerDay = GetDailyFineRate(loan.BookCopy.Book.Format),
RenewalUrl = $"https://library.ayodhyya.com/renew/{loan.Id}"
}),
ScheduledFor = DateTime.UtcNow,
RetryCount = 0,
MaxRetries = 3
};
await _messageBus.PublishAsync("notifications.outgoing", notification);
}
}
public async Task ProcessNotificationQueueAsync()
{
var pendingNotifications = await _db.NotificationMessages
.Where(n => n.Status == NotificationStatus.Pending
&& n.ScheduledFor <= DateTime.UtcNow)
.OrderBy(n => n.ScheduledFor)
.Take(100)
.ToListAsync();
foreach (var notification in pendingNotifications)
{
try
{
switch (notification.Channel)
{
case NotificationChannel.Email:
await _emailService.SendAsync(
notification.RecipientEmail,
notification.Subject,
notification.Body);
break;
case NotificationChannel.SMS:
await _smsService.SendAsync(
notification.RecipientPhone,
notification.Body);
break;
case NotificationChannel.Push:
await _pushService.SendAsync(
notification.PatronId,
notification.Subject,
notification.Body);
break;
}
notification.Status = NotificationStatus.Sent;
notification.SentAt = DateTime.UtcNow;
}
catch (Exception ex)
{
notification.RetryCount++;
if (notification.RetryCount >= notification.MaxRetries)
{
notification.Status = NotificationStatus.Failed;
notification.ErrorMessage = ex.Message;
}
}
}
await _db.SaveChangesAsync();
}
}
16. Security & Access Control
Library management systems handle sensitive patron data including personal information, reading habits, and payment details. The security model must enforce role-based access control, protect data at rest and in transit, comply with data protection regulations, and prevent unauthorized access to the catalog and administrative functions. A compromised library system can expose the reading preferences of politicians, journalists, or dissidents — making patron privacy a civil liberties concern, not just a technical requirement.
Role-Based Access Control
| Role | Catalog | Checkout | Holds | Fines | Reports | Admin |
|---|---|---|---|---|---|---|
| Patron (self) | Read | Own loans | Own holds | Own fines | No | No |
| Staff | Read/Write | Any patron | Manage | Collect/Waive | Branch only | No |
| Branch Manager | Read/Write | Any patron | Manage | Full | Branch | Branch staff |
| System Admin | Full | Full | Full | Full | All | Full |
| API Service | Read | Scoped | Scoped | No | No | No |
public class AuthorizationService
{
public async Task<AuthorizationResult> AuthorizeAsync(
ClaimsPrincipal user, string action, object resource)
{
var role = user.FindFirst(ClaimTypes.Role)?.Value;
var patronId = user.FindFirst("patron_id")?.Value;
var branchId = user.FindFirst("branch_id")?.Value;
return (role, action) switch
{
("Patron", "checkout") => await AuthorizePatronCheckoutAsync(patronId, resource),
("Patron", "view_loans") => await AuthorizePatronResourceAsync(patronId, resource),
("Patron", "place_hold") => await AuthorizePatronHoldAsync(patronId, resource),
("Staff", "checkout") => AuthorizationResult.Allowed(),
("Staff", "manage_catalog") => AuthorizationResult.Allowed(),
("Staff", "collect_fine") => AuthorizationResult.Allowed(),
("Staff", "waive_fine") => AuthorizationResult.Denied("Requires manager approval"),
("BranchManager", _) => await AuthorizeBranchScopeAsync(branchId, resource),
("SystemAdmin", _) => AuthorizationResult.Allowed(),
_ => AuthorizationResult.Denied($"Unknown role: {role}")
};
}
}
public class SecurityMiddleware
{
public async Task InvokeAsync(HttpContext context)
{
// Rate limiting per patron
var patronId = context.User.FindFirst("patron_id")?.Value;
if (patronId != null)
{
var requestCount = await _rateLimiter.IncrementAsync(
$"ratelimit:{patronId}", TimeSpan.FromMinutes(1));
if (requestCount > 120)
{
context.Response.StatusCode = 429;
return;
}
}
// Audit logging for sensitive operations
if (IsAuditableOperation(context.Request))
{
await _auditLog.LogAsync(new AuditEntry
{
Timestamp = DateTime.UtcNow,
UserId = patronId ?? "anonymous",
Action = context.Request.Method,
Resource = context.Request.Path,
IpAddress = context.Connection.RemoteIpAddress?.ToString(),
UserAgent = context.Request.Headers["User-Agent"].ToString()
});
}
await _next(context);
}
}
Data Protection Measures
- Encryption at rest: AES-256 for patron PII in database columns, full disk encryption for database volumes
- Encryption in transit: TLS 1.3 for all API communication, mutual TLS for inter-service communication
- Key management: AWS KMS or HashiCorp Vault for encryption key rotation
- SQL injection prevention: Parameterized queries via Entity Framework, no raw SQL with string interpolation
- XSS prevention: Output encoding for all user-generated content in the catalog (reviews, notes)
- CSRF protection: Anti-forgery tokens on all state-changing forms
- Patron data retention: Automatic anonymization after 12 months, configurable per jurisdiction
- Payment security: PCI DSS compliance via tokenized payment processing through Stripe/Braintree
17. Cost Estimation
Understanding the infrastructure cost of a library management system helps justify architectural decisions and plan for growth. We estimate costs for a mid-sized city library network serving 2 million patrons across 30 branches with 8 million physical items.
Monthly Infrastructure Cost Breakdown
| Component | Specification | Monthly Cost | Notes |
|---|---|---|---|
| PostgreSQL (Primary) | db.r6g.xlarge, 500 GB SSD | $350 | Transactional workloads |
| PostgreSQL (Read Replica) | db.r6g.large, 500 GB SSD | $175 | Reporting queries |
| Elasticsearch | 3-node cluster, m5.large | $450 | Search indexing and queries |
| Redis | cache.r6g.large, 13 GB | $120 | Caching and rate limiting |
| Application Servers | 3× t3.large (2 vCPU, 8 GB) | $300 | API and business logic |
| Message Bus (RabbitMQ) | 3-node cluster, t3.medium | $150 | Async notifications |
| S3 Storage | 500 GB (cover images) | $12 | Book covers and static assets |
| CloudFront CDN | 1 TB transfer/month | $90 | Static assets and images |
| Load Balancer | Application LB | $25 | Traffic distribution |
| DNS (Route 53) | Hosted zone + queries | $2 | DNS management |
| Monitoring (CloudWatch) | Logs + Metrics | $50 | Observability |
| Email (SES) | 100,000 emails/month | $10 | Notifications |
| SMS (SNS) | 50,000 SMS/month | $75 | Urgent notifications |
| Total | ~$1,809/month |
Personnel Costs
| Role | Headcount | Monthly Cost | Total |
|---|---|---|---|
| Backend Engineers | 3 | $12,000 | $36,000 |
| Frontend Engineers | 2 | $11,000 | $22,000 |
| DevOps/SRE | 1 | $13,000 | $13,000 |
| QA Engineer | 1 | $9,000 | $9,000 |
| Product Manager | 1 | $11,000 | $11,000 |
| Total | 8 | $91,000/month |
18. Testing Strategy
A comprehensive testing strategy for a library management system must cover unit tests for business logic, integration tests for database operations, end-to-end tests for critical workflows, and load tests for peak scenarios. The borrowing and return flows are particularly important to test because they involve multi-step transactions where partial failures can leave the system in inconsistent states.
Test Categories and Coverage Targets
| Test Type | Scope | Coverage Target | Framework |
|---|---|---|---|
| Unit Tests | Fine calculation, eligibility checks, date logic | 90% | xUnit + Moq |
| Integration Tests | Database operations, search indexing | 80% | xUnit + TestContainers |
| Contract Tests | API request/response schemas | 100% | Pact |
| E2E Tests | Full checkout/return/hold flows | Critical paths | Playwright |
| Load Tests | Peak checkout throughput, search latency | N/A | k6 |
| Chaos Tests | Database failover, network partitions | N/A | Chaos Monkey |
public class CheckoutServiceTests
{
[Fact]
public async Task Checkout_ShouldFail_WhenPatronHasExceededFineThreshold()
{
// Arrange
var patron = new Patron
{
Id = Guid.NewGuid(),
IsActive = true,
IsBlocked = false,
Tier = PatronTier.General,
MaxLoans = 10,
CurrentLoanCount = 3,
OutstandingFines = 12.50m // Exceeds $10 threshold
};
var copy = new BookCopy
{
Id = Guid.NewGuid(),
Status = CopyStatus.Available,
Book = new Book { Format = BookFormat.Hardcover }
};
var mockDb = CreateMockDbContext(patron, copy);
var mockFineService = new Mock<IFineService>();
mockFineService.Setup(f => f.GetOutstandingTotalAsync(patron.Id))
.ReturnsAsync(12.50m);
var service = new CheckoutService(mockDb.Object, mockFineService.Object,
Mock.Of<INotificationService>(), Mock.Of<IInventoryCache>());
// Act
var result = await service.CheckoutAsync(new CheckoutRequest
{
PatronId = patron.Id,
CopyId = copy.Id,
BranchId = Guid.NewGuid()
});
// Assert
Assert.False(result.Success);
Assert.Equal("FINES_EXCEEDED", result.ErrorCode);
Assert.Contains("12.50", result.Message);
}
[Fact]
public async Task Checkout_ShouldCreateLoan_WhenAllConditionsMet()
{
// Arrange
var patron = new Patron
{
Id = Guid.NewGuid(),
IsActive = true,
IsBlocked = false,
Tier = PatronTier.General,
MaxLoans = 10,
CurrentLoanCount = 3,
OutstandingFines = 5.00m // Below $10 threshold
};
var copy = new BookCopy
{
Id = Guid.NewGuid(),
Status = CopyStatus.Available,
Book = new Book { Format = BookFormat.Hardcover }
};
var mockDb = CreateMockDbContext(patron, copy);
var mockFineService = new Mock<IFineService>();
mockFineService.Setup(f => f.GetOutstandingTotalAsync(patron.Id))
.ReturnsAsync(5.00m);
var service = new CheckoutService(mockDb.Object, mockFineService.Object,
Mock.Of<INotificationService>(), Mock.Of<IInventoryCache>());
// Act
var result = await service.CheckoutAsync(new CheckoutRequest
{
PatronId = patron.Id,
CopyId = copy.Id,
BranchId = Guid.NewGuid()
});
// Assert
Assert.True(result.Success);
Assert.NotNull(result.Data.LoanId);
Assert.True(result.Data.DueDate > DateTime.UtcNow);
Assert.Equal(21, (result.Data.DueDate - DateTime.UtcNow).Days);
}
[Theory]
[InlineData(BookFormat.Hardcover, PatronTier.Student, 21)]
[InlineData(BookFormat.Hardcover, PatronTier.Faculty, 60)]
[InlineData(BookFormat.Ebook, PatronTier.General, 14)]
[InlineData(BookFormat.DVD, PatronTier.General, 14)]
public void LoanPeriod_ShouldMatch_BasedOnFormatAndTier(
BookFormat format, PatronTier tier, int expectedDays)
{
var patron = new Patron { Tier = tier };
var copy = new BookCopy { Book = new Book { Format = format } };
var calculator = new FineCalculator(new FinePolicyConfiguration());
var loanPeriod = calculator.GetLoanPeriod(copy, patron);
Assert.Equal(expectedDays, loanPeriod);
}
}
Load Testing Scenarios
// k6 load test script for checkout throughput
/*
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '2m', target: 100 }, // ramp up
{ duration: '5m', target: 500 }, // peak load
{ duration: '2m', target: 1000 }, // stress test
{ duration: '3m', target: 500 }, // sustained peak
{ duration: '2m', target: 0 }, // ramp down
],
thresholds: {
http_req_duration: ['p(95)<200', 'p(99)<500'],
http_req_failed: ['rate<0.01'],
},
};
export default function () {
const checkoutPayload = JSON.stringify({
patronId: `patron-${__VU % 10000}`,
copyId: `copy-${__ITER % 50000}`,
branchId: `branch-${__VU % 30}`
});
const checkoutRes = http.post('https://api.library.ayodhyya.com/api/v1/loans/checkout',
checkoutPayload,
{ headers: { 'Content-Type': 'application/json' } });
check(checkoutRes, {
'checkout status is 200': (r) => r.status === 200,
'checkout latency < 200ms': (r) => r.timings.duration < 200,
});
sleep(Math.random() * 5 + 1); // 1-6 second think time
}
*/
19. Interview Q&A
The following questions and answers cover the most frequently asked library management system design questions in senior and staff-level system design interviews. Each answer addresses not just the "what" but the "why" behind design decisions.
Q1: How do you prevent the race condition where two patrons try to check out the same last copy simultaneously?
Answer: We use database-level serializable isolation for the checkout transaction. When two concurrent checkout requests target the same copy, the database serializes them — one transaction commits successfully, and the other must either retry (if the copy is now unavailable) or fail. We can also implement an optimistic concurrency check using a row version stamp on the BookCopy record. If the version has changed when we attempt to update, we know another transaction modified the record and we roll back. In Redis, we can use a distributed lock with SETNX on the copy's barcode during checkout to provide an application-level mutex.
Q2: How would you handle a scenario where a patron places a hold, but the book is returned at a different branch?
Answer: The hold system operates at the book level (not copy level), so when any copy of the held book becomes available at any branch, the system checks if the returning branch matches the patron's preferred branch. If it does, the copy is reserved for pickup at that branch. If not, the system can either: (a) fulfill the hold at the returning branch and ask the patron to travel there, (b) transfer the copy to the preferred branch before fulfilling the hold, or (c) present the patron with a choice. Option (b) introduces a delay but provides the best patron experience. The hold record tracks which branch was selected for fulfillment.
Q3: How do you handle the hold queue fairness when multiple patrons place holds simultaneously?
Answer: The queue position is determined at the time of hold placement using a serializable transaction. The system queries the current maximum queue position for the book and assigns position + 1. Serializable isolation ensures that two concurrent hold placements receive different queue positions. We also consider patron tier as a secondary sort factor — in some libraries, faculty members may be prioritized over general patrons for academic materials. This can be implemented by assigning queue positions within tier buckets: positions 1-5 for faculty holds, positions 6-15 for general holds, etc.
Q4: How do you ensure the search index stays consistent with the catalog database?
Answer: We use Change Data Capture (CDC) via Debezium to capture row-level changes from PostgreSQL and publish them to a Kafka topic. The Search Service consumes these events and updates the Elasticsearch index. This provides eventual consistency with a typical lag of 2-5 seconds. For critical scenarios where immediate consistency is needed (e.g., a newly added book should be searchable immediately), we implement a synchronous index update as part of the catalog write transaction, falling back to the async CDC pipeline for recovery. We also run a reconciliation job nightly that compares the database and search index to catch any drift.
Q5: How would you design the fine calculation to handle library holidays and closures?
Answer: Each branch maintains a holiday calendar stored in the database. When calculating fines, the FineCalculator retrieves the list of closed days between the due date and return date. Closed days are subtracted from the overdue count before applying the daily rate. This is important because patrons should not be penalized for days when the library was physically closed and could not return items. The holiday calendar is configurable per branch (different branches may have different local holidays) and can be updated by branch managers through the admin interface.
Q6: How would you scale this system for a national library network with 10,000 branches?
Answer: At national scale, we would shard the database by region, with each region owning its branch and patron data. The catalog would be replicated globally with eventual consistency (search is tolerant of stale data). Cross-region operations like inter-library loans would go through a dedicated service with asynchronous coordination. We would use a global Redis cluster for caching hot catalog entries and implement a CDN for static assets. The notification system would be region-localized to ensure low latency. Each region would have its own Elasticsearch cluster, with a global catalog service that aggregates results across regions for nationwide search.
Q7: How do you handle the transition from a physical to a digital-first library?
Answer: The data model already supports multiple formats (eBook, audiobook, physical). Digital items use DRM integration (Adobe DRM, Readium LCP) through a separate Digital Rights Service. Checkout for digital items creates a time-limited license instead of tracking a physical copy. The lending rules differ: digital items have no physical damage risk but may have simultaneous user limits (some publishers restrict the number of concurrent digital loans per license). The system tracks digital licenses separately from physical inventory, and the reporting system merges both for total circulation metrics.
Q8: How do you implement self-checkout kiosks that work offline?
Answer: Self-checkout kiosks run a local application that caches patron eligibility data and item availability for items scanned at that kiosk. When a checkout is performed offline, it is stored locally in a queue with a unique transaction ID. When network connectivity is restored, the kiosk syncs its transaction queue to the central system. The sync process handles conflicts — if an item was checked out elsewhere while the kiosk was offline, the sync will fail that transaction and flag it for staff resolution. The local cache is refreshed every 15 minutes when online, and kiosk staff can trigger a manual sync.
Q9: What database indexing strategy would you use to optimize the "find available copies near me" query?
Answer: This query requires joining book availability with branch geolocation. We create a composite index on book_copies(book_id, status, branch_id) for the availability check, and a geospatial index on branches(location) for proximity. The query first identifies branches within the desired radius using PostGIS's ST_DWithin, then checks availability at those branches. We materialize branch-level availability counts in a Redis sorted set keyed by branch_id, updated on every checkout and return. This avoids querying the database for availability on every search result and provides sub-millisecond lookups.
Q10: How would you handle a patron dispute about a fine they believe is incorrect?
Answer: The fine system maintains a complete audit trail: every fine is linked to a specific loan record with exact timestamps for checkout, due date, and return. The Fine Dispute workflow allows patrons to submit a dispute through the portal, which creates a dispute record and pauses fine accrual on the disputed amount. A staff member reviews the audit trail, the loan timestamps, and any supporting evidence (e.g., a return receipt). They can either uphold the fine, partially waive it, or fully waive it with a documented reason. All waiver actions are logged with the staff member's ID for accountability. The system also supports automated dispute resolution for common cases like system-caused incorrect due dates.