So far, BiblioTech has saved and recovered its own state: in plain text, in JSON, and in a relational database. But no real application lives in isolation: it almost always needs to communicate with other systems over the network, typically through a REST API — a service that exposes data and operations over HTTP, with JSON as the usual exchange format. This lesson goes deeper into System.Text.Json for scenarios more complex than those seen so far, and introduces HttpClient, .NET's class for making HTTP requests, by consuming a (simulated) external service that returns additional metadata for a book given its ISBN. This closes Module 5 (Working with Data), the last piece before Module 6, dedicated to more advanced topics of the language and the platform.

Contents

  1. Review and going deeper: System.Text.Json in more complex scenarios
  2. Nested collections and naming options with JsonNamingPolicy
  3. HttpClient: the entry point to HTTP services
  4. Consuming a GET and deserializing the response
  5. Sending data with POST
  6. Best practices with HttpClient: reuse and IHttpClientFactory
  7. Handling network and HTTP errors
  8. BiblioTech queries external metadata for a book by ISBN

  1. Review and going deeper: System.Text.Json in more complex scenarios

The Serialization lesson introduced JsonSerializer.Serialize/Deserialize on simple objects and flat collections. The JSON returned by a real external API, however, usually has a richer structure: objects nested inside other objects, lists inside an object, and naming conventions that don't match PascalCase. This lesson picks up exactly those cases.

  1. Nested collections and naming options with JsonNamingPolicy

Consider a typical response from an external book-metadata API, with a nested list of genres and a nested object holding publisher data:

{
  "isbn": "978-84-376-0495-4",
  "publisher": { "name": "Sudamericana", "country": "Argentina" },
  "genres": ["Fiction", "Latin American Literature"],
  "averageRating": 4.6
}

To deserialize this structure, the nested classes are modeled to mirror the JSON as-is:

class Publisher
{
    public string Name { get; set; } = string.Empty;
    public string Country { get; set; } = string.Empty;
}

class ExternalBookMetadata
{
    public string Isbn { get; set; } = string.Empty;
    public Publisher Publisher { get; set; } = new Publisher();
    public List<string> Genres { get; set; } = new List<string>();
    public double AverageRating { get; set; }
}

JsonSerializer.Deserialize<ExternalBookMetadata>(json) automatically rebuilds both the nested object (Publisher) and the list (Genres), with no extra code: EF Core, in the previous lesson, and System.Text.Json, here, share the same philosophy of mapping entire structures by convention.

Many real APIs use camelCase (averageRating, not AverageRating) in their JSON keys, instead of the PascalCase usual for C# properties. Instead of annotating each property with [JsonPropertyName] one by one (seen in the Serialization lesson), JsonSerializerOptions.PropertyNamingPolicy applies the conversion to all properties at once:

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true
};

ExternalBookMetadata? metadata = JsonSerializer.Deserialize<ExternalBookMetadata>(json, options);
Console.WriteLine(metadata?.Publisher.Name); // "Sudamericana"

string generatedJson = JsonSerializer.Serialize(metadata, options);
// the keys are generated in camelCase: "isbn", "publisher", "genres", "averageRating"

PropertyNamingPolicy works in both directions (serializing and deserializing), and it's the recommended option over [JsonPropertyName] when an entire class follows the same naming convention; reserve [JsonPropertyName] for one-off exceptions inside a class that otherwise follows the default convention.

  1. HttpClient: the entry point to HTTP services

HttpClient (from the System.Net.Http namespace) is .NET's class for making HTTP requests: GET to fetch data, POST to send it, and the rest of the usual HTTP verbs (PUT, DELETE...). Basic usage consists of creating an instance, optionally setting a BaseAddress, and making requests against paths relative to it:

using System.Net.Http;

HttpClient client = new HttpClient
{
    BaseAddress = new Uri("https://api.bibliotech-externo.example/")
};

The System.Net.Http.Json package (included by default in modern .NET) adds extension methods that combine the HTTP request with JSON deserialization in a single call: GetFromJsonAsync<T>, PostAsJsonAsync<T>, skipping the intermediate step of reading the response body as text and deserializing it separately.

  1. Consuming a GET and deserializing the response

using System.Net.Http.Json;

ExternalBookMetadata? metadata =
    await client.GetFromJsonAsync<ExternalBookMetadata>("books/978-84-376-0495-4");

if (metadata is not null)
{
    Console.WriteLine($"Publisher: {metadata.Publisher.Name} ({metadata.Publisher.Country})");
    Console.WriteLine($"Average rating: {metadata.AverageRating}");
}

GetFromJsonAsync<T> makes the GET request, checks that the response was successful, and deserializes the JSON body directly into the given type T — the three steps that, with plain JsonSerializer and an HttpClient without the .Json extension, would need to be written separately (GetAsync, reading the body with ReadAsStringAsync, and JsonSerializer.Deserialize).

  1. Sending data with POST

To send data (for example, registering with an external service that BiblioTech has added a new book to its catalog), PostAsJsonAsync<T> serializes the given object to JSON and sends it as the request body:

class NewExternalBook
{
    public string Isbn { get; set; } = string.Empty;
    public string Title { get; set; } = string.Empty;
}

NewExternalBook newBook = new NewExternalBook
{
    Isbn = "978-84-376-0497-8",
    Title = "The Aleph"
};

HttpResponseMessage response = await client.PostAsJsonAsync("books", newBook);

if (response.IsSuccessStatusCode)
{
    Console.WriteLine("Book registered with the external service.");
}

response.IsSuccessStatusCode is true for any 2xx HTTP status code (200, 201, 204...); it's the usual way to check whether a request succeeded without needing to read the exact numeric code.

  1. Best practices with HttpClient: reuse and IHttpClientFactory

Unlike SqliteConnection or StreamReader, HttpClient should not be created with using for each request and discarded right after. Even though HttpClient also implements IDisposable, creating a new instance per request can exhaust the operating system's available sockets under load (each disposed HttpClient leaves its network connection in a closing state that takes a while to fully release):

Pattern Right for...
A single HttpClient instance, reused for the whole life of the application (or of a long-lived component) Console applications and simple scripts, like this course's
IHttpClientFactory (injected via dependency injection) ASP.NET Core applications and other scenarios with many concurrent requests (Module 7)
new HttpClient() inside a using, on every request Avoid: can exhaust available sockets under sustained load

For this course's scope — a console application like BiblioTech — it's enough to create a single HttpClient instance (for example, as a static readonly field on the class that uses it) and reuse it across all calls; IHttpClientFactory solves the same problem in a more sophisticated way in ASP.NET Core applications, where components' lifetimes are different, a topic picked back up in Module 7 (Building Applications).

  1. Handling network and HTTP errors

An HTTP request can fail in two very different ways, worth telling apart:

Type of failure Exception / symptom Example
Network failure HttpRequestException (or another network exception) The server doesn't respond, no internet connection
Error HTTP response The request completes, but with a 4xx/5xx code The resource doesn't exist (404), server error (500)
try
{
    HttpResponseMessage response = await client.GetAsync("books/nonexistent-isbn");
    response.EnsureSuccessStatusCode(); // throws HttpRequestException if the code isn't 2xx

    ExternalBookMetadata? metadata =
        await response.Content.ReadFromJsonAsync<ExternalBookMetadata>();
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Error querying the external service: {ex.Message}");
}
catch (TaskCanceledException)
{
    Console.WriteLine("The request timed out.");
}

EnsureSuccessStatusCode() turns an HTTP error code into an HttpRequestException, so it can be handled with the same try/catch as any other error (recalling Exception Handling from Module 2), instead of manually checking the numeric code on every call. TaskCanceledException can occur if the request exceeds the timeout configured on HttpClient.Timeout, a common scenario when the external service doesn't respond in time.

  1. BiblioTech queries external metadata for a book by ISBN

Putting everything above together, Library gains a method that queries a (simulated) external service — like the Task.Delay in the Asynchronous Programming lesson stood in for a slow check — to enrich a Book's information with data BiblioTech doesn't store itself:

class Library
{
    // ... Catalog, Members, Loans, earlier methods from the course unchanged ...

    private static readonly HttpClient SharedHttpClient = new HttpClient
    {
        BaseAddress = new Uri("https://api.bibliotech-externo.example/")
    };

    private static readonly JsonSerializerOptions ExternalJsonOptions = new JsonSerializerOptions
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase
    };

    public async Task<ExternalBookMetadata?> GetMetadataByIsbnAsync(string isbn)
    {
        try
        {
            HttpResponseMessage response = await SharedHttpClient.GetAsync($"books/{isbn}");
            response.EnsureSuccessStatusCode();

            return await response.Content.ReadFromJsonAsync<ExternalBookMetadata>(ExternalJsonOptions);
        }
        catch (HttpRequestException ex)
        {
            Console.WriteLine($"Could not get external metadata for '{isbn}': {ex.Message}");
            return null;
        }
    }
}
Book hopscotch = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
library.AddItem(hopscotch);

ExternalBookMetadata? metadata = await library.GetMetadataByIsbnAsync(hopscotch.Isbn);
if (metadata is not null)
{
    Console.WriteLine($"'{hopscotch.Title}' - Publisher: {metadata.Publisher.Name}, rating: {metadata.AverageRating}");
}
else
{
    Console.WriteLine($"No external metadata available for '{hopscotch.Title}'.");
}

GetMetadataByIsbnAsync follows the same pattern as LendBookAsync from the Asynchronous Programming lesson: an async Task<T> method, with its obligatory Async in the name, that wraps an I/O operation (before, a simulated Task.Delay; now, a real HTTP request) and handles its own errors by returning null when the query fails, instead of propagating the exception up to the caller.

sequenceDiagram
    participant Main as Client code
    participant Lib as Library
    participant Http as HttpClient
    participant Api as External API

    Main->>Lib: await GetMetadataByIsbnAsync(isbn)
    Lib->>Http: GetAsync("books/{isbn}")
    Http->>Api: GET /books/{isbn}
    Api-->>Http: 200 OK + JSON
    Http-->>Lib: HttpResponseMessage
    Lib->>Lib: ReadFromJsonAsync<ExternalBookMetadata>
    Lib-->>Main: ExternalBookMetadata

Common Mistakes and Tips

  • Creating a new HttpClient per request with using: under load, it exhausts the system's available sockets; reuse a single instance (or use IHttpClientFactory in ASP.NET Core applications).
  • Not telling a network failure apart from an error HTTP response: GetAsync doesn't throw an exception by itself on a 404 or a 500 — you have to call EnsureSuccessStatusCode() (or check IsSuccessStatusCode) explicitly to treat them as errors.
  • Assuming external JSON follows PascalCase: most real APIs use camelCase or snake_case; configure PropertyNamingPolicy (or [JsonPropertyName] for one-off cases) instead of assuming it will match C# property names.
  • Forgetting error handling on a network call: unlike reading a local file, an HTTP request depends on an external system that might not respond, take too long, or return an error; any code using HttpClient in a real application needs its corresponding try/catch.
  • Tip: to debug the exact JSON an external API returns before writing the destination classes, it helps to first deserialize into a generic inspection type (like JsonDocument, or even a plain string with WriteIndented) and look at its actual structure, instead of guessing the properties blindly.

Exercises

  1. Define the Publisher and ExternalBookMetadata classes as presented in this lesson. Deserialize the example JSON from section 2 using JsonNamingPolicy.CamelCase, and show Publisher.Country and the first element of Genres on the console.

  2. Write a method async Task<bool> BookExistsInExternalServiceAsync(HttpClient client, string isbn) that makes a GetAsync($"books/{isbn}") call and returns true if response.IsSuccessStatusCode is true, false otherwise, without throwing any exception for a 404.

  3. Add the GetMetadataByIsbnAsync method from this lesson to Library. Call it from an asynchronous Main for a book in the catalog, and handle both the success case (showing the publisher) and the case where the method returns null.

Solutions

string json =
    """
    {
      "isbn": "978-84-376-0495-4",
      "publisher": { "name": "Sudamericana", "country": "Argentina" },
      "genres": ["Fiction", "Latin American Literature"],
      "averageRating": 4.6
    }
    """;

var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
ExternalBookMetadata? metadata = JsonSerializer.Deserialize<ExternalBookMetadata>(json, options);

Console.WriteLine(metadata?.Publisher.Country); // "Argentina"
Console.WriteLine(metadata?.Genres[0]);          // "Fiction"
async Task<bool> BookExistsInExternalServiceAsync(HttpClient client, string isbn)
{
    try
    {
        HttpResponseMessage response = await client.GetAsync($"books/{isbn}");
        return response.IsSuccessStatusCode;
    }
    catch (HttpRequestException)
    {
        return false;
    }
}
Book book = library.Catalog.OfType<Book>().First();
ExternalBookMetadata? metadata = await library.GetMetadataByIsbnAsync(book.Isbn);

if (metadata is not null)
{
    Console.WriteLine($"Publisher of '{book.Title}': {metadata.Publisher.Name}");
}
else
{
    Console.WriteLine($"No external metadata available for '{book.Title}'.");
}

OfType<Book>() is a LINQ operator (from the LINQ lesson, Module 4) that filters a collection down to elements of a specific type — here, only the Books from the mixed Catalog, discarding the Magazines.

Conclusion

In this lesson you've gone deeper into System.Text.Json for nested structures and real naming conventions, and learned to use HttpClient to consume an external REST API: GET and POST requests, reuse best practices, and telling network errors apart from HTTP errors. Library can now enrich its catalog with information from an external service, closing out Module 5 (Working with Data): from the plain text file in the first lesson to an external REST API, by way of JSON, ADO.NET, and Entity Framework, BiblioTech has stopped being an application that only lives in memory.

Module 6 (Advanced Topics) revisits, in more depth, several tools this module has already used in passing: reflection, which is literally the mechanism that lets JsonSerializer inspect a class's properties with no manual mapping code; attributes, like [JsonPropertyName] or [JsonDerivedType] seen in the Serialization lesson; memory management and the garbage collector, which automatically frees the objects this module has been creating; and real multithreading with Thread and Parallel, which completes what async/await left sketched out in Module 4 about concurrency.

© Copyright 2026. All rights reserved