System Design: Library Management System in .NET — Catalog, Reservations, Multi-Branch, and Fine Calculation
Design a multi-branch library management system in .NET: book catalog with full-text search, reservation queue, loan lifecycle, overdue fine calculation, RFID integration, and branch federation — plus the CQRS trade-offs for a read-heavy catalog.
System Design: Library Management System in .NET
A library management system sounds deceptively simple. In reality it is a distributed inventory system with strict consistency requirements, a reservation queue that must be fair under race conditions, and a financial ledger that accumulates complexity over years of renewals, waivers, and partial payments. This case study walks through how to design it properly in .NET 9, with real trade-offs explained at each step.
System Overview
The core user journey flows through six stages:
Member searches catalog
↓
Member places reservation (or directly borrows at desk)
↓
Copy becomes available → Member notified (48h hold window)
↓
Member collects copy → Loan created (Active)
↓
Loan due date passes without return → Loan transitions to Overdue
↓
Member returns copy → Fine calculated → Loan closedSupporting that journey are five bounded contexts:
- Catalog — the bibliographic record of every book (title, author, ISBN, subject headings, description). Read-heavy, rarely written.
- Inventory — physical copies, which branch holds them, their condition, RFID tag identifiers.
- Circulation — loans and the lifecycle from checkout to return.
- Reservations — the queue of members waiting for a specific ISBN at a specific (or any) branch.
- Fines — the financial ledger for overdue charges, payments, and waivers.
Each bounded context owns its own tables. They communicate through domain events published via an outbox pattern, not foreign key joins across contexts.
Data Model
Catalog: Book and Edition
A Book in the catalog represents the abstract work — the intellectual content. A physical Copy in inventory represents one instance held by a branch. This separation matters: when a member searches for "Clean Code", they search the catalog. When they borrow it, they borrow a specific copy.
// Catalog bounded context
public class Book
{
public Guid Id { get; private set; }
public string Isbn13 { get; private set; }
public string Title { get; private set; }
public string Author { get; private set; }
public string Publisher { get; private set; }
public int PublicationYear { get; private set; }
public string Description { get; private set; }
public string[] SubjectHeadings { get; private set; }
public BookStatus Status { get; private set; }
// Populated by EF Core from a generated tsvector column.
// Never set directly in domain code.
public NpgsqlTsVector SearchVector { get; private set; } = null!;
private Book() { } // EF Core constructor
public static Book Create(
string isbn13,
string title,
string author,
string publisher,
int publicationYear,
string description,
string[] subjectHeadings)
{
ArgumentException.ThrowIfNullOrWhiteSpace(isbn13);
ArgumentException.ThrowIfNullOrWhiteSpace(title);
return new Book
{
Id = Guid.NewGuid(),
Isbn13 = isbn13,
Title = title,
Author = author,
Publisher = publisher,
PublicationYear = publicationYear,
Description = description,
SubjectHeadings = subjectHeadings,
Status = BookStatus.Active
};
}
}
public enum BookStatus { Active, Withdrawn, OnOrder }Inventory: Copy
// Inventory bounded context
public class Copy
{
public Guid Id { get; private set; }
public string Isbn13 { get; private set; } // denormalised reference to catalog
public Guid BranchId { get; private set; }
public string Barcode { get; private set; }
public string RfidTag { get; private set; }
public CopyCondition Condition { get; private set; }
public CopyStatus Status { get; private set; }
// EF Core optimistic concurrency token
public uint RowVersion { get; private set; }
private Copy() { }
public void MarkOnLoan()
{
if (Status != CopyStatus.Available)
throw new InvalidOperationException(
$"Copy {Id} cannot be loaned from status {Status}.");
Status = CopyStatus.OnLoan;
}
public void MarkAvailable()
{
Status = CopyStatus.Available;
}
public void MarkInTransit(Guid destinationBranchId)
{
Status = CopyStatus.InTransit;
}
}
public enum CopyStatus { Available, OnLoan, InTransit, Lost, Withdrawn }
public enum CopyCondition { New, Good, Fair, Poor }Circulation: Loan
The loan is a state machine. Every transition is explicit, and invalid transitions throw rather than silently succeed.
public class Loan
{
public Guid Id { get; private set; }
public Guid CopyId { get; private set; }
public Guid MemberId { get; private set; }
public Guid BranchId { get; private set; }
public DateOnly CheckoutDate { get; private set; }
public DateOnly DueDate { get; private set; }
public DateOnly? ReturnDate { get; private set; }
public LoanStatus Status { get; private set; }
public int RenewalCount { get; private set; }
private readonly List<DomainEvent> _events = [];
public IReadOnlyList<DomainEvent> DomainEvents => _events;
private Loan() { }
public static Loan Create(Guid copyId, Guid memberId, Guid branchId,
DateOnly checkoutDate, DateOnly dueDate)
{
var loan = new Loan
{
Id = Guid.NewGuid(),
CopyId = copyId,
MemberId = memberId,
BranchId = branchId,
CheckoutDate = checkoutDate,
DueDate = dueDate,
Status = LoanStatus.Active,
RenewalCount = 0
};
loan._events.Add(new LoanCreatedEvent(loan.Id, copyId, memberId, branchId, dueDate));
return loan;
}
public void Renew(DateOnly newDueDate, int maxRenewals)
{
if (Status != LoanStatus.Active && Status != LoanStatus.Overdue)
throw new InvalidOperationException("Only active or overdue loans can be renewed.");
if (RenewalCount >= maxRenewals)
throw new DomainException($"Maximum renewals ({maxRenewals}) reached.");
RenewalCount++;
DueDate = newDueDate;
Status = LoanStatus.Active;
_events.Add(new LoanRenewedEvent(Id, MemberId, newDueDate, RenewalCount));
}
public void MarkOverdue()
{
if (Status != LoanStatus.Active)
return; // idempotent — background job may call this repeatedly
Status = LoanStatus.Overdue;
_events.Add(new LoanOverdueEvent(Id, MemberId, DueDate));
}
public void Return(DateOnly returnDate)
{
if (Status == LoanStatus.Returned)
return; // idempotent
if (Status == LoanStatus.Lost)
throw new InvalidOperationException("Lost items require a different return workflow.");
ReturnDate = returnDate;
Status = LoanStatus.Returned;
_events.Add(new LoanReturnedEvent(Id, CopyId, MemberId, returnDate, DueDate));
}
}
public enum LoanStatus { Active, Overdue, Returned, Lost }Reservations: Queue Entry
public class Reservation
{
public Guid Id { get; private set; }
public string Isbn13 { get; private set; }
public Guid MemberId { get; private set; }
public Guid? PreferredBranchId { get; private set; } // null = any branch
public Guid? AssignedBranchId { get; private set; }
public int QueuePosition { get; private set; }
public ReservationStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }
public DateTime? HoldExpiresAt { get; private set; } // set when copy is assigned
}
public enum ReservationStatus
{
Queued,
HoldReady, // copy assigned, member has 48h to collect
Fulfilled, // member collected the copy
Expired, // hold window passed, copy reassigned
Cancelled
}Design Decision: CQRS for the Catalog
Why CQRS Here
The catalog exhibits an extreme read/write imbalance:
- 500,000 books in the system
- ~50 catalog updates per day (new acquisitions, corrections)
- ~10,000 search queries per minute (members, staff, self-service kiosks)
A traditional CRUD approach puts both reads and writes through the same EF Core DbContext querying the same PostgreSQL table. For catalog search this creates a problem: full-text queries are expensive, and you need a tsvector GIN index to make them fast, but the query must still go through the same ORM layer that manages writes.
CQRS separates the write model (command side, validates invariants, maintains the canonical record) from the read model (query side, optimised purely for retrieval).
Write side — a CatalogCommandService that accepts CreateBookCommand / UpdateBookCommand, validates business rules, and saves through EF Core.
Read side — a CatalogSearchService that queries a PostgreSQL books_search_view (or a dedicated Elasticsearch index) using raw SQL for ts_rank scoring.
For a system of this scale, a materialized view or PostgreSQL full-text is sufficient. Elasticsearch adds operational complexity that only pays off above ~5M documents or when you need faceting/fuzzy matching.
// WRITE SIDE — command handler via MediatR
public sealed class CreateBookCommandHandler
: IRequestHandler<CreateBookCommand, Guid>
{
private readonly CatalogDbContext _db;
private readonly ILogger<CreateBookCommandHandler> _logger;
public CreateBookCommandHandler(CatalogDbContext db,
ILogger<CreateBookCommandHandler> logger)
{
_db = db;
_logger = logger;
}
public async Task<Guid> Handle(CreateBookCommand cmd,
CancellationToken ct)
{
// Duplicate ISBN check on the write side only
var exists = await _db.Books
.AnyAsync(b => b.Isbn13 == cmd.Isbn13, ct);
if (exists)
throw new ConflictException($"ISBN {cmd.Isbn13} already registered.");
var book = Book.Create(
cmd.Isbn13, cmd.Title, cmd.Author,
cmd.Publisher, cmd.PublicationYear,
cmd.Description, cmd.SubjectHeadings);
_db.Books.Add(book);
await _db.SaveChangesAsync(ct);
_logger.LogInformation("Book {BookId} ({Isbn13}) created.", book.Id, book.Isbn13);
return book.Id;
}
}PostgreSQL Full-Text Search with tsvector
The tsvector column is generated by PostgreSQL itself — we never compute it in C#.
// EF Core model configuration for the Book entity
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.HasKey(b => b.Id);
builder.Property(b => b.Isbn13).HasMaxLength(13).IsRequired();
builder.HasIndex(b => b.Isbn13).IsUnique();
// Map the generated tsvector column.
// PostgreSQL generates it from title, author, and subject headings.
// We use HasComputedColumnSql so EF Core never tries to write to it.
builder.Property(b => b.SearchVector)
.HasColumnType("tsvector")
.HasComputedColumnSql(
"to_tsvector('english', " +
"coalesce(title, '') || ' ' || " +
"coalesce(author, '') || ' ' || " +
"coalesce(description, '') || ' ' || " +
"coalesce(array_to_string(subject_headings, ' '), ''))",
stored: true);
// GIN index enables fast full-text search
builder.HasIndex(b => b.SearchVector)
.HasMethod("GIN");
builder.Property(b => b.Status)
.HasConversion<string>()
.HasMaxLength(20);
}
}READ SIDE: CatalogSearchService
// READ SIDE — thin query service, no EF Core change tracking
public sealed class CatalogSearchService
{
private readonly IDbConnectionFactory _connectionFactory;
public CatalogSearchService(IDbConnectionFactory connectionFactory)
=> _connectionFactory = connectionFactory;
public async Task<PagedResult<BookSearchResult>> SearchAsync(
string query,
int page,
int pageSize,
CancellationToken ct)
{
ArgumentException.ThrowIfNullOrWhiteSpace(query);
// ts_rank_cd ranks results by term frequency and proximity.
// plainto_tsquery converts a plain string like "clean code"
// into a tsquery without requiring special syntax from the user.
const string sql = """
SELECT
id,
isbn13,
title,
author,
publisher,
publication_year,
ts_rank_cd(search_vector, query) AS rank,
COUNT(*) OVER() AS total_count
FROM books,
plainto_tsquery('english', @query) query
WHERE search_vector @@ query
AND status = 'Active'
ORDER BY rank DESC
LIMIT @pageSize OFFSET @offset
""";
await using var conn = await _connectionFactory.OpenAsync(ct);
var rows = await conn.QueryAsync<BookSearchRow>(
sql,
new { query, pageSize, offset = (page - 1) * pageSize });
var list = rows.ToList();
var totalCount = list.Count > 0 ? list[0].TotalCount : 0;
return new PagedResult<BookSearchResult>(
Items: list.Select(r => new BookSearchResult(
r.Id, r.Isbn13, r.Title, r.Author,
r.Publisher, r.PublicationYear, r.Rank)),
TotalCount: totalCount,
Page: page,
PageSize: pageSize);
}
}The read side uses Dapper directly — no EF Core, no change tracking overhead. A CatalogDbContext is only loaded when a write command arrives.
Reservation Queue: Fairness Under Race Conditions
The Design
The reservation queue is FIFO per ISBN. When a member reserves a book, they get a queue position. When any copy of that ISBN is returned, the system assigns it to the member at position 1, sets a 48-hour hold window, and notifies them.
The fairness requirement is strict: two members who reserve the same ISBN must be served in the exact order they placed their reservations, regardless of which branch the copy came from (unless the member specified a preferred branch, which acts as a filter, not a strict constraint).
The Race Condition
When a copy is returned, two things must happen atomically:
- Find the next eligible reservation in the queue.
- Assign that specific copy to that specific reservation.
Without a lock, if two copies of the same book are returned within milliseconds by different branches, both return handlers might read the same reservation (queue position 1) as unassigned and both try to assign to it — resulting in one member getting notified twice and the next member being skipped.
The solution is a distributed lock per ISBN combined with a pessimistic row lock on the Copy row during assignment.
public sealed class ReservationQueueService
{
private readonly CirculationDbContext _db;
private readonly IDistributedLockProvider _lockProvider;
private readonly INotificationService _notifications;
private readonly TimeProvider _timeProvider;
private readonly ILogger<ReservationQueueService> _logger;
// The hold window: member has 48 hours to collect after notification
private static readonly TimeSpan HoldWindow = TimeSpan.FromHours(48);
public ReservationQueueService(
CirculationDbContext db,
IDistributedLockProvider lockProvider,
INotificationService notifications,
TimeProvider timeProvider,
ILogger<ReservationQueueService> logger)
{
_db = db;
_lockProvider = lockProvider;
_notifications = notifications;
_timeProvider = timeProvider;
_logger = logger;
}
/// <summary>
/// Called when a copy is returned. Finds the next reservation in queue
/// and assigns the copy, or marks the copy Available if no queue exists.
/// </summary>
public async Task ProcessReturnAsync(
Guid copyId, string isbn13, Guid branchId, CancellationToken ct)
{
// Acquire a distributed lock scoped to this ISBN.
// Only one node can process reservations for this ISBN at a time.
// Using Medallion.Threading or RedLock.net in production.
var lockKey = $"reservation:isbn:{isbn13}";
await using var @lock = await _lockProvider.AcquireAsync(lockKey, ct);
// Load the next eligible reservation inside the lock.
// We use a raw SQL pessimistic lock (FOR UPDATE SKIP LOCKED)
// on the reservation row to prevent any concurrent handler
// that somehow bypassed the distributed lock from double-assigning.
var nextReservation = await _db.Reservations
.FromSqlRaw("""
SELECT * FROM reservations
WHERE isbn13 = {0}
AND status = 'Queued'
ORDER BY queue_position ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
""", isbn13)
.FirstOrDefaultAsync(ct);
if (nextReservation is null)
{
// No queue — mark the copy available immediately
await MarkCopyAvailableAsync(copyId, ct);
_logger.LogInformation(
"Copy {CopyId} returned with no reservation queue. Marked available.", copyId);
return;
}
// Assign the copy to this reservation
nextReservation.Assign(copyId, branchId, _timeProvider.GetUtcNow(), HoldWindow);
// Update copy status to OnHold (inventory context, same transaction)
await MarkCopyOnHoldAsync(copyId, ct);
await _db.SaveChangesAsync(ct);
// Notify outside the transaction — failure here is non-critical,
// a background job will retry notifications for HoldReady reservations
// that have no member notification sent yet.
await _notifications.NotifyHoldReadyAsync(
nextReservation.MemberId,
isbn13,
branchId,
nextReservation.HoldExpiresAt!.Value,
ct);
_logger.LogInformation(
"Reservation {ReservationId} assigned copy {CopyId}. " +
"Member {MemberId} notified, hold expires {HoldExpiry}.",
nextReservation.Id, copyId,
nextReservation.MemberId, nextReservation.HoldExpiresAt);
}
}Hold Expiry Background Job
If the member does not collect within 48 hours, the hold expires and the copy is reassigned to the next reservation.
public sealed class HoldExpiryJob(
CirculationDbContext db,
ReservationQueueService queueService,
TimeProvider timeProvider,
ILogger<HoldExpiryJob> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(15));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await ProcessExpiredHoldsAsync(stoppingToken);
}
}
private async Task ProcessExpiredHoldsAsync(CancellationToken ct)
{
var now = timeProvider.GetUtcNow();
var expiredHolds = await db.Reservations
.Where(r => r.Status == ReservationStatus.HoldReady
&& r.HoldExpiresAt < now)
.Include(r => r.AssignedCopyId)
.ToListAsync(ct);
foreach (var reservation in expiredHolds)
{
reservation.Expire();
logger.LogWarning(
"Hold expired for reservation {ReservationId}, member {MemberId}.",
reservation.Id, reservation.MemberId);
// Re-enter the copy into the queue processing pipeline
if (reservation.AssignedCopyId.HasValue)
{
await queueService.ProcessReturnAsync(
reservation.AssignedCopyId.Value,
reservation.Isbn13,
reservation.AssignedBranchId!.Value,
ct);
}
}
await db.SaveChangesAsync(ct);
}
}Fine Calculation: Event-Sourced Ledger
Why Event Sourcing for Fines
Fine calculation looks simple at first: (returnDate - dueDate) * dailyRate. But real scenarios compound quickly:
- Member renews a loan — the due date changes, any accrued days should be locked in
- Member makes a partial payment — the balance must reduce correctly
- Staff grants a waiver on compassionate grounds — must be auditable
- Member's loan record spans multiple rate changes (branch policy updated)
- Loan is marked lost, then the item is returned — fine structure changes
Recalculating a fine from the current state of the Loan row is fragile. Any data correction or retroactive policy change will silently produce wrong amounts. Instead, every financially significant event is appended to a FineEvent table. The current balance is always derived by replaying events — never stored as a mutable column.
// Domain events written to the fine ledger
public abstract record FineEvent
{
public Guid Id { get; init; } = Guid.NewGuid();
public Guid LoanId { get; init; }
public Guid MemberId { get; init; }
public DateTime OccurredAt { get; init; }
}
public sealed record FineAccruedEvent(
Guid LoanId, Guid MemberId, DateTime OccurredAt,
int OverdueDays, decimal DailyRate, decimal Amount) : FineEvent;
public sealed record PaymentReceivedEvent(
Guid LoanId, Guid MemberId, DateTime OccurredAt,
decimal AmountPaid, string PaymentReference) : FineEvent;
public sealed record WaiverGrantedEvent(
Guid LoanId, Guid MemberId, DateTime OccurredAt,
decimal AmountWaived, string WaiverReason, Guid GrantedByStaffId) : FineEvent;
public sealed record CapAppliedEvent(
Guid LoanId, Guid MemberId, DateTime OccurredAt,
decimal CapAmount, decimal OriginalAmount) : FineEvent;public sealed class FineCalculator
{
private readonly FinesDbContext _db;
private readonly IBranchPolicyService _policy;
private readonly TimeProvider _timeProvider;
public FineCalculator(FinesDbContext db, IBranchPolicyService policy,
TimeProvider timeProvider)
{
_db = db;
_policy = policy;
_timeProvider = timeProvider;
}
/// <summary>
/// Called when a loan is returned. Appends a FineAccruedEvent if overdue,
/// then applies the branch cap. Idempotent — checks for existing accrual event.
/// </summary>
public async Task RecordReturnFineAsync(
Guid loanId, Guid memberId, Guid branchId,
DateOnly dueDate, DateOnly returnDate,
CancellationToken ct)
{
// Idempotency: if a FineAccruedEvent already exists for this loan, skip.
var alreadyAccrued = await _db.FineEvents
.OfType<FineAccruedEvent>()
.AnyAsync(e => e.LoanId == loanId, ct);
if (alreadyAccrued) return;
var policy = await _policy.GetPolicyAsync(branchId, ct);
var overdueDays = Math.Max(0, returnDate.DayNumber
- dueDate.DayNumber
- policy.GracePeriodDays);
if (overdueDays == 0) return;
var rawFine = overdueDays * policy.DailyRatePence / 100m;
_db.FineEvents.Add(new FineAccruedEvent(
loanId, memberId,
_timeProvider.GetUtcNow().UtcDateTime,
overdueDays, policy.DailyRatePence / 100m, rawFine));
// Apply cap if exceeded
if (rawFine > policy.MaxFinePerLoan / 100m)
{
_db.FineEvents.Add(new CapAppliedEvent(
loanId, memberId,
_timeProvider.GetUtcNow().UtcDateTime,
policy.MaxFinePerLoan / 100m, rawFine));
}
await _db.SaveChangesAsync(ct);
}
/// <summary>
/// Projects the current outstanding balance for a loan by replaying all events.
/// This is the ONLY way the balance is computed — never stored as a column.
/// </summary>
public async Task<FineBalance> GetBalanceAsync(Guid loanId, CancellationToken ct)
{
var events = await _db.FineEvents
.Where(e => e.LoanId == loanId)
.OrderBy(e => e.OccurredAt)
.ToListAsync(ct);
decimal accrued = 0m;
decimal paid = 0m;
decimal waived = 0m;
decimal cap = decimal.MaxValue;
foreach (var e in events)
{
switch (e)
{
case FineAccruedEvent a:
accrued += a.Amount;
break;
case CapAppliedEvent c:
cap = c.CapAmount;
break;
case PaymentReceivedEvent p:
paid += p.AmountPaid;
break;
case WaiverGrantedEvent w:
waived += w.AmountWaived;
break;
}
}
var gross = Math.Min(accrued, cap);
var outstanding = Math.Max(0m, gross - paid - waived);
return new FineBalance(loanId, accrued, cap, paid, waived, outstanding);
}
}
public record FineBalance(
Guid LoanId,
decimal Accrued,
decimal Cap,
decimal Paid,
decimal Waived,
decimal Outstanding);RFID Integration
RFID tag scans arrive as events from self-checkout kiosks and staff workstations. The tag contains a RfidTag identifier that maps to a specific Copy. The system must be able to handle:
- Check-out scan: member scans their card, then scans one or more books — creates loans
- Return scan: book scanned at returns drop — triggers return workflow
- Inventory scan: staff scans shelves — reconciles physical inventory against database
public sealed class RfidEventProcessor(
InventoryDbContext inventoryDb,
CirculationDbContext circulationDb,
ReservationQueueService queueService,
FineCalculator fineCalculator,
TimeProvider timeProvider,
ILogger<RfidEventProcessor> logger)
{
public async Task HandleScanAsync(RfidScanEvent scan, CancellationToken ct)
{
var copy = await inventoryDb.Copies
.FirstOrDefaultAsync(c => c.RfidTag == scan.RfidTag, ct);
if (copy is null)
{
logger.LogWarning("RFID tag {Tag} not found in inventory.", scan.RfidTag);
return;
}
switch (scan.ScanType)
{
case ScanType.CheckOut:
await HandleCheckOutAsync(copy, scan, ct);
break;
case ScanType.Return:
await HandleReturnAsync(copy, scan, ct);
break;
case ScanType.InventoryAudit:
await HandleInventoryAuditAsync(copy, scan, ct);
break;
}
}
private async Task HandleReturnAsync(Copy copy, RfidScanEvent scan, CancellationToken ct)
{
var activeLoan = await circulationDb.Loans
.FirstOrDefaultAsync(l => l.CopyId == copy.Id
&& (l.Status == LoanStatus.Active || l.Status == LoanStatus.Overdue), ct);
if (activeLoan is null)
{
logger.LogWarning(
"Return scan for copy {CopyId} but no active loan found.", copy.Id);
return;
}
var today = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
activeLoan.Return(today);
await circulationDb.SaveChangesAsync(ct);
// Trigger fine calculation if overdue
if (activeLoan.DueDate < today)
{
await fineCalculator.RecordReturnFineAsync(
activeLoan.Id, activeLoan.MemberId, activeLoan.BranchId,
activeLoan.DueDate, today, ct);
}
// Trigger queue processing — may assign copy to next reservation
await queueService.ProcessReturnAsync(
copy.Id, copy.Isbn13, copy.BranchId, ct);
}
}Multi-Branch: Inter-Library Loan Transfer
When a member requests a specific ISBN and the preferred branch has no copies available but another branch does, the system can initiate a transfer. This is modelled as a separate TransferRequest aggregate with its own state machine.
public class TransferRequest
{
public Guid Id { get; private set; }
public Guid CopyId { get; private set; }
public Guid SourceBranchId { get; private set; }
public Guid DestinationBranchId { get; private set; }
public Guid ReservationId { get; private set; }
public TransferStatus Status { get; private set; }
public DateTime RequestedAt { get; private set; }
public DateTime? ShippedAt { get; private set; }
public DateTime? ReceivedAt { get; private set; }
public void MarkShipped(DateTime shippedAt)
{
if (Status != TransferStatus.Pending)
throw new InvalidOperationException("Only pending transfers can be shipped.");
Status = TransferStatus.InTransit;
ShippedAt = shippedAt;
}
public void MarkReceived(DateTime receivedAt)
{
if (Status != TransferStatus.InTransit)
throw new InvalidOperationException("Only in-transit transfers can be received.");
Status = TransferStatus.Received;
ReceivedAt = receivedAt;
}
}
public enum TransferStatus { Pending, InTransit, Received, Cancelled }When the transfer is received at the destination branch, the same ProcessReturnAsync logic runs, treating it as though the copy was just returned at the destination branch — this naturally triggers the reservation assignment.
Lessons Learned
1. Separate catalog identity from inventory identity early. Many library systems conflate "book" (the intellectual work) with "copy" (the physical item). This causes chaos when you need to answer "how many copies of this book are available across all branches" versus "what is the condition of copy 00472 at the North Branch?"
2. Distributed locks must be ISBN-scoped, not system-wide. A single global lock on reservation assignment would serialise all returns across all branches. Lock granularity should match the contention domain — reservations for "Harry Potter" do not conflict with reservations for "Clean Code".
3. Event sourcing for fines pays for itself within months. The first time a bug in fine calculation is discovered and you need to retroactively correct balances, having a ledger of immutable events means you can replay with corrected logic. Mutable balance columns require ad-hoc SQL updates that are hard to audit and easy to get wrong.
4. The CQRS split for catalog was worth the complexity. The write side and read side can now be scaled and optimised independently. When the library added a kiosk self-service system that hammers the catalog search API, adding a read replica and routing the CatalogSearchService connection there required zero changes to the write path.
5. RFID idempotency is non-negotiable. Kiosks sometimes send duplicate scan events due to network retries. Every RFID handler must check current state before acting — the Loan.Return() method being idempotent and the FineCalculator.RecordReturnFineAsync checking for existing events prevents duplicate charges.
Enjoyed this article?
Explore the System Design learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.