Course outline

C# / Advanced: Lifetimes, Concurrency, and Runtime Design / Lesson 16

API Design, Immutability, and Compatibility

Define what callers may retain, mutate, await, and inspect so type-correct changes do not silently break behavioral contracts.

About 5 min readContent reviewed
On this page

Lesson goals

  • Specify ownership and snapshot depth.
  • Choose validation and error categories at public boundaries.
  • Review compatibility beyond method signatures.

An API promises behavior as well as types

A public contract includes accepted values, null meaning, ordering, exceptions, mutation, concurrency, cancellation, and resource ownership. Changing any of those can break callers even if method signatures remain identical. Put the promise near the exported operation and keep implementation comments focused on how the invariant is preserved.

Avoid exposing every internal collection or helper. A small surface is easier to test and evolve. Interfaces should come from real consumer requirements rather than speculative future implementations. Constructor side effects need clear ownership: silently starting a worker without a completion handle creates an obligation the caller cannot manage.

Copy the data the snapshot promises

The first program copies integer input into private storage and exposes a read-only wrapper over that private array. Mutating the original list cannot alter the captured values. For int elements, copying the elements establishes the desired independence. A snapshot of mutable object references would need a different element policy.

The wrapper restricts normal caller mutation through its interface; the guarantee also depends on not leaking another mutable alias to the private array. Describe whether snapshots are taken under synchronization when the input is shared. Copying while a writer changes the source is not automatically a coherent snapshot.

csharp A privately owned integer snapshot
using System;
using System.Collections.Generic;
using System.Linq;

var source = new List<int> { 1, 2 };
var snapshot = new Snapshot(source);
source[0] = 9;
Console.WriteLine(string.Join(",", snapshot.Values));

internal sealed class Snapshot
{
    public IReadOnlyList<int> Values { get; }
    public Snapshot(IEnumerable<int> values)
    {
        Values = Array.AsReadOnly(values.ToArray());
    }
}
Expected output
1,2

The constructor captures one and two before the source is modified.

Represent invalid input without accidental ambiguity

Choose exceptions for exceptional contract failure and explicit results for expected rejection when that fits the API. A Boolean Try operation must define its output on false. A result record can carry a category and value, but avoid invalid combinations such as success with no required value unless explicitly meaningful. Type design should reduce states callers must guess about.

The second example keeps parsing and domain checking together behind a Try contract. It fixes culture and numeric style for a machine-readable count. A positive sign or surrounding whitespace is deliberately rejected by NumberStyles.None; that is a format choice, not a universal preference for every user-facing form.

csharp A strict bounded count contract
using System;
using System.Globalization;

bool accepted = TryCount("42", out int count);
Console.WriteLine($"accepted={accepted} count={count}");
Console.WriteLine($"suffix={TryCount("42x", out _)}");

static bool TryCount(string text, out int count)
{
    count = 0;
    if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int value)) return false;
    if (value > 100) return false;
    count = value;
    return true;
}
Expected output
accepted=True count=42
suffix=False

The complete decimal record42 is accepted, while42x fails syntax instead of being partially accepted.

An apparently safer change can still break consumers

Changing a live view to a snapshot, adding an exception category, changing an error message clients wrongly parse, or changing null-versus-empty behavior deserves compatibility review. Some changes improve design while still needing a migration. Document stable machine-readable categories so callers do not depend on incidental implementation text.

Adding overloads can alter overload resolution; changing optional-parameter defaults can affect newly compiled callers differently from existing compiled ones. Review source and binary compatibility under the actual distribution model. This course does not promise that every API edit is safe merely because the current project compiles.

Make asynchronous and disposal promises explicit

A Task-returning API should define when borrowed input is no longer used and whether cancellation can leave partial effects. An IDisposable result should identify the owner and whether methods remain valid after disposal. A stream parameter should state whether it is borrowed, retained, or closed. These rules are often more consequential than naming conventions.

Test the public contract through realistic consumer shapes: retaining a result, modifying original input, repeated enumeration, cancellation after commit, and disposal after completion. Keep tests independent of private fields when possible. A small complete contract makes both implementation review and later security/performance changes easier to assess.

Common errors and fixes

A read-only result changes through an input alias.

Why it happens
The API exposed a view instead of its promised snapshot.
How to fix it
Copy under the intended ownership and synchronization policy.
Prevent it next time
Test mutation after construction.

A Try method returns false but callers use stale assumptions.

Why it happens
Failure output semantics were undocumented.
How to fix it
Define and test output meaning on false.
Prevent it next time
Keep success and failure result handling explicit.

A signature-compatible release breaks cancellation handling.

Why it happens
Behavioral compatibility was omitted from review.
How to fix it
Preserve or deliberately migrate the documented cancellation categories.
Prevent it next time
Include lifetime behavior in release tests.

Check your understanding

1. An API returns IEnumerable<Customer> but callers require a stable independent report. What contract and implementation questions follow?

Show worked solution

Decide whether report membership, ordering, and customer field values are all captured. Materialize under a valid source lifetime and synchronization policy, then project immutable report values or copy required fields. Returning a list alone only captures outer references. Document size bounds and errors, and test mutation of source objects after the report is created.

2. Why should a library not dispose every Stream it receives just to ensure cleanup?

Show worked solution

Receiving a parameter does not imply ownership transfer. The caller may need the stream for later operations, and disposing it can violate composition. Specify borrowing or ownership explicitly and choose wrapper leaveOpen behavior accordingly. The opener often owns disposal, while a library that creates and returns a new stream must document who closes that result.

Key takeaways

  • Behavioral compatibility includes ownership, timing, nullness, and errors.
  • Read-only exposure and independent immutable data are separate guarantees.
  • Use contracts that make invalid states and lifetime obligations visible.

Official references