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
- What ASP.NET Core is and what problem it solves
- Minimal APIs versus Controllers
- Creating a project with
dotnet new webapi - Routing and HTTP verbs:
app.MapGet/app.MapPost - Basic dependency injection in the services container
- Returning JSON: revisiting
System.Text.Jsonfrom Module 5 - Complete example: a minimal BiblioTech API with
LibraryDbContext - Testing the API
- 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.
- 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.
- Creating a project with
dotnet new webapi
dotnet new webapiThe 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.
- Routing and HTTP verbs:
app.MapGet/app.MapPost
app.MapGet/app.MapPostEach 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.
- 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.
- Returning JSON: revisiting
System.Text.Json from Module 5
System.Text.Json from Module 5When 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.
- Complete example: a minimal BiblioTech API with
LibraryDbContext
LibraryDbContextCombining 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.
- 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
LibraryDbContextwithAddSingletoninstead ofAddDbContext: aDbContextisn'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
Availablecheck inside the endpoint instead of delegating it tobook.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 withapp.MapGroup(...)so as not to end up with a single, huge file.
Exercises
-
Add a
GET /members/{id}endpoint that returns the correspondingMember(withResults.Ok) orResults.NotFoundif no member exists with thatid. -
Add a
POST /returnsendpoint that receives arecord ReturnRequest(int LoanId), looks up the correspondingLoan, callsRegisterReturn()(Module 2), and saves the changes withSaveChangesAsync(). ReturnResults.NotFoundif 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.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
