Build one explicit import boundary from smaller contracts
The capstone accepts a narrow offline HTTP import contract. It requires successful status, application/json content, at most 512 bytes read into the application buffer, a JSON array containing one to three rows, unique nonblank names and nonnegative decimal amounts. Each condition belongs to a distinct boundary. The calling code passes one cancellation owner through sending and reading. ImportHandler supplies the body through a real StreamContent and MemoryStream, so no public request is made. The result is an ImportSummary rather than a response object or a live JSON view.
Walk through the accepted two-row import
The controlled body contains two objects: A with amount 12.5 and B with amount 4.25. SendAsync uses ResponseHeadersRead, the media type matches, and repeated reads copy the body into a temporary buffer under the byte budget. JsonDocument then parses an array of length two. The first name enters the ordinal HashSet, the second name is different, and checked decimal addition produces 16.75. Import returns count two and that total. The caller prints rows:2, total:16.75 and source:offline, with invariant decimal formatting for a predictable transcript.
Return independent domain values after disposing representations
Import owns its request, response, content stream, temporary MemoryStream and JsonDocument through using declarations. These owners remain alive until the data they supply has been consumed, and exceptions still unwind their scopes. The method returns a record containing a count and decimal total, so disposal does not invalidate the result. The HashSet owns copied string values returned by JSON accessors rather than borrowing a JsonElement beyond document disposal. Keeping response objects out of the returned domain result also prevents the caller from accidentally inheriting an undocumented obligation to dispose transport resources.
Keep every limit tied to a named representation
The 512-byte rule limits bytes accumulated by this importer, not exact process memory: the underlying content, MemoryStream capacity, copied array and parsed document have their own storage. The array limit applies after parsing. Unknown object members and duplicate JSON property names are not rejected by this sample; unique row names are a different rule. Decimal overflow is a failure even when each amount is individually nonnegative. The importer is not an authentication mechanism, a complete schema system or an integration check against a real service. Preserve these limits when describing what the successful result establishes.
Practice: inject one rejection at a time
Extend the handler with independent routes for a non-success status, a non-JSON media type, malformed JSON, an empty array, four rows, a missing Amount, a negative amount and duplicate row names. Specify the expected exception or rejected-result category for each before running. Avoid one body that violates several conditions, because the first rejection can conceal a missing later check. As a hint, compare named failure categories at the adapter boundary rather than exception messages. Keep the original accepted input as a regression case so additional validation does not silently reject valid data.
Practice: add controlled cancellation and boundary cases
Test bodies exactly at the byte maximum and one byte over while keeping their JSON meaning otherwise valid, for example with controlled trailing JSON whitespace. Then use a capped readable stream with an explicit memory-based ReadAsync override to ensure acceptance does not depend on chunk size or inherited thread scheduling. Add pre-cancellation as one deterministic case, and a separately coordinated cancellation between content chunks if that phase matters. Keep the empty-array rule independent from the byte limit. For a numeric boundary case, use two individually representable decimal amounts whose sum overflows and verify that no successful ImportSummary escapes.
Review evidence without claiming a production service
A robust learning capstone composes earlier contracts without pretending that composition removes their limits. The accepted summary proves a specific offline body passed the implemented checks; it does not prove the remote service exists, that a network request was authorized or that every possible malformed input is handled as a user-friendly error. The companion projects can turn selected boundary cases into executable checks. In a production adapter, add deliberate failure translation, observability and target-environment integration tests while keeping the pure acceptance rules and resource ownership small enough to inspect independently.
- Validate status, representation, bytes, JSON shape and domain values in order.
- Return an independent result only after every required row has been accepted.
A working example
using System;
using System.Linq;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
using System.Text.Json;
using System.IO;
using System.IO.Compression;
using System.Buffers.Binary;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using var client = new HttpClient(new ImportHandler());
var result = await Import(client, CancellationToken.None);
Console.WriteLine("rows:" + result.Count); Console.WriteLine("total:" + result.Total.ToString("0.00", CultureInfo.InvariantCulture)); Console.WriteLine("source:offline");
static async Task<ImportSummary> Import(HttpClient client, CancellationToken token) {
using var request = new HttpRequestMessage(HttpMethod.Get, "https://offline.invalid/import");
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, token); response.EnsureSuccessStatusCode();
if (!string.Equals(response.Content.Headers.ContentType?.MediaType, "application/json", StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("Media type");
using Stream body = await response.Content.ReadAsStreamAsync(token); using var bytes = new MemoryStream(); byte[] buffer = new byte[16]; int read;
while ((read = await body.ReadAsync(buffer.AsMemory(), token)) != 0) { if (bytes.Length + read > 512) throw new InvalidDataException("Byte limit"); bytes.Write(buffer, 0, read); }
using var document = JsonDocument.Parse(bytes.ToArray()); JsonElement root = document.RootElement;
if (root.ValueKind != JsonValueKind.Array || root.GetArrayLength() is < 1 or > 3) throw new InvalidDataException("Array shape");
var names = new HashSet<string>(StringComparer.Ordinal); decimal total = 0; int count = 0;
foreach (JsonElement row in root.EnumerateArray()) {
if (row.ValueKind != JsonValueKind.Object || !row.TryGetProperty("Name", out var name) || name.ValueKind != JsonValueKind.String || string.IsNullOrWhiteSpace(name.GetString()) || !row.TryGetProperty("Amount", out var amount) || amount.ValueKind != JsonValueKind.Number || !amount.TryGetDecimal(out decimal value) || value < 0 || !names.Add(name.GetString()!)) throw new InvalidDataException("Invalid row");
total = checked(total + value); count++;
}
return new ImportSummary(count, total);
}
record ImportSummary(int Count, decimal Total);
sealed class ImportHandler : HttpMessageHandler {
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) {
token.ThrowIfCancellationRequested(); var content = new StreamContent(new MemoryStream(Encoding.UTF8.GetBytes("""[{"Name":"A","Amount":12.5},{"Name":"B","Amount":4.25}]"""), writable: false)); content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content });
}
}
Expected output
rows:2 total:16.75 source:offline
Trace status, representation, byte budget and row validation separately; the injected transport remains explicitly offline.