Course outline

C# / Intermediate: Composition and Asynchronous Work / Lesson 11

Bounded I/O and Serialization

Keep transport, encoding, syntax, domain validation, and disposal as separate parts of a reliable data boundary.

About 5 min readContent reviewed
On this page

Lesson goals

  • Handle partial reads and exact-length requirements.
  • Define size, text, and JSON validation policies.
  • Preserve stream ownership through async and parsing lifetimes.

A stream read is not a whole record

Stream.Read and ReadAsync can return fewer bytes than requested without reaching the end of the application record. A zero result for a nonempty request normally indicates end of stream under the APIโ€™s contract. Use ReadExactly when a fixed count is required, or maintain incremental parsing state rather than reading uninitialized buffer regions.

The first example reads three bytes from a known memory stream and leaves the fourth available. It exercises the exact-length contract without touching a filesystem. For an untrusted frame length, validate the maximum before allocating. Also bound total records and elapsed work, because many individually small records can still exhaust resources.

csharp An exact-length bounded record
using System;
using System.IO;
using System.Text;

using var input = new MemoryStream(Encoding.ASCII.GetBytes("ABCD"));
byte[] record = new byte[3];
input.ReadExactly(record);
Console.WriteLine($"record={Encoding.ASCII.GetString(record)} remaining={input.Length - input.Position}");
Expected output
record=ABC remaining=1

ReadExactly fills ABC and leaves one byte in the synthetic stream.

Encoding and line policy are explicit

A byte stream does not identify its own text encoding. Choose an Encoding and a policy for malformed input, byte-order marks, line endings, and maximum record size. Reading all text at once is convenient only when its size is justified. A line-reading API can still allocate a very large line unless the surrounding design enforces a limit.

String length counts UTF-16 units after decoding, so a byte limit and a text-unit limit are not interchangeable. Protocol identifiers often need ordinal comparison and invariant formatting; user-local presentation can need culture-specific behavior. Keep parsing and display decisions separate so a machineโ€™s culture does not silently alter a serialized format.

Validate a bounded synthetic JSON record

The second program deserializes one known small JSON object and checks both object presence and its quantity domain. The example uses System.Text.Json from the platform and requires no network or package. A real boundary should enforce input size before deserialization, decide unknown-field policy, and validate required-field presence rather than assuming successful decoding means valid business data.

A missing numeric property can produce its default zero, which may be indistinguishable from an explicit zero under a simple model. Use nullable fields, required-member rules, or an explicit schema policy when presence matters. Serialization settings and naming conventions are compatibility decisions that should be tested with fixed protocol vectors.

csharp A decoded object still needs domain checks
using System;
using System.Text.Json;

Order? order = JsonSerializer.Deserialize<Order>("""{"Quantity":3}""");
if (order is null || order.Quantity < 0 || order.Quantity > 100)
{
    throw new InvalidOperationException("invalid order");
}
Console.WriteLine($"quantity={order.Quantity}");

internal sealed class Order
{
    public int Quantity { get; set; }
}
Expected output
quantity=3

The fixed record contains quantity three, which satisfies the declared zero-through-one-hundred domain.

Keep the resource alive through consumption

A using scope must cover actual reading, awaiting, and deferred enumeration. Returning a task or iterator that later touches a disposed stream is a lifetime defect. Some wrappers close their underlying stream by default; choose leaveOpen intentionally when the outer caller owns the underlying resource. A borrowed Stream parameter does not automatically transfer disposal responsibility.

Check write failures and required flush/dispose results at the delivery boundary. Successful serialization into a buffer is not necessarily durable file storage. Atomic replacement and crash persistence depend on filesystem and platform behavior, so a reusable serializer should not pretend to guarantee them without an explicit storage protocol.

Test incomplete and oversized input deliberately

Keep acquisition, syntax, representation range, and domain validation separate. Reject trailing records when the format requires exactly one. Do not log an entire untrusted or secret payload just because parsing failed. Report a useful location or category while preserving the original exception for trusted diagnostics where appropriate.

Tests should cover empty input, exact maximum size, one byte beyond it, truncated binary data, malformed encoding, JSON null, absent fields, and out-of-domain values. Use synthetic streams that return small chunks to prove the reader does not assume a full buffer. Verify cleanup and unchanged committed state after every rejection.

Common errors and fixes

A record contains stale bytes after a short read.

Why it happens
The code assumed Read filled the whole buffer.
How to fix it
Process the returned count or use a justified exact-length API.
Prevent it next time
Test a stream that returns one byte per read.

Successful JSON decoding accepts an invalid order.

Why it happens
Syntax success was mistaken for domain validation.
How to fix it
Check presence, range, required fields, and policy after decoding.
Prevent it next time
Test null and absent fields separately.

An async read fails after the creating method returns.

Why it happens
The method disposed its stream before completion.
How to fix it
Await consumption within ownership or transfer the lifetime explicitly.
Prevent it next time
Test delayed consumption paths.

Check your understanding

1. A required four-byte frame has only two bytes available. What should a ReadExactly-based parser promise about the application state?

Show worked solution

It should report incomplete input rather than interpret a partial record as complete. Only commit domain state after the whole record and its validation succeed. Dispose owned resources on failure, preserve a useful truncation diagnostic, and do not inspect unfilled bytes. An incremental parser can retain partial data only under an explicitly different contract.

2. Why is a maximum string length after JsonSerializer.Deserialize insufficient as the only input-size defense?

Show worked solution

The input may already have caused large byte buffers, decoded strings, nested objects, or deep processing before that check. Enforce a transport byte limit and suitable parser depth/shape policy before or during decoding, then validate domain lengths afterward. Time and cancellation limits are separate requirements; one length check does not bound all resource use.

Key takeaways

  • Partial transfer, complete syntax, and valid domain data are different claims.
  • A size limit must precede allocations it is meant to bound.
  • Disposal ownership must span actual asynchronous and deferred consumption.

Official references