Table of Contents

HTTP Client Integration

Level: Beginner | Time: 20-30 minutes

Learn how to use HttpClient with Railway-Oriented Programming for functional HTTP communication with automatic error handling.

Overview

The FunctionalDDD.Http package provides extension methods for HttpResponseMessage that integrate seamlessly with Railway-Oriented Programming patterns. Instead of dealing with exceptions and manual status code checks, you get clean, composable operations that return Result<T>.

What you'll learn:

  • ✅ Handle specific HTTP status codes (401, 403, 404, 409) functionally
  • ✅ Handle error ranges (all 4xx or 5xx) with custom factories
  • ✅ Deserialize JSON responses to Result<T> or Result<Maybe<T>>
  • ✅ Chain HTTP operations with other ROP operations
  • ✅ Work with CancellationToken for proper cancellation support

Installation

dotnet add package FunctionalDDD.Http

Quick Start

Basic JSON Deserialization

using FunctionalDdd;
using System.Net.Http.Json;

// Define your JSON context for AOT compatibility
[JsonSerializable(typeof(User))]
internal partial class UserJsonContext : JsonSerializerContext { }

public async Task<Result<User>> GetUserAsync(string userId, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/users/{userId}", ct)
        .HandleNotFoundAsync(Error.NotFound($"User {userId} not found"))
        .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);
}

Handle Multiple Status Codes

public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
    return await _httpClient.PostAsJsonAsync("api/orders", request, ct)
        .HandleUnauthorizedAsync(Error.Unauthorized("Please login to create orders"))
        .HandleForbiddenAsync(Error.Forbidden("You don't have permission to create orders"))
        .HandleConflictAsync(Error.Conflict("Order already exists"))
        .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct);
}

Status Code Handlers

Specific Status Code Handlers

The library provides handlers for common HTTP status codes that map to specific error types:

Handler Status Code Error Type Use Case
HandleNotFound 404 NotFoundError Resource doesn't exist
HandleUnauthorized 401 UnauthorizedError Authentication required
HandleForbidden 403 ForbiddenError Insufficient permissions
HandleConflict 409 ConflictError Resource already exists or state conflict

Example:

var result = await _httpClient.GetAsync($"api/products/{productId}", ct)
    .HandleNotFoundAsync(Error.NotFound("Product", productId))
    .HandleUnauthorizedAsync(Error.Unauthorized("Please login"))
    .ReadResultFromJsonAsync(ProductJsonContext.Default.Product, ct);

// Result will be:
// - Success<Product> if status is 200 and JSON deserializes
// - Failure<NotFoundError> if status is 404
// - Failure<UnauthorizedError> if status is 401
// - Success with response if other status codes (passes through)

Range-Based Handlers

Handle entire ranges of status codes with custom error factories:

HandleClientError (4xx)

Handles all client error responses (400-499):

var result = await _httpClient.PostAsJsonAsync("api/orders", order, ct)
    .HandleClientErrorAsync(statusCode => statusCode switch
    {
        HttpStatusCode.BadRequest => Error.BadRequest("Invalid order data"),
        HttpStatusCode.NotFound => Error.NotFound("Endpoint not found"),
        HttpStatusCode.Conflict => Error.Conflict("Order already exists"),
        _ => Error.Unexpected($"Client error: {statusCode}")
    })
    .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct);

HandleServerError (5xx)

Handles all server error responses (500+):

var result = await _httpClient.GetAsync("api/data", ct)
    .HandleServerErrorAsync(statusCode => 
        Error.ServiceUnavailable($"API is experiencing issues: {statusCode}"))
    .ReadResultFromJsonAsync(DataJsonContext.Default.Data, ct);

EnsureSuccess

Functional alternative to HttpResponseMessage.EnsureSuccessStatusCode() that returns a Result instead of throwing an exception:

// Default error for non-success status codes
var result = await _httpClient.DeleteAsync($"api/items/{id}", ct)
    .EnsureSuccessAsync()
    .TapAsync(response => _logger.LogInformation("Deleted item {Id}", id));

// Custom error factory
var result = await _httpClient.PutAsJsonAsync($"api/users/{userId}", updateData, ct)
    .EnsureSuccessAsync(statusCode => 
        Error.Unexpected($"Update failed with status {statusCode}"))
    .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);

JSON Deserialization

ReadResultFromJsonAsync

Deserializes JSON to Result<T>. Returns an error if the response body is null:

public async Task<Result<User>> GetUserAsync(string userId, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/users/{userId}", ct)
        .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);
}

// If response is 200 with JSON body → Success<User>
// If response is 200 with null body → Failure<UnexpectedError>
// If response is non-success (4xx, 5xx) → Failure<UnexpectedError>

ReadResultMaybeFromJsonAsync

Deserializes JSON to Result<Maybe<T>>. Null responses become Maybe.None instead of errors:

public async Task<Result<Maybe<Profile>>> GetOptionalProfileAsync(string userId, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/users/{userId}/profile", ct)
        .ReadResultMaybeFromJsonAsync(ProfileJsonContext.Default.Profile, ct)
        .TapAsync(maybe =>
        {
            if (maybe.HasValue)
                _logger.LogInformation("Profile found: {Name}", maybe.Value.Name);
            else
                _logger.LogInformation("No profile available");
        });
}

// If response is 200 with JSON body → Success<Maybe<Profile>> with value
// If response is 200 with null body → Success<Maybe<Profile>> with no value
// If response is non-success (4xx, 5xx) → Failure<UnexpectedError>

Composing HTTP Calls

Chaining Multiple Status Handlers

public async Task<Result<Order>> PlaceOrderAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    return await _httpClient.PostAsJsonAsync("api/orders", request, ct)
        .HandleUnauthorizedAsync(Error.Unauthorized("Please login to place orders"))
        .HandleForbiddenAsync(Error.Forbidden("Your account cannot place orders"))
        .HandleConflictAsync(Error.Conflict("Order already exists"))
        .HandleClientErrorAsync(code => Error.BadRequest($"Invalid order data: {code}"))
        .HandleServerErrorAsync(code => Error.ServiceUnavailable($"Order service unavailable: {code}"))
        .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct)
        .TapAsync(order => _logger.LogInformation("Order {OrderId} created", order.Id));
}

// Handlers are evaluated in order - first match wins
// Remaining handlers are skipped once a status code matches

Integration with Railway-Oriented Programming

HTTP calls compose naturally with other ROP operations:

public async Task<Result<OrderConfirmation>> ProcessOrderWorkflowAsync(
    string orderId,
    CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/orders/{orderId}", ct)
        .HandleNotFoundAsync(Error.NotFound("Order", orderId))
        .HandleUnauthorizedAsync(Error.Unauthorized("Please login"))
        .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct)
        .EnsureAsync(order => order.Status == "Pending",
            Error.Validation("Only pending orders can be processed"))
        .BindAsync((order, token) => ValidateInventoryAsync(order, token), ct)
        .BindAsync((order, token) => ProcessPaymentAsync(order, token), ct)
        .TapAsync((order, token) => SendConfirmationEmailAsync(order, token), ct)
        .MapAsync(order => new OrderConfirmation(order.Id, order.Total));
}

Parallel HTTP Calls

Fetch data from multiple endpoints in parallel:

public async Task<Result<Dashboard>> GetDashboardAsync(string userId, CancellationToken ct)
{
    var userTask = _httpClient.GetAsync($"api/users/{userId}", ct)
        .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);

    var ordersTask = _httpClient.GetAsync($"api/users/{userId}/orders", ct)
        .ReadResultFromJsonAsync(OrderListJsonContext.Default.OrderList, ct);

    var preferencesTask = _httpClient.GetAsync($"api/users/{userId}/preferences", ct)
        .ReadResultMaybeFromJsonAsync(PreferencesJsonContext.Default.Preferences, ct);

    return await userTask
        .ParallelAsync(ordersTask)
        .ParallelAsync(preferencesTask)
        .AwaitAsync()
        .MapAsync((user, orders, preferencesAsync) => new Dashboard(
            user,
            orders,
            preferences.GetValueOrDefault(Preferences.Default)));
}

Custom Error Handling

HandleFailureAsync

For complex error handling scenarios where you need to inspect the response:

public async Task<Result<Order>> CreateOrderWithCustomErrorsAsync(
    CreateOrderRequest request,
    CancellationToken ct)
{
    return await _httpClient.PostAsJsonAsync("api/orders", request, ct)
        .HandleFailureAsync(
            async (response, context, cancellationToken) =>
            {
                var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
                var statusCode = response.StatusCode;

                return statusCode switch
                {
                    HttpStatusCode.BadRequest => Error.Validation($"Invalid order: {errorBody}"),
                    HttpStatusCode.Conflict => Error.Conflict("Duplicate order detected"),
                    HttpStatusCode.ServiceUnavailable => Error.ServiceUnavailable("Order service is down"),
                    _ => Error.Unexpected($"Order creation failed ({statusCode}): {errorBody}")
                };
            },
            context: null,
            ct)
        .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct);
}

Best Practices

1. Use JSON Source Generators

Always use JSON source generators for AOT compatibility and better performance:

// Define once per assembly
[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Product))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext { }

// Use everywhere
var user = await _httpClient.GetAsync("api/users/123", ct)
    .ReadResultFromJsonAsync(AppJsonContext.Default.User, ct);

2. Handle Expected Errors Explicitly

Use specific handlers for expected error scenarios:

// ✅ Good - Explicit handling of expected errors
var result = await _httpClient.GetAsync($"api/users/{userId}", ct)
    .HandleNotFoundAsync(Error.NotFound("User not found"))
    .HandleUnauthorizedAsync(Error.Unauthorized("Please login"))
    .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);

// ❌ Avoid - Generic catch-all for expected scenarios
var result = await _httpClient.GetAsync($"api/users/{userId}", ct)
    .EnsureSuccessAsync()  // Too broad
    .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct);

3. Always Pass CancellationToken

Support graceful cancellation and timeouts:

public async Task<Result<Data>> FetchDataAsync(string id, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/data/{id}", ct)
        .HandleNotFoundAsync(Error.NotFound("Data", id))
        .ReadResultFromJsonAsync(DataJsonContext.Default.Data, ct)
        .TapAsync((data, token) => CacheDataAsync(data, token), ct);  // Pass through
}

4. Use Range Handlers for Fallbacks

Catch unexpected client/server errors after specific handlers:

var result = await _httpClient.PostAsJsonAsync("api/orders", order, ct)
    .HandleConflictAsync(Error.Conflict("Order exists"))  // Specific
    .HandleUnauthorizedAsync(Error.Unauthorized("Login required"))  // Specific
    .HandleClientErrorAsync(code => Error.BadRequest($"Client error: {code}"))  // Catch-all 4xx
    .HandleServerErrorAsync(code => Error.ServiceUnavailable($"Server error: {code}"))  // Catch-all 5xx
    .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct);

5. Combine with Retry Policies

Use Polly for retry logic (don't reinvent it):

using Polly;
using Polly.Extensions.Http;

// Configure HttpClient with Polly
services.AddHttpClient<IOrderService, OrderService>()
    .AddPolicyHandler(GetRetryPolicy());

static IAsyncPolicy<HttpResponseMessage> GetRetryPolicy()
{
    return HttpPolicyExtensions
        .HandleTransientHttpError()
        .WaitAndRetryAsync(3, retryAttempt => 
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)));
}

// Then use FunctionalDDD for functional error handling
public async Task<Result<Order>> GetOrderAsync(string orderId, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/orders/{orderId}", ct)  // Polly handles retries
        .HandleNotFoundAsync(Error.NotFound("Order", orderId))  // FunctionalDDD handles errors
        .ReadResultFromJsonAsync(OrderJsonContext.Default.Order, ct);
}

Complete Example

Here's a complete service that demonstrates all HTTP integration patterns:

using FunctionalDdd;
using System.Net.Http.Json;
using System.Text.Json.Serialization;

[JsonSerializable(typeof(User))]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderList))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class ApiJsonContext : JsonSerializerContext { }

public class OrderApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger<OrderApiClient> _logger;

    public OrderApiClient(HttpClient httpClient, ILogger<OrderApiClient> logger)
    {
        _httpClient = httpClient;
        _logger = logger;
    }

    /// <summary>
    /// Get user with specific error handling
    /// </summary>
    public async Task<Result<User>> GetUserAsync(string userId, CancellationToken ct)
    {
        return await _httpClient.GetAsync($"api/users/{userId}", ct)
            .HandleNotFoundAsync(Error.NotFound($"User {userId} not found"))
            .HandleUnauthorizedAsync(Error.Unauthorized("Authentication required"))
            .ReadResultFromJsonAsync(ApiJsonContext.Default.User, ct)
            .TapAsync(user => _logger.LogInformation("Retrieved user: {UserId}", user.Id));
    }

    /// <summary>
    /// Create order with comprehensive error handling
    /// </summary>
    public async Task<Result<Order>> CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
    {
        return await _httpClient.PostAsJsonAsync("api/orders", request, ApiJsonContext.Default.CreateOrderRequest, ct)
            .HandleUnauthorizedAsync(Error.Unauthorized("Please login to create orders"))
            .HandleForbiddenAsync(Error.Forbidden("Your account cannot place orders"))
            .HandleConflictAsync(Error.Conflict("Order already exists"))
            .HandleClientErrorAsync(code => Error.BadRequest($"Invalid order data: {code}"))
            .HandleServerErrorAsync(code => Error.ServiceUnavailable($"Order service unavailable: {code}"))
            .ReadResultFromJsonAsync(ApiJsonContext.Default.Order, ct)
            .TapAsync(order => _logger.LogInformation("Order {OrderId} created", order.Id));
    }

    /// <summary>
    /// Get optional profile using Maybe pattern
    /// </summary>
    public async Task<Result<Maybe<UserProfile>>> GetOptionalProfileAsync(string userId, CancellationToken ct)
    {
        return await _httpClient.GetAsync($"api/users/{userId}/profile", ct)
            .ReadResultMaybeFromJsonAsync(ApiJsonContext.Default.UserProfile, ct)
            .TapAsync(maybe =>
            {
                if (maybe.HasValue)
                    _logger.LogInformation("Profile found for user {UserId}", userId);
                else
                    _logger.LogInformation("No profile for user {UserId}", userId);
            });
    }

    /// <summary>
    /// Complex workflow with multiple operations
    /// </summary>
    public async Task<Result<OrderConfirmation>> ProcessOrderWorkflowAsync(
        string orderId,
        CancellationToken ct)
    {
        return await GetOrderAsync(orderId, ct)
            .EnsureAsync(order => order.Status == "Pending",
                Error.Validation("Only pending orders can be processed"))
            .BindAsync((order, token) => ValidateInventoryAsync(order, token), ct)
            .BindAsync((order, token) => ProcessPaymentAsync(order, token), ct)
            .TapAsync((order, token) => SendConfirmationEmailAsync(order, token), ct)
            .MapAsync(order => new OrderConfirmation(order.Id, order.Total));
    }

    private async Task<Result<Order>> GetOrderAsync(string orderId, CancellationToken ct)
    {
        return await _httpClient.GetAsync($"api/orders/{orderId}", ct)
            .HandleNotFoundAsync(Error.NotFound("Order", orderId))
            .ReadResultFromJsonAsync(ApiJsonContext.Default.Order, ct);
    }

    private async Task<Result<Order>> ValidateInventoryAsync(Order order, CancellationToken ct)
    {
        return await _httpClient.PostAsJsonAsync($"api/orders/{order.Id}/validate-inventory", order, ct)
            .HandleConflictAsync(Error.Conflict("Insufficient inventory"))
            .ReadResultFromJsonAsync(ApiJsonContext.Default.Order, ct);
    }

    private async Task<Result<Order>> ProcessPaymentAsync(Order order, CancellationToken ct)
    {
        return await _httpClient.PostAsJsonAsync($"api/orders/{order.Id}/process-payment", order, ct)
            .HandleClientErrorAsync(code => Error.BadRequest("Payment failed"))
            .ReadResultFromJsonAsync(ApiJsonContext.Default.Order, ct);
    }

    private async Task SendConfirmationEmailAsync(Order order, CancellationToken ct)
    {
        await _httpClient.PostAsync($"api/notifications/order-confirmation/{order.Id}", null, ct);
    }
}

Comparison with Traditional Approach

Before (Traditional Exception-Based)

public async Task<User> GetUserAsync(string userId, CancellationToken ct)
{
    try
    {
        var response = await _httpClient.GetAsync($"api/users/{userId}", ct);
        
        if (response.StatusCode == HttpStatusCode.NotFound)
            throw new NotFoundException($"User {userId} not found");
            
        if (response.StatusCode == HttpStatusCode.Unauthorized)
            throw new UnauthorizedException("Please login");
            
        response.EnsureSuccessStatusCode();  // Throws for other errors
        
        var user = await response.Content.ReadFromJsonAsync<User>(ct);
        
        if (user == null)
            throw new InvalidOperationException("Response was null");
            
        _logger.LogInformation("Retrieved user: {UserId}", user.Id);
        return user;
    }
    catch (HttpRequestException ex)
    {
        _logger.LogError(ex, "HTTP request failed");
        throw;
    }
    catch (JsonException ex)
    {
        _logger.LogError(ex, "JSON deserialization failed");
        throw;
    }
}

After (Functional with Result)

public async Task<Result<User>> GetUserAsync(string userId, CancellationToken ct)
{
    return await _httpClient.GetAsync($"api/users/{userId}", ct)
        .HandleNotFoundAsync(Error.NotFound($"User {userId} not found"))
        .HandleUnauthorizedAsync(Error.Unauthorized("Please login"))
        .ReadResultFromJsonAsync(UserJsonContext.Default.User, ct)
        .TapAsync(user => _logger.LogInformation("Retrieved user: {UserId}", user.Id));
}

Benefits:

  • No exceptions - All errors are values in the type system
  • Composable - Chain with other ROP operations
  • Explicit - Return type shows this can fail
  • Concise - 60% less code
  • Type-safe - Compiler enforces error handling

Next Steps

  1. Explore ASP.NET Core Integration to convert Result back to HTTP responses
  2. Learn Error Handling for working with different error types
  3. Master Working with Async Operations for proper cancellation support
  4. See Examples for more real-world patterns

API Reference

For complete API documentation, see: