With the requirements and planning from the previous lesson now closed, this lesson assembles the complete Iteration 2: BiblioTech's API. No new domain logic is written — all the behavior already exists in Library, in ILibraryRepository, and in its implementations; the work consists of organizing that code into a multi-project solution structure, correctly registering dependencies in ASP.NET Core's DI container (picking back up from 07-03 and 08-03), and exposing one endpoint for each of the seven functional requirements defined in Lesson 2.

Contents

  1. Solution structure: several .NET projects
  2. BiblioTech.Domain: the model already built, unchanged
  3. BiblioTech.Persistence: ILibraryRepository and its four implementations
  4. BiblioTech.Api: registering services in the DI container
  5. Endpoints: catalog, members, loans, returns, metadata
  6. Complete Program.cs
  7. Applying patterns and standards already covered

  1. Solution structure: several .NET projects

Until now, every lesson in the course worked with a single console project, or a single project for whatever technology was in play. A real project of this size is better organized into several separate .NET projects, grouped into a solution (.sln), each with its own responsibility — the same single-responsibility principle from 08-01, now applied at the project level instead of the class level:

dotnet new sln -n BiblioTech

dotnet new classlib -n BiblioTech.Domain
dotnet new classlib -n BiblioTech.Persistence
dotnet new webapi -n BiblioTech.Api
dotnet new xunit -n BiblioTech.Tests

dotnet sln add BiblioTech.Domain BiblioTech.Persistence BiblioTech.Api BiblioTech.Tests

dotnet add BiblioTech.Persistence reference BiblioTech.Domain
dotnet add BiblioTech.Api reference BiblioTech.Domain BiblioTech.Persistence
dotnet add BiblioTech.Tests reference BiblioTech.Domain BiblioTech.Persistence
flowchart TD
    Api["BiblioTech.Api<br/>(ASP.NET Core Minimal API)"] --> Persistence["BiblioTech.Persistence<br/>(ILibraryRepository + 4 implementations)"]
    Api --> Domain["BiblioTech.Domain<br/>(Library, LibraryItem, Member, Loan...)"]
    Persistence --> Domain
    Tests["BiblioTech.Tests<br/>(xUnit + Moq, Module 8)"] --> Domain
    Tests --> Persistence

The arrows in the diagram show dependency direction (dotnet add reference): Domain doesn't depend on any other project — it doesn't even know that persistence or an API exist — while Persistence and Api depend on it. This direction isn't a coincidence: it's exactly the same principle that motivated extracting ILibraryRepository in 08-03, now applied to folder and project organization, not just to classes within a single project. An optional BiblioTech.Web project (a Blazor client) would be treated exactly the same way: it would depend only on what's strictly necessary to consume the API over HTTP, without referencing BiblioTech.Domain or BiblioTech.Persistence directly (the client doesn't know those classes: it only knows the DTOs the API exposes as JSON).

  1. BiblioTech.Domain: the model already built, unchanged

This project receives, without changing a single line of behavior, the classes built in Modules 2 through 4 and the penalty policies from Module 8:

BiblioTech.Domain/
├── LibraryItem.cs      // abstract class, Module 3
├── Book.cs             // Module 3
├── Magazine.cs         // Module 3
├── Member.cs           // Module 3, with OutstandingBalance and ApplyPenalty (08-05, exercise 2)
├── Loan.cs             // Module 3, with CalculatePenalty(IPenaltyPolicy) (08-02)
├── ILendable.cs        // Module 4
├── ISearchable.cs      // Module 4
├── IPenaltyPolicy.cs   // Module 8, with FixedPenalty and ProgressivePenalty
└── Library.cs          // Module 4, with Catalog/Members/Loans and LendBookAsync

Moving these classes into their own project is, in itself, an example of "extract class" at a larger scale (08-05, section 4): the domain becomes physically separated from any persistence or web-infrastructure detail, something previously achieved only through discipline within a single project and now enforced by the solution structure itself.

  1. BiblioTech.Persistence: ILibraryRepository and its four implementations

This project receives, likewise with no change in behavior, the interface and the four classes built in 08-03:

BiblioTech.Persistence/
├── ILibraryRepository.cs
├── TextRepository.cs
├── JsonRepository.cs
├── SqliteRepository.cs
├── EntityFrameworkRepository.cs
└── LibraryDbContext.cs   // Module 5, Entity Framework Core

Lesson 2 chose Entity Framework Core as the production persistence mechanism; the other three implementations aren't deleted, they're simply not registered in BiblioTech.Api's DI container (section 4). They remain available, for example, for quick manual testing with no database, exactly as already seen in 08-03, section 7.

  1. BiblioTech.Api: registering services in the DI container

With the project structure already in place, the service registration in Program.cs is practically the same as the one built in 08-03, section 7, now with the rest of section 5's endpoints added:

var builder = WebApplication.CreateBuilder(args);

// Persistence: Entity Framework Core over SQLite, Lesson 2's decision
builder.Services.AddDbContext<LibraryDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("BiblioTech")));

// ILibraryRepository resolved as EntityFrameworkRepository (08-03)
builder.Services.AddScoped<ILibraryRepository, EntityFrameworkRepository>();

// Library depends on ILibraryRepository, same lifetime as LibraryDbContext (08-03)
builder.Services.AddScoped<Library>();

// Penalty policy: Strategy from Module 8, holds no state of its own, valid as a Singleton
builder.Services.AddSingleton<IPenaltyPolicy, FixedPenalty>();

Notice the deliberate difference from 07-03's builder.Configuration.GetConnectionString(...), where the connection string was written directly in the code: here it's read from configuration (appsettings.json), a preview of the per-environment configuration completed in Lesson 5, and a direct application of Lesson 2's basic-security non-functional requirement (not leaving production connection strings written in the source code).

  1. Endpoints: catalog, members, loans, returns, metadata

Each endpoint maps directly onto one of Lesson 2's seven functional requirements (FR1-FR7):

Endpoint Requirement What it does
GET /books FR3 (partial: listing) Returns library.Catalog
GET /books/search?q=... FR3 Filters Catalog with LINQ (Module 4)
POST /books FR1 Adds an item with AddItem
DELETE /books/{isbn} FR2 Removes an item from the catalog
POST /members FR4 Adds a member with AddMember
POST /loans FR5 Registers a loan with LendBookAsync
POST /loans/{id}/return FR6 Calls RegisterReturn() and CalculatePenalty(policy)
GET /books/{isbn}/metadata FR7 Calls GetMetadataByIsbnAsync (Module 5)

  1. Complete Program.cs

Putting all the previous pieces together, here's BiblioTech.Api's complete Program.cs:

using BiblioTech.Domain;
using BiblioTech.Persistence;
using Microsoft.EntityFrameworkCore;

var builder = WebApplication.CreateBuilder(args);

// --- Service registration (DI container, 08-03) ---
builder.Services.AddDbContext<LibraryDbContext>(options =>
    options.UseSqlite(builder.Configuration.GetConnectionString("BiblioTech")));
builder.Services.AddScoped<ILibraryRepository, EntityFrameworkRepository>();
builder.Services.AddScoped<Library>();
builder.Services.AddSingleton<IPenaltyPolicy, FixedPenalty>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(); // interactive testing UI, 07-03 section 8

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

// --- FR3: list and search the catalog ---
app.MapGet("/books", (Library library) =>
{
    library.LoadCatalog();
    return Results.Ok(library.Catalog);
});

app.MapGet("/books/search", (string q, Library library) =>
{
    library.LoadCatalog();
    List<LibraryItem> results = library.Catalog
        .Where(i => i.Title.Contains(q, StringComparison.OrdinalIgnoreCase)
                 || i.Author.Contains(q, StringComparison.OrdinalIgnoreCase))
        .ToList(); // LINQ, Module 4

    return Results.Ok(results);
});

// --- FR1: add an item ---
app.MapPost("/books", (Book book, Library library) =>
{
    library.AddItem(book);
    library.SaveCatalog();
    return Results.Created($"/books/{book.Isbn}", book);
});

// --- FR2: remove an item ---
app.MapDelete("/books/{isbn}", (string isbn, Library library) =>
{
    library.LoadCatalog();
    LibraryItem? item = library.Catalog
        .FirstOrDefault(i => i is Book book && book.Isbn == isbn);

    if (item is null)
    {
        return Results.NotFound($"No book exists with ISBN {isbn}.");
    }

    library.Catalog.Remove(item);
    library.SaveCatalog();
    return Results.NoContent();
});

// --- FR4: add a member ---
app.MapPost("/members", (Member member, Library library) =>
{
    library.AddMember(member);
    return Results.Created($"/members/{member.Id}", member);
});

// --- FR5: register a loan ---
app.MapPost("/loans", async (LoanRequest request, Library library) =>
{
    Book? book = library.Catalog.OfType<Book>()
        .FirstOrDefault(b => b.Isbn == request.Isbn);
    Member? member = library.FindMemberById(request.MemberId);

    if (book is null || member is null)
    {
        return Results.BadRequest("ISBN or member not found.");
    }

    try
    {
        await library.LendBookAsync(book, member); // validates availability and persists (08-05)
    }
    catch (InvalidOperationException ex)
    {
        return Results.Conflict(ex.Message);
    }

    return Results.Ok(library.Loans.Last());
});

// --- FR6: register a return, with penalty calculation ---
app.MapPost("/loans/{id}/return", (int id, Library library, IPenaltyPolicy policy) =>
{
    Loan? loan = library.Loans.ElementAtOrDefault(id);

    if (loan is null)
    {
        return Results.NotFound($"Loan #{id} doesn't exist.");
    }

    loan.RegisterReturn();
    decimal penalty = loan.CalculatePenalty(policy);

    if (penalty > 0)
    {
        loan.Member.ApplyPenalty(penalty); // 08-05, exercise 2
    }

    library.SaveCatalog();
    return Results.Ok(new { loan.ReturnDate, Penalty = penalty });
});

// --- FR7: external metadata lookup ---
app.MapGet("/books/{isbn}/metadata", async (string isbn, Library library) =>
{
    ExternalBookMetadata? metadata = await library.GetMetadataByIsbnAsync(isbn);

    return metadata is not null
        ? Results.Ok(metadata)
        : Results.NotFound($"No external metadata is available for ISBN {isbn}.");
});

app.Run();

// DTOs (Module 3, record): the expected body of a POST /loans
record LoanRequest(string Isbn, int MemberId);

No endpoint contains its own business logic: each one just extracts data from the HTTP request (the same binding mechanism from 07-03), calls an existing method on Library or its dependencies, and translates the result into an HTTP response with Results.Ok/Results.NotFound/Results.Conflict. That's precisely the sign that Module 8's decoupled architecture worked as expected: the API is a thin layer over a domain that already knew how to do all of this long before.

  1. Applying patterns and standards already covered

This Program.cs, although new as a file, doesn't introduce any practice not already seen in the course:

  • Constructor/parameter injection (08-03): each endpoint receives Library, IPenaltyPolicy, or ILibraryRepository already resolved by the container, never creating them with new.
  • Strategy (08-02): IPenaltyPolicy lets you switch from FixedPenalty to ProgressivePenalty with a single line in the service registration, without touching the return endpoint.
  • Exception handling as expected flow control (Module 2, 08-05): the try/catch around InvalidOperationException in POST /loans translates an expected domain error (book unavailable) into a concrete HTTP code (409 Conflict), instead of letting it propagate as a generic 500 error.
  • Consistent naming (08-01): LoanRequest, AddItem, SaveCatalog follow exactly the same conventions already established in Module 8's Lesson 1.

Common Mistakes and Tips

  • Referencing BiblioTech.Api from BiblioTech.Domain: this would invert the dependency direction from section 1's diagram; the domain must never know the web layer that consumes it exists.
  • Registering IPenaltyPolicy as AddScoped instead of AddSingleton: FixedPenalty holds no state of its own between calls (08-03, section 4), so it can be safely shared for the whole application's lifetime; using a shorter lifetime than necessary isn't incorrect, but wastes AddSingleton's reason for existing.
  • Writing business logic directly inside an endpoint (for example, calculating the penalty by hand instead of calling loan.CalculatePenalty(policy)): this would duplicate logic that already exists and is already tested (Module 8, Lesson 4), breaking the main advantage of reusing the existing domain.
  • Tip: if an endpoint starts exceeding 4-5 lines of its own logic (not counting parameter extraction or the translation to Results.*), that's a "long method" signal (08-05) at the endpoint level: a fragment probably needs extracting into a Library method.

Exercises

  1. Add a GET /members/{id} endpoint that returns a specific member's data using library.FindMemberById(id), returning 404 Not Found if it doesn't exist.

  2. The POST /loans/{id}/return endpoint from section 6 uses library.Loans.ElementAtOrDefault(id) to locate the loan by its position in the list. Explain why this is fragile in a real system, and what you would change in the Loan model to solve it more robustly.

Solutions

app.MapGet("/members/{id}", (int id, Library library) =>
{
    Member? member = library.FindMemberById(id);

    return member is not null
        ? Results.Ok(member)
        : Results.NotFound($"No member exists with Id {id}.");
});

Using the position within library.Loans as if it were an identifier is fragile because that position changes if any loan is removed from the list, or if the insertion order varies (for example, when reloading the catalog from the repository); two different requests could end up unintentionally referring to different loans. The robust solution is to add its own Id property to Loan (following the same pattern Member.Id already has, Module 3), assigned uniquely when each loan is created, and to search by that Id instead of by position.

Conclusion

This lesson has assembled the complete Iteration 2 of the project: a multi-project .NET solution structure with separated responsibilities, the registration of ILibraryRepository and Library in ASP.NET Core's DI container picking back up from 07-03 and 08-03, and a complete Program.cs with one endpoint per each of Lesson 2's seven functional requirements, with no new business logic that didn't already exist in BiblioTech's domain. The API works, but it hasn't yet been verified with tests beyond Module 8's unit tests, nor reviewed against any formal quality criteria. The next lesson, Testing and Debugging, completes that work: it expands the test suite with integration tests over these same endpoints, and introduces the quality checklist that must be passed before moving on to Lesson 5, Deployment.

© Copyright 2026. All rights reserved