The previous two lessons gave BiblioTech a desktop interface, first with Windows Forms and then with WPF — both exclusive to Windows and meant for a single user running the application on their own computer. ASP.NET Core radically changes that model: instead of a window, it exposes BiblioTech's logic as a web service, accessible over HTTP from any client — a browser, a mobile application, another service, or the very same HttpClient you already used in Module 5 to consume an external API. This lesson introduces ASP.NET Core focusing on Minimal APIs, the most modern and direct style for defining HTTP endpoints in .NET, and builds a real BiblioTech API with operations to query the catalog and register loans.

Contents

  1. What ASP.NET Core is and what problem it solves
  2. Minimal APIs versus Controllers
  3. Creating a project with dotnet new webapi
  4. Routing and HTTP verbs: app.MapGet/app.MapPost
  5. Basic dependency injection in the services container
  6. Returning JSON: revisiting System.Text.Json from Module 5
  7. Complete example: a minimal BiblioTech API with LibraryDbContext
  8. Testing the API

  1. What ASP.NET Core is and what problem it solves

ASP.NET Core is .NET's framework for building web applications and services: from APIs that only return data (this lesson's focus) to full websites with HTML pages rendered on the server. Its central piece is the built-in web server (Kestrel), which listens for incoming HTTP requests and dispatches them to the C# code that handles them:

flowchart LR
    C["HTTP client<br/>(browser, mobile app, HttpClient...)"] -->|"GET /books"| K["Kestrel<br/>(ASP.NET Core's web server)"]
    K --> E["C# endpoint<br/>app.MapGet(...)"]
    E --> B["Library<br/>(existing domain)"]
    E -->|JSON| K
    K -->|"HTTP response"| C

Unlike Windows Forms and WPF, an ASP.NET Core application has no window or visual interface of its own: it runs as a background process (on a server, in a container, or locally during development) and responds to requests. This makes it cross-platform — it runs the same on Windows, Linux, or macOS — and well suited precisely to the role BiblioTech needed: a central point where different clients (a future Blazor app, a MAUI app, a third party) can request catalog data or register loans, with no need to duplicate the domain logic in each one of them.

  1. Minimal APIs versus Controllers

ASP.NET Core offers two styles for defining HTTP endpoints:

Minimal APIs Controllers
How endpoints are defined Lambda functions or methods registered directly on app Controller classes with methods decorated with attributes ([HttpGet], [HttpPost])
Amount of code for a simple endpoint Minimal: one line per endpoint Larger: a whole class, with its own structure
Historical origin Introduced in .NET 6 as a lighter alternative ASP.NET Core's original style (and ASP.NET MVC's, before .NET Core)
When it fits Small or medium APIs, microservices, prototypes Large APIs with many related endpoints that benefit from organization into classes

This lesson focuses on Minimal APIs for being the simplest and most direct style to get started with, and because it fits well with the size of BiblioTech's API at this stage of the course. Controllers haven't disappeared or become obsolete: for an API with dozens of endpoints organized by resource, the class-based structure of Controllers can turn out to be easier to maintain in the long run — a design decision, not a "better versus worse" hierarchy.

  1. Creating a project with dotnet new webapi

dotnet new webapi -n BiblioTech.Api --use-minimal-apis
cd BiblioTech.Api

The central file of a Minimal API is Program.cs, where everything is configured and started:

var builder = WebApplication.CreateBuilder(args);

// services are registered in the container here (section 5)

var app = builder.Build();

// endpoints are defined here (section 4)

app.Run();

WebApplication.CreateBuilder(args) prepares a builder with the default configuration (reading appsettings.json, the logging system, etc.); builder.Build() builds the application (app) from that configuration; app.Run() starts Kestrel and keeps listening for requests until the process stops. All the code that registers services goes before Build(); all the code that defines endpoints goes after it.

  1. Routing and HTTP verbs: app.MapGet/app.MapPost

Each endpoint is registered by indicating the HTTP verb, the route, and a function that handles it:

app.MapGet("/books", () =>
{
    return new List<string> { "Hopscotch", "Ficciones" }; // simplified, completed in section 7
});

app.MapGet("/books/{isbn}", (string isbn) =>
{
    // {isbn} in the route is automatically bound to the "isbn" parameter of the function
    return $"Looking up the book with ISBN {isbn}";
});

app.MapPost("/loans", (string isbn, int memberId) =>
{
    return $"Loan registered: ISBN {isbn}, member {memberId}";
});
HTTP verb ASP.NET Core method Typical use
GET app.MapGet(route, function) Querying data, without modifying anything on the server
POST app.MapPost(route, function) Creating something new (here, a loan)
PUT app.MapPut(route, function) Replacing an existing resource
DELETE app.MapDelete(route, function) Deleting a resource

{isbn} inside the route is a route parameter: ASP.NET Core automatically extracts that segment from the URL (/books/978-84-376-0495-4) and passes it as a string isbn argument to the function, with automatic type conversion if the parameter were, say, int. This parameter binding mechanism is, in spirit, analogous to WPF's data binding: both avoid having to extract and convert data manually.

  1. Basic dependency injection in the services container

ASP.NET Core comes with a built-in dependency injection container: a central registry of services (builder.Services) from which the application obtains instances, instead of each endpoint constructing its own dependencies with new:

var builder = WebApplication.CreateBuilder(args);

// Registers Library as a single shared service for the entire lifetime of the application
builder.Services.AddSingleton<Library>();

var app = builder.Build();

app.MapGet("/books", (Library library) =>
{
    // "library" arrives already built: ASP.NET Core resolves it from the container automatically
    return library.Catalog;
});

AddSingleton<Library>() tells the container: "create a single Library instance and reuse it for every request that needs it." Just declaring Library library as a parameter of the endpoint's function is enough for ASP.NET Core to resolve and inject it automatically, with no need for the endpoint to know how it was constructed. This mechanism — dependency injection — is a central pillar of modern .NET application design, and will be studied in depth in Module 8 (Best Practices and Design Patterns); for now it's enough to recognize the basic pattern: register in builder.Services, receive as a parameter in the endpoint.

  1. Returning JSON: revisiting System.Text.Json from Module 5

When an endpoint returns an object or a collection (like library.Catalog in the previous example), ASP.NET Core automatically serializes it to JSON using System.Text.Json — the same library already used in Module 5 with JsonSerializer.Serialize — with no need for the endpoint to call it explicitly:

app.MapGet("/books", (Library library) =>
{
    return library.Catalog; // ASP.NET Core serializes this list to JSON automatically
});
[
  { "title": "Hopscotch", "author": "Julio Cortazar", "available": true },
  { "title": "Ficciones", "author": "Jorge Luis Borges", "available": true }
]

By default, ASP.NET Core uses camelCase for the output JSON keys (title, not Title), the same convention seen with JsonNamingPolicy.CamelCase in Module 5 — here applied automatically by the framework, with no additional configuration. If an endpoint needs explicit control over the response's HTTP status code, it can return an IResult with Results.Ok(...), Results.NotFound(), or Results.BadRequest(...) instead of returning the object directly, as shown in section 7.

  1. Complete example: a minimal BiblioTech API with LibraryDbContext

Combining the previous pieces with the database persistence already built in Module 5 (LibraryDbContext, with Entity Framework Core), a realistic minimal API for BiblioTech:

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<LibraryDbContext>(options =>
    options.UseSqlite("Data Source=bibliotech.db"));

var app = builder.Build();

app.MapGet("/books", async (LibraryDbContext context) =>
{
    List<Book> books = await context.Books.ToListAsync(); // EF Core, Module 5
    return Results.Ok(books);
});

app.MapGet("/books/{isbn}", async (string isbn, LibraryDbContext context) =>
{
    Book? book = await context.Books.FirstOrDefaultAsync(b => b.Isbn == isbn);

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

    return Results.Ok(book);
});

app.MapPost("/loans", async (LoanRequest request, LibraryDbContext context) =>
{
    Book? book = await context.Books.FirstOrDefaultAsync(b => b.Isbn == request.Isbn);
    Member? member = await context.Members.FindAsync(request.MemberId);

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

    if (!book.Available)
    {
        return Results.BadRequest($"'{book.Title}' is already on loan.");
    }

    book.Lend(); // domain logic that already exists, Module 2
    Loan loan = new Loan(book, member);
    context.Loans.Add(loan);
    await context.SaveChangesAsync(); // EF Core, Module 5

    return Results.Created($"/loans/{loan.Id}", loan);
});

app.Run();
// LoanRequest.cs: a simple DTO (Data Transfer Object) for the POST body
record LoanRequest(string Isbn, int MemberId);

No endpoint reimplements the domain logic: GET /books and GET /books/{isbn} only query LibraryDbContext (already built in Module 5), and POST /loans calls book.Lend() exactly as the console or the Windows Forms form would. The API is, once again, a new access layer over the same domain, now reachable over HTTP instead of by keyboard or mouse click. LoanRequest is a record (Module 3) that models the JSON body expected in the POST; ASP.NET Core deserializes it automatically from the request body, with no additional code, applying the same binding mechanism from section 4.

  1. Testing the API

With the project running (dotnet run), ASP.NET Core typically exposes an interactive testing interface (Swagger/OpenAPI) at a URL like https://localhost:5001/swagger, in addition to being testable directly with curl or with HttpClient (Module 5):

curl https://localhost:5001/books

curl -X POST https://localhost:5001/loans \
  -H "Content-Type: application/json" \
  -d '{"isbn": "978-84-376-0495-4", "memberId": 1}'

-H "Content-Type: application/json" tells the server that the request body (-d) is JSON, so that ASP.NET Core deserializes it correctly into the LoanRequest parameter of the POST /loans endpoint.

Common Mistakes and Tips

  • Registering LibraryDbContext with AddSingleton instead of AddDbContext: a DbContext isn't designed to be shared as a single instance across concurrent requests; AddDbContext (used here) correctly manages creating a new instance per request.
  • Reimplementing the Available check inside the endpoint instead of delegating it to book.Lend(): this would duplicate logic already solved in the domain since Module 2, with the risk of it drifting out of sync over time.
  • Confusing Minimal APIs with "no dependency injection": Minimal APIs uses the same services container as Controllers; only how endpoints are registered changes, not how dependency injection works underneath.
  • Tip: for an API as small as this one, Minimal APIs keeps all the routing visible in Program.cs; if the number of endpoints grew a lot, it's worth grouping them into separate files with app.MapGroup(...) so as not to end up with a single, huge file.

Exercises

  1. Add a GET /members/{id} endpoint that returns the corresponding Member (with Results.Ok) or Results.NotFound if no member exists with that id.

  2. Add a POST /returns endpoint that receives a record ReturnRequest(int LoanId), looks up the corresponding Loan, calls RegisterReturn() (Module 2), and saves the changes with SaveChangesAsync(). Return Results.NotFound if the loan doesn't exist.

Solutions

app.MapGet("/members/{id}", async (int id, LibraryDbContext context) =>
{
    Member? member = await context.Members.FindAsync(id);

    if (member is null)
    {
        return Results.NotFound($"No member exists with id {id}.");
    }

    return Results.Ok(member);
});
record ReturnRequest(int LoanId);

app.MapPost("/returns", async (ReturnRequest request, LibraryDbContext context) =>
{
    Loan? loan = await context.Loans.FindAsync(request.LoanId);

    if (loan is null)
    {
        return Results.NotFound($"No loan exists with id {request.LoanId}.");
    }

    loan.RegisterReturn(); // Module 2
    await context.SaveChangesAsync();

    return Results.Ok(loan);
});

Conclusion

In this lesson BiblioTech has stopped being a single-user, single-computer application: with ASP.NET Core and Minimal APIs, its domain logic — with no changes at all — is now accessible over HTTP through GET/POST endpoints, with basic dependency injection to obtain LibraryDbContext on each request and automatic JSON serialization of the responses.

The next lesson introduces Blazor, which picks up this very API directly (or, depending on the chosen model, connects to the domain in an even more direct way) to build a web user interface with C# instead of JavaScript — closing the loop between the data binding already seen in WPF and the world of the browser.

© Copyright 2026. All rights reserved