Fixing ReadFromJsonAsync List errors caused by API envelopes

Summary

ReadFromJsonAsync\<List\<T>> fails because the API wraps the array inside a data property. The deserializer expects a root JSON array, so it throws a JsonException. The fix is to deserialize to a wrapper DTO that contains the list, then extract the list for the generic repository.

Root Cause

  • The JSON payload is an object with members { data: [], message: ..., isSuccess: ... }.
  • ReadFromJsonAsync<List<T>> tells System.Text.Json to treat the entire payload as a JSON array.
  • Because the root token is an object, the serializer cannot convert it to List<T> and raises JsonException.

Why This Happens in Real Systems

  • APIs often return a standard envelope (status, message, pagination, etc.) for consistency.
  • Generic repository helpers are written assuming a bare array response for simplicity.
  • Developers forget to model the envelope, leading to mismatched expectations between client and server.

Real-World Impact

  • Runtime failures in production when the first request hits the endpoint.
  • Hidden bugs for junior developers because the exception is caught and turned into a generic error result.
  • Increased latency: each failed call may trigger retries or fallback logic.
  • Loss of telemetry: the true cause (envelope mismatch) is obscured by generic error messages.

Example or Code (if necessary and relevant)

// Envelope DTO matching the API response
public class ApiResponse
{
    public T Data { get; set; }
    public string? Message { get; set; }
    public bool IsSuccess { get; set; }
}
// Updated generic repository method
public async Task<IDataResult<List>> GetListAsync(string endpoint,
    Expression<Func>? filter = null)
{
    try
    {
        using var httpClient = new HttpClient();
        httpClient.BaseAddress = new Uri(_baseUrl.Trim());
        httpClient.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", _token);

        var response = await httpClient.GetAsync($"api/{endpoint}");
        if (!response.IsSuccessStatusCode)
            return new ErrorDataResult<List>(response.ReasonPhrase ?? "Api error");

        // Deserialize to the envelope first
        var apiResponse = await response.Content
            .ReadFromJsonAsync<ApiResponse<List>>();

        if (apiResponse?.Data == null)
            return new ErrorDataResult<List>("Deserialization failed");

        var data = apiResponse.Data;

        if (filter != null)
        {
            var compiledFilter = filter.Compile();
            data = data.Where(compiledFilter).ToList();
        }

        return new SuccessDataResult<List>(data);
    }
    catch (Exception ex)
    {
        return new ErrorDataResult<List>(ex.Message);
    }
}

How Senior Engineers Fix It

  • Model the envelope (ApiResponse<T>) once and reuse it across all generic calls.
  • Encapsulate deserialization logic in a helper method to keep repository code clean.
  • Add unit tests that feed the exact JSON payload and assert successful mapping.
  • Log the raw response on first failure to surface envelope mismatches quickly.
  • Prefer HttpClientFactory and typed clients to avoid creating a new HttpClient per request.

Why Juniors Miss It

  • They often focus on the collection type (List<T>) and overlook the surrounding JSON object.
  • Lack of experience with API envelope patterns leads to assuming a flat array response.
  • Junior code bases may hard‑code deserialization targets instead of using a reusable wrapper, making the bug hard to spot.
  • Insufficient unit testing of the HTTP layer hides the mismatch until runtime.

Leave a Comment